diff --git a/.esprintrc b/.esprintrc new file mode 100644 index 000000000..9330e00d1 --- /dev/null +++ b/.esprintrc @@ -0,0 +1,9 @@ +{ + "paths": [ + "frontend/src/**/*.js" + ], + "ignored": [ + "**/node_modules/**/*" + ], + "port": 5004 +} diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..ad5884817 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +save-prefix="" diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 000000000..fdd705c63 --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +save-prefix "" diff --git a/build.sh b/build.sh index 77526e0fb..cede3c535 100755 --- a/build.sh +++ b/build.sh @@ -90,16 +90,16 @@ Build() RunGulp() { - echo "##teamcity[progressStart 'npm install']" - npm-cache install npm || CheckExitCode npm install --no-optional --no-bin-links - echo "##teamcity[progressFinish 'npm install']" + ProgressStart 'yarn install' + yarn install + ProgressEnd 'yarn install' echo "##teamcity[progressStart 'Running gulp']" CheckExitCode npm run build echo "##teamcity[progressFinish 'Running gulp']" echo "##teamcity[progressStart 'Running gulp (phantom)']" - CheckExitCode npm run build -- --phantom --production + CheckExitCode yarn run build -- --production echo "##teamcity[progressFinish 'Running gulp (phantom)']" } diff --git a/frontend/.csscomb.json b/frontend/.csscomb.json new file mode 100644 index 000000000..a82e49732 --- /dev/null +++ b/frontend/.csscomb.json @@ -0,0 +1,25 @@ +{ + "remove-empty-rulesets": true, + "always-semicolon": true, + "color-case": "lower", + "block-indent": " ", + "color-shorthand": false, + "element-case": "lower", + "eof-newline": true, + "leading-zero": true, + "quotes": "double", + "sort-order-fallback": "abc", + "space-before-colon": "", + "space-after-colon": " ", + "space-before-combinator": " ", + "space-after-combinator": " ", + "space-between-declarations": "\n", + "space-before-opening-brace": " ", + "space-after-opening-brace": "\n", + "space-after-selector-delimiter": " ", + "space-before-selector-delimiter": "", + "space-before-closing-brace": "\n", + "strip-spaces": true, + "tab-size": true, + "unitless-zero": false +} diff --git a/frontend/.esformatter b/frontend/.esformatter new file mode 100644 index 000000000..600bb0751 --- /dev/null +++ b/frontend/.esformatter @@ -0,0 +1,335 @@ +{ + "indent": { + "value": " ", + "FunctionExpression": 1, + "ArrayExpression": 1, + "ObjectExpression": 1 + }, + "lineBreak": { + "value": "\n", + + "before": { + "ArrayPatternClosing": 0, + "ArrayPatternComma": 0, + "ArrayPatternOpening": 0, + "ArrowFunctionExpressionArrow": 0, + "ArrowFunctionExpressionClosingBrace": ">=1", + "ArrowFunctionExpressionOpeningBrace": 0, + "AssignmentExpression": ">=1", + "AssignmentOperator": 0, + "BlockStatement": 0, + "BreakKeyword": ">=1", + "CallExpression": -1, + "CallExpressionClosingParentheses": -1, + "CallExpressionOpeningParentheses": 0, + "CatchClosingBrace": ">=1", + "CatchKeyword": 0, + "CatchOpeningBrace": 0, + "ClassDeclaration": ">=1", + "ClassDeclarationClosingBrace": ">=1", + "ClassDeclarationOpeningBrace": 0, + "ConditionalExpression": ">=1", + "DeleteOperator": ">=1", + "DoWhileStatement": ">=1", + "DoWhileStatementClosingBrace": ">=1", + "DoWhileStatementOpeningBrace": 0, + "ElseIfStatement": 0, + "ElseIfStatementClosingBrace": ">=1", + "ElseIfStatementOpeningBrace": 0, + "ElseStatement": 0, + "ElseStatementClosingBrace": ">=1", + "ElseStatementOpeningBrace": 0, + "EmptyStatement": -1, + "EndOfFile": -1, + "FinallyClosingBrace": ">=1", + "FinallyKeyword": -1, + "FinallyOpeningBrace": 0, + "ForInStatement": ">=1", + "ForInStatementClosingBrace": ">=1", + "ForInStatementExpressionClosing": 0, + "ForInStatementExpressionOpening": 0, + "ForInStatementOpeningBrace": 0, + "ForStatement": ">=1", + "ForStatementClosingBrace": ">=1", + "ForStatementExpressionClosing": "<2", + "ForStatementExpressionOpening": 0, + "ForStatementOpeningBrace": 0, + "FunctionDeclaration": ">=1", + "FunctionDeclarationClosingBrace": ">=1", + "FunctionDeclarationOpeningBrace": 0, + "FunctionExpression": 0, + "FunctionExpressionClosingBrace": 1, + "FunctionExpressionOpeningBrace":0, + "IIFEClosingParentheses": 0, + "IfStatement": ">=1", + "IfStatementClosingBrace": ">=1", + "IfStatementOpeningBrace": 0, + "LogicalExpression": -1, + "MemberExpressionClosing": 0, + "MemberExpressionOpening": 0, + "MemberExpressionPeriod": -1, + "MethodDefinition": ">=1", + "ObjectExpressionClosingBrace": "<=1", + "ObjectPatternClosingBrace": 0, + "ObjectPatternComma": 0, + "ObjectPatternOpeningBrace": 0, + "ParameterDefault": 0, + "Property": "<=2", + "PropertyValue": 0, + "ReturnStatement": -1, + "SwitchClosingBrace": ">=1", + "SwitchOpeningBrace": 0, + "ThisExpression": -1, + "ThrowStatement": ">=1", + "TryClosingBrace": ">=1", + "TryKeyword": -1, + "TryOpeningBrace": 0, + "VariableDeclaration": ">=1", + "VariableDeclarationSemiColon": 0, + "VariableDeclarationWithoutInit": ">=1", + "VariableName": ">=1", + "VariableValue": 0, + "WhileStatement": ">=1", + "WhileStatementClosingBrace": ">=1", + "WhileStatementOpeningBrace": 0 + }, + + "after": { + "ArrayPatternClosing": 0, + "ArrayPatternComma": 0, + "ArrayPatternOpening": 0, + "ArrowFunctionExpressionArrow": 0, + "ArrowFunctionExpressionClosingBrace": -1, + "ArrowFunctionExpressionOpeningBrace": ">=1", + "AssignmentExpression": ">=1", + "AssignmentOperator": 0, + "BlockStatement": 0, + "BreakKeyword": -1, + "CallExpression": -1, + "CallExpressionClosingParentheses": -1, + "CallExpressionOpeningParentheses": -1, + "CatchClosingBrace": ">=0", + "CatchKeyword": 0, + "CatchOpeningBrace": ">=1", + "ClassDeclaration": ">=1", + "ClassDeclarationClosingBrace": ">=1", + "ClassDeclarationOpeningBrace": ">=1", + "ConditionalExpression": ">=1", + "DeleteOperator": ">=1", + "DoWhileStatement": ">=1", + "DoWhileStatementClosingBrace": 0, + "DoWhileStatementOpeningBrace": ">=1", + "ElseIfStatement": ">=1", + "ElseIfStatementClosingBrace": ">=1", + "ElseIfStatementOpeningBrace": ">=1", + "ElseStatement": ">=1", + "ElseStatementClosingBrace": ">=1", + "ElseStatementOpeningBrace": ">=1", + "EmptyStatement": -1, + "FinallyClosingBrace": ">=1", + "FinallyKeyword": -1, + "FinallyOpeningBrace": ">=1", + "ForInStatement": ">=1", + "ForInStatementClosingBrace": ">=1", + "ForInStatementExpressionClosing": -1, + "ForInStatementExpressionOpening": "<2", + "ForInStatementOpeningBrace": ">=1", + "ForStatement": ">=1", + "ForStatementClosingBrace": ">=1", + "ForStatementExpressionClosing": -1, + "ForStatementExpressionOpening": "<2", + "ForStatementOpeningBrace": ">=1", + "FunctionDeclaration": ">=1", + "FunctionDeclarationClosingBrace": ">=1", + "FunctionDeclarationOpeningBrace": ">=1", + "FunctionExpression": 0, + "FunctionExpressionClosingBrace": -1, + "FunctionExpressionOpeningBrace": 1, + "IIFEOpeningParentheses": 0, + "IfStatement": ">=1", + "IfStatementClosingBrace": ">=1", + "IfStatementOpeningBrace": ">=1", + "LogicalExpression": -1, + "MemberExpressionClosing": 0, + "MemberExpressionOpening": 0, + "MemberExpressionPeriod": 0, + "MethodDefinition": ">=1", + "ObjectExpressionOpeningBrace": "<=1", + "ObjectPatternClosingBrace": 0, + "ObjectPatternComma": 0, + "ObjectPatternOpeningBrace": 0, + "ParameterDefault": 0, + "Property": -1, + "PropertyName": 0, + "ReturnStatement": -1, + "SwitchCaseColon": ">=1", + "SwitchClosingBrace": ">=1", + "SwitchOpeningBrace": ">=1", + "ThisExpression": 0, + "ThrowStatement": ">=1", + "TryClosingBrace": 0, + "TryKeyword": -1, + "TryOpeningBrace": ">=1", + "VariableDeclaration": ">=1", + "VariableDeclarationSemiColon": ">=1", + "VariableValue": -1, + "WhileStatement": ">=1", + "WhileStatementClosingBrace": ">=1", + "WhileStatementOpeningBrace": ">=1" + } + }, + "whiteSpace": { + "value": " ", + "removeTrailing": 1, + "before": { + "ArgumentComma": 0, + "ArgumentList": 0, + "ArgumentListArrayExpression": 0, + "ArgumentListFunctionExpression": 1, + "ArgumentListObjectExpression": 0, + "ArrayExpressionClosing": 0, + "ArrayExpressionComma": 0, + "ArrayExpressionOpening": 1, + "AssignmentOperator": 1, + "BinaryExpression": 0, + "BinaryExpressionOperator": 1, + "BlockComment": 1, + "CallExpression": 1, + "CatchClosingBrace": 1, + "CatchKeyword": 1, + "CatchOpeningBrace": 1, + "CatchParameterList": 0, + "CommaOperator": 0, + "ConditionalExpressionAlternate": 1, + "ConditionalExpressionConsequent": 1, + "DoWhileStatementClosingBrace": 1, + "DoWhileStatementConditional": 1, + "DoWhileStatementOpeningBrace": 1, + "ElseIfStatementClosingBrace": 1, + "ElseIfStatementOpeningBrace": 1, + "ElseStatementClosingBrace": 1, + "ElseStatementOpeningBrace": 1, + "EmptyStatement": 0, + "ExpressionClosingParentheses": 0, + "FinallyClosingBrace": 1, + "FinallyKeyword": -1, + "FinallyOpeningBrace": 1, + "ForInStatement": 1, + "ForInStatementClosingBrace": 1, + "ForInStatementExpressionClosing": 0, + "ForInStatementExpressionOpening": 1, + "ForInStatementOpeningBrace": 1, + "ForStatement": 1, + "ForStatementClosingBrace": 1, + "ForStatementExpressionClosing": 0, + "ForStatementExpressionOpening": 1, + "ForStatementOpeningBrace": 1, + "ForStatementSemicolon": 0, + "FunctionDeclarationClosingBrace": 1, + "FunctionDeclarationOpeningBrace": 1, + "FunctionExpressionClosingBrace": 1, + "FunctionExpressionOpeningBrace": 1, + "IfStatementClosingBrace": 1, + "IfStatementConditionalClosing": 0, + "IfStatementConditionalOpening": 1, + "IfStatementOpeningBrace": 1, + "LineComment": 1, + "LogicalExpressionOperator": 1, + "MemberExpressionClosing": 0, + "ObjectExpressionClosingBrace": 1, + "ParameterComma": 0, + "ParameterList": 0, + "Property": 1, + "PropertyName": 1, + "PropertyValue": 1, + "SwitchDiscriminantClosing": 0, + "SwitchDiscriminantOpening": 1, + "ThrowKeyword": 1, + "TryClosingBrace": 1, + "TryKeyword": -1, + "TryOpeningBrace": 1, + "UnaryExpressionOperator": 0, + "VariableName": 1, + "VariableValue": 1, + "WhileStatementClosingBrace": 1, + "WhileStatementConditionalClosing": 0, + "WhileStatementConditionalOpening": 1, + "WhileStatementOpeningBrace": 1 + }, + "after": { + "ArgumentComma": 1, + "ArgumentList": 0, + "ArgumentListArrayExpression": 1, + "ArgumentListFunctionExpression": 1, + "ArgumentListObjectExpression": 0, + "ArrayExpressionClosing": 0, + "ArrayExpressionComma": 1, + "ArrayExpressionOpening": 0, + "AssignmentOperator": 1, + "BinaryExpression": 0, + "BinaryExpressionOperator": 1, + "BlockComment": 1, + "CallExpression": 0, + "CatchClosingBrace": 1, + "CatchKeyword": 1, + "CatchOpeningBrace": 1, + "CatchParameterList": 0, + "CommaOperator": 1, + "ConditionalExpressionConsequent": 1, + "ConditionalExpressionTest": 1, + "DoWhileStatementBody": 1, + "DoWhileStatementClosingBrace": 1, + "DoWhileStatementOpeningBrace": 1, + "ElseIfStatementClosingBrace": 1, + "ElseIfStatementOpeningBrace": 1, + "ElseStatementClosingBrace": 1, + "ElseStatementOpeningBrace": 1, + "EmptyStatement": 0, + "ExpressionOpeningParentheses": 0, + "FinallyClosingBrace": 1, + "FinallyKeyword": -1, + "FinallyOpeningBrace": 1, + "ForInStatement": 1, + "ForInStatementClosingBrace": 1, + "ForInStatementExpressionClosing": 1, + "ForInStatementExpressionOpening": 0, + "ForInStatementOpeningBrace": 1, + "ForStatement": 1, + "ForStatementClosingBrace": 1, + "ForStatementExpressionClosing": 1, + "ForStatementExpressionOpening": 0, + "ForStatementOpeningBrace": 1, + "ForStatementSemicolon": 1, + "FunctionDeclarationClosingBrace": 0, + "FunctionDeclarationOpeningBrace": 0, + "FunctionExpressionClosingBrace": 0, + "FunctionExpressionOpeningBrace": 0, + "FunctionName": 0, + "FunctionReservedWord": 0, + "IfStatementClosingBrace": 1, + "IfStatementConditionalClosing": 0, + "IfStatementConditionalOpening": 0, + "IfStatementOpeningBrace": 1, + "LogicalExpressionOperator": 1, + "MemberExpressionOpening": 0, + "ObjectExpressionClosingBrace": 0, + "ObjectExpressionOpeningBrace": 1, + "ParameterComma": 1, + "ParameterList": 0, + "PropertyName": 0, + "PropertyValue": 0, + "SwitchDiscriminantClosing": 1, + "SwitchDiscriminantOpening": 0, + "ThrowKeyword": 1, + "TryClosingBrace": 1, + "TryKeyword": -1, + "TryOpeningBrace": 1, + "UnaryExpressionOperator": 0, + "VariableName": 1, + "WhileStatementClosingBrace": 1, + "WhileStatementConditionalClosing": 1, + "WhileStatementConditionalOpening": 0, + "WhileStatementOpeningBrace": 1 + } + } +} diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 000000000..d4b43f836 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1 @@ +**/JsLibraries/** diff --git a/frontend/.eslintrc b/frontend/.eslintrc new file mode 100644 index 000000000..31b1173ec --- /dev/null +++ b/frontend/.eslintrc @@ -0,0 +1,288 @@ +{ + "parser": "babel-eslint", + + "env": { + "browser": true, + "commonjs": true, + "node": true, + "es6": true + }, + + "globals": { + "expect": false, + "chai": false, + "sinon": false + }, + + "parserOptions": { + "ecmaVersion": 6, + "sourceType": "module", + "ecmaFeatures": { + "modules": true, + "impliedStrict": true + } + }, + + "plugins": [ + "filenames", + "react" + ], + + "rules": { + "filenames/match-exported": ["error"], + + # ECMAScript 6 + + "arrow-body-style": [0], + "arrow-parens": ["error", "always"], + "arrow-spacing": ["error", { "before": true, "after": true }], + "constructor-super": "error", + "generator-star-spacing": "off", + "no-class-assign": "error", + "no-confusing-arrow": "error", + "no-const-assign": "error", + "no-dupe-class-members": "error", + "no-duplicate-imports": "error", + "no-new-symbol": "error", + "no-this-before-super": "error", + "no-useless-escape": "error", + "no-useless-computed-key": "error", + "no-useless-constructor": "error", + "no-var": "warn", + "object-shorthand": ["error", "properties"], + "prefer-arrow-callback": "error", + "prefer-const": "warn", + "prefer-reflect": "off", + "prefer-rest-params": "off", + "prefer-spread": "warn", + "prefer-template": "error", + "require-yield": "off", + "template-curly-spacing": ["error", "never"], + "yield-star-spacing": "off", + + # Possible Errors + + "comma-dangle": "error", + "no-cond-assign": "error", + "no-console": "off", + "no-constant-condition": "warn", + "no-control-regex": "error", + "no-debugger": "off", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty": "warn", + "no-empty-character-class": "error", + "no-ex-assign": "error", + "no-extra-boolean-cast": "error", + "no-extra-parens": ["error", "functions"], + "no-extra-semi": "error", + "no-func-assign": "error", + "no-inner-declarations": "error", + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-negated-in-lhs": "error", + "no-obj-calls": "error", + "no-regex-spaces": "error", + "no-sparse-arrays": "error", + "no-unexpected-multiline": "error", + "no-unreachable": "warn", + "no-unsafe-finally": "error", + "use-isnan": "error", + "valid-jsdoc": "off", + "valid-typeof": "error", + + # Best Practices + + "accessor-pairs": "off", + "array-callback-return": "warn", + "block-scoped-var": "warn", + "consistent-return": "off", + "curly": "error", + "default-case": "error", + "dot-location": ["error", "property"], + "dot-notation": "error", + "eqeqeq": ["error", "smart"], + "guard-for-in": "error", + "no-alert": "warn", + "no-caller": "error", + "no-case-declarations": "error", + "no-div-regex": "error", + "no-else-return": "error", + "no-empty-function": ["error", {"allow": ["arrowFunctions"]}], + "no-empty-pattern": "error", + "no-eval": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-fallthrough": "error", + "no-floating-decimal": "error", + "no-implicit-coercion": ["error", { + "boolean": false, + "number": true, + "string": true, + "allow": [/* "!!", "~", "*", "+" */] + }], + "no-implicit-globals": "error", + "no-implied-eval": "error", + "no-invalid-this": "off", + "no-iterator": "error", + "no-labels": "error", + "no-lone-blocks": "error", + "no-loop-func": "error", + "no-magic-numbers": ["off", {"ignoreArrayIndexes": true, "ignore": [0, 1] }], + "no-multi-spaces": "error", + "no-multi-str": "error", + "no-native-reassign": ["error", {"exceptions": ["console"]}], + "no-new": "off", + "no-new-func": "error", + "no-new-wrappers": "error", + "no-octal": "error", + "no-octal-escape": "error", + "no-param-reassign": "off", + "no-process-env": "off", + "no-proto": "error", + "no-redeclare": "error", + "no-return-assign": "warn", + "no-script-url": "error", + "no-self-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-throw-literal": "error", + "no-unmodified-loop-condition": "error", + "no-unused-expressions": "error", + "no-unused-labels": "error", + "no-useless-call": "error", + "no-useless-concat": "error", + "no-void": "error", + "no-warning-comments": "off", + "no-with": "error", + "radix": ["error", "as-needed"], + "vars-on-top": "off", + "wrap-iife": ["error", "inside"], + "yoda": "error", + + # Strict Mode + + "strict": ["error", "never"], + + # Variables + + "init-declarations": ["error", "always"], + "no-catch-shadow": "error", + "no-delete-var": "error", + "no-label-var": "error", + "no-restricted-globals": "off", + "no-shadow": "error", + "no-shadow-restricted-names": "error", + "no-undef": "error", + "no-undef-init": "off", + "no-undefined": "off", + "no-unused-vars": ["error", { "args": "none", "ignoreRestSiblings": true }], + "no-use-before-define": "error", + + # Node.js and CommonJS + + "callback-return": "warn", + "global-require": "error", + "handle-callback-err": "warn", + "no-mixed-requires": "error", + "no-new-require": "error", + "no-path-concat": "error", + "no-process-exit": "error", + + # Stylistic Issues + + "array-bracket-spacing": ["error", "never"], + "block-spacing": ["error", "always"], + "brace-style": ["error", "1tbs", { "allowSingleLine": false }], + "camelcase": "off", + "comma-spacing": ["error", {"before": false, "after": true}], + "comma-style": ["error", "last"], + "computed-property-spacing": ["error", "never"], + "consistent-this": ["error", "self"], + "eol-last": "error", + "func-names": "off", + "func-style": ["error", "declaration"], + "indent": ["error", 2, {"SwitchCase": 1}], + "key-spacing": ["error", {"beforeColon": false, "afterColon": true}], + "keyword-spacing": ["error", { "before": true, "after": true}], + "lines-around-comment": ["error", { "beforeBlockComment": true, "afterBlockComment": false }], + "max-depth": ["error", {"maximum": 5}], + "max-nested-callbacks": ["error", 4], + "max-params": ["error", 6], + "max-statements": "off", + "max-statements-per-line": ["error", { "max": 1 }], + "new-cap": ["error", {"capIsNewExceptions": ["$.Deferred", "DragDropContext", "DragLayer", "DragSource", "DropTarget"]}], + "new-parens": "error", + "newline-after-var": "off", + "newline-before-return": "off", + "newline-per-chained-call": "off", + "no-array-constructor": "error", + "no-bitwise": "error", + "no-continue": "error", + "no-inline-comments": "off", + "no-lonely-if": "warn", + "no-mixed-spaces-and-tabs": "error", + "no-multiple-empty-lines": ["error", { "max": 1 }], + "no-negated-condition": "warn", + "no-nested-ternary": "error", + "no-new-object": "error", + "no-plusplus": "off", + "no-restricted-syntax": "off", + "no-spaced-func": "error", + "no-ternary": "off", + "no-trailing-spaces": "error", + "no-underscore-dangle": ["error", { "allowAfterThis": true }], + "no-unneeded-ternary": "error", + "no-whitespace-before-property": "error", + "object-curly-spacing": ["error", "always"], + "one-var": ["error", "never"], + "one-var-declaration-per-line": ["error", "always"], + "operator-assignment": ["off", "never"], + "operator-linebreak": ["error", "after"], + "quote-props": ["error", "as-needed"], + "quotes": ["error", "single"], + "require-jsdoc": "off", + "semi": "error", + "semi-spacing": ["error", { "before": false, "after": true }], + "sort-vars": "off", + "space-before-blocks": ["error", "always"], + "space-before-function-paren": ["error", "never"], + "space-in-parens": "off", + "space-infix-ops": "off", + "space-unary-ops": "off", + "spaced-comment": "error", + "wrap-regex": "error", + + # React + + "react/jsx-boolean-value": [2, "always"], + "react/jsx-uses-vars": 2, + "react/jsx-closing-bracket-location": 2, + "react/jsx-tag-spacing": ["error"], + "react/jsx-curly-spacing": [2, "never"], + "react/jsx-equals-spacing": [2, "never"], + "react/jsx-indent-props": [2, 2], + "react/jsx-indent": [2, 2], + "react/jsx-key": 2, + "react/jsx-no-bind": [2, { "allowArrowFunctions": true }], + "react/jsx-no-duplicate-props": [2, { "ignoreCase": true }], + "react/jsx-max-props-per-line": [2, { "maximum": 2 }], + "react/jsx-handler-names": [2, { "eventHandlerPrefix": "(on|dispatch)", "eventHandlerPropPrefix": "on" }], + "react/jsx-no-undef": 2, + "react/jsx-pascal-case": 2, + "react/jsx-uses-react": 2, + // Explicitly disabled in case we want to enable them again + "react/no-did-mount-set-state": 0, + "react/no-did-update-set-state": 0, + "react/no-direct-mutation-state": 2, + "react/no-multi-comp": [2, { "ignoreStateless": true }], + "react/no-unknown-property": 2, + "react/prefer-es6-class": 2, + "react/prop-types": 2, + "react/react-in-jsx-scope": 2, + "react/self-closing-comp": 2, + "react/sort-comp": 2, + "react/jsx-wrap-multilines": 2 + } +} diff --git a/frontend/.jsbeautifyrc b/frontend/.jsbeautifyrc new file mode 100644 index 000000000..50aa6aa29 --- /dev/null +++ b/frontend/.jsbeautifyrc @@ -0,0 +1,12 @@ +{ + "js": { + "indent_size": 2, + "indent_char": " ", + "indent_level": 2, + "indent_with_tabs": false, + "preserve_newlines": true, + "brace_style": "collapse", + "max_preserve_newlines": 2, + "jslint_happy": true + } +} \ No newline at end of file diff --git a/frontend/.stylelintrc b/frontend/.stylelintrc new file mode 100644 index 000000000..5587e5d4d --- /dev/null +++ b/frontend/.stylelintrc @@ -0,0 +1,396 @@ +{ +"plugins": [ + "stylelint-order" +], +"ignoreFiles": [ + "frontend/src/Styles/scaffolding.css", + "**/*.js" +], +"rules": { + "at-rule-empty-line-before": [ + "always", + { + "except": [ + "inside-block" + ] + } + ], + "at-rule-name-case": "lower", + "at-rule-name-newline-after": "always-multi-line", + "at-rule-name-space-after": "always", + "at-rule-no-unknown": [ + true, + { + "ignoreAtRules": [ + "/^add\\-mixin$/", + "/^define\\-mixin$/" + ] + } + ], + "at-rule-no-vendor-prefix": true, + "at-rule-semicolon-newline-after": "always", + "at-rule-semicolon-space-before": "never", + "block-closing-brace-empty-line-before": "never", + "block-closing-brace-newline-after": "always", + "block-closing-brace-newline-before": "always", + "block-closing-brace-space-after": "always-single-line", + "block-closing-brace-space-before": "always-single-line", + "block-no-empty": true, + "block-opening-brace-newline-after": "always", + "block-opening-brace-newline-before": "never-single-line", + "block-opening-brace-space-after": "always-single-line", + "block-opening-brace-space-before": "always", + "color-hex-case": "lower", + "color-hex-length": "short", + "color-named": "never", + "color-no-invalid-hex": true, + "comment-whitespace-inside": "always", + "declaration-bang-space-after": "never", + "declaration-bang-space-before": "always", + "declaration-block-no-duplicate-properties": [ + true, + { + "ignoreProperties": [ + "composes" + ] + } + ], + "declaration-block-no-redundant-longhand-properties": true, + "declaration-block-no-shorthand-property-overrides": true, + "declaration-block-semicolon-newline-after": "always", + "declaration-block-semicolon-newline-before": "never-multi-line", + "declaration-block-semicolon-space-before": "never", + "declaration-block-single-line-max-declarations": 1, + "declaration-block-trailing-semicolon": "always", + "declaration-colon-space-after": "always", + "declaration-colon-space-before": "never", + "font-family-name-quotes": "always-unless-keyword", + "function-calc-no-unspaced-operator": true, + "function-comma-newline-after": "never-multi-line", + "function-comma-newline-before": "never-multi-line", + "function-comma-space-after": "always", + "function-comma-space-before": "never", + "function-linear-gradient-no-nonstandard-direction": true, + "function-name-case": "lower", + "function-parentheses-newline-inside": "never-multi-line", + "function-parentheses-space-inside": "never", + "function-url-quotes": "always", + "function-url-scheme-blacklist": [ + "data" + ], + "function-whitespace-after": "always", + "indentation": 2, + "keyframe-declaration-no-important": true, + "length-zero-no-unit": true, + "max-empty-lines": 1, + "max-line-length": [ + 100, + { + "ignore": [ + "non-comments" + ] + } + ], + "max-nesting-depth": 2, + "media-feature-colon-space-after": "always", + "media-feature-colon-space-before": "never", + "media-feature-name-case": "lower", + "media-feature-name-no-vendor-prefix": true, + "media-feature-range-operator-space-after": "always", + "media-feature-range-operator-space-before": "always", + "no-empty-source": true, + "no-eol-whitespace": true, + "no-extra-semicolons": true, + "no-invalid-double-slash-comments": true, + "no-missing-end-of-source-newline": true, + "number-leading-zero": "always", + "number-no-trailing-zeros": true, + "order/order": [ + "custom-properties", + "dollar-variables", + { + "hasBlock": false, + "name": "add-mixin", + "type": "at-rule" + }, + "declarations", + "rules", + "at-rules" + ], + "order/properties-order": [ + { + "emptyLineBefore": "always", + "properties": [ + "composes" + ] + }, + { + "emptyLineBefore": "always", + "properties": [ + "position", + "top", + "right", + "bottom", + "left", + "z-index", + "display", + "visibility", + "align-content", + "align-items", + "align-self", + "justify-content", + "flex", + "flex-direction", + "flex-order", + "flex-pack", + "flex-align", + "flex-grow", + "flex-shrink", + "flex-basis", + "flex-wrap", + "flex-flow", + "float", + "clear", + "overflow", + "overflow-x", + "overflow-y", + "-webkit-overflow-scrolling", + "clip", + "box-sizing", + "margin", + "margin-top", + "margin-right", + "margin-bottom", + "margin-left", + "padding", + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "min-width", + "min-height", + "max-width", + "max-height", + "width", + "height", + "outline", + "outline-width", + "outline-style", + "outline-color", + "outline-offset", + "border", + "border-spacing", + "border-collapse", + "border-width", + "border-style", + "border-color", + "border-top", + "border-top-width", + "border-top-style", + "border-top-color", + "border-right", + "border-right-width", + "border-right-style", + "border-right-color", + "border-bottom", + "border-bottom-width", + "border-bottom-style", + "border-bottom-color", + "border-left", + "border-left-width", + "border-left-style", + "border-left-color", + "border-radius", + "border-top-left-radius", + "border-top-right-radius", + "border-bottom-right-radius", + "border-bottom-left-radius", + "border-image", + "border-image-source", + "border-image-slice", + "border-image-width", + "border-image-outset", + "border-image-repeat", + "border-top-image", + "border-right-image", + "border-bottom-image", + "border-left-image", + "border-corner-image", + "border-top-left-image", + "border-top-right-image", + "border-bottom-right-image", + "border-bottom-left-image", + "background", + "background-color", + "background-image", + "background-attachment", + "background-position", + "background-position-x", + "background-position-y", + "background-clip", + "background-origin", + "background-size", + "background-repeat", + "box-decoration-break", + "box-shadow", + "color", + "table-layout", + "caption-side", + "empty-cells", + "list-style", + "list-style-position", + "list-style-type", + "list-style-image", + "quotes", + "content", + "counter-increment", + "counter-reset", + "-ms-writing-mode", + "vertical-align", + "text-align", + "text-align-last", + "text-decoration", + "text-emphasis", + "text-emphasis-position", + "text-emphasis-style", + "text-emphasis-color", + "text-indent", + "text-justify", + "text-outline", + "text-transform", + "text-wrap", + "text-overflow", + "text-overflow-ellipsis", + "text-overflow-mode", + "text-shadow", + "white-space", + "word-spacing", + "word-wrap", + "word-break", + "tab-size", + "hyphens", + "letter-spacing", + "font", + "font-weight", + "font-style", + "font-variant", + "font-size-adjust", + "font-stretch", + "font-size", + "font-family", + "font-smoothing", + "-moz-osx-font-smoothing", + "-webkit-font-smoothing", + "src", + "line-height", + "opacity", + "filter", + "resize", + "cursor", + "appearance", + "nav-index", + "nav-up", + "nav-right", + "nav-down", + "nav-left", + "transition", + "transition-delay", + "transition-timing-function", + "transition-duration", + "transition-property", + "transform", + "transform-origin", + "transform-style", + "backface-visibility", + "animation", + "animation-name", + "animation-duration", + "animation-play-state", + "animation-timing-function", + "animation-delay", + "animation-iteration-count", + "animation-direction", + "animation-fill-mode", + "pointer-events", + "user-select", + "touch-action", + "-webkit-tap-highlight-color", + "unicode-bidi", + "direction", + "columns", + "column-span", + "column-width", + "column-count", + "column-fill", + "column-gap", + "column-rule", + "column-rule-width", + "column-rule-style", + "column-rule-color", + "break-before", + "break-inside", + "break-after", + "page-break-before", + "page-break-inside", + "page-break-after", + "orphans", + "widows", + "zoom", + "max-zoom", + "min-zoom", + "user-zoom", + "orientation" + ] + } + ], + "property-case": "lower", + "property-no-vendor-prefix": true, + "rule-empty-line-before": [ + "always", + { + "except": [ + "first-nested" + ], + "ignore": [ + "after-comment" + ] + } + ], + "selector-attribute-brackets-space-inside": "never", + "selector-attribute-operator-space-after": "never", + "selector-attribute-operator-space-before": "never", + "selector-attribute-quotes": "never", + "selector-class-pattern": "^[A-Za-z0-9]+$", + "selector-combinator-space-after": "always", + "selector-combinator-space-before": "always", + "selector-descendant-combinator-no-non-space": true, + "selector-list-comma-newline-after": "always", + "selector-list-comma-newline-before": "never-multi-line", + "selector-list-comma-space-before": "never", + "selector-max-attribute": 0, + "selector-max-class": 3, + "selector-max-compound-selectors": 3, + "selector-max-empty-lines": 0, + "selector-max-id": 0, + "selector-max-universal": 0, + "selector-pseudo-class-case": "lower", + "selector-pseudo-class-parentheses-space-inside": "never", + "selector-pseudo-element-case": "lower", + "selector-pseudo-element-colon-notation": "double", + "selector-pseudo-element-no-unknown": true, + "selector-type-case": "lower", + "selector-type-no-unknown": true, + "shorthand-property-no-redundant-values": true, + "string-no-newline": true, + "string-quotes": "single", + "time-min-milliseconds": 100, + "unit-case": "lower", + "unit-no-unknown": true, + "value-list-comma-newline-after": "never-multi-line", + "value-list-comma-newline-before": "never-multi-line", + "value-list-comma-space-after": "always", + "value-list-comma-space-before": "never", + "value-list-max-empty-lines": 0, + "value-no-vendor-prefix": true + } +} diff --git a/frontend/.tern-project b/frontend/.tern-project new file mode 100644 index 000000000..aa9d76407 --- /dev/null +++ b/frontend/.tern-project @@ -0,0 +1,7 @@ +{ + "ecmaVersion": 6, + "libs": [ + "browser", + "jquery" + ] +} diff --git a/frontend/gulp/build.js b/frontend/gulp/build.js new file mode 100644 index 000000000..cfeb5d138 --- /dev/null +++ b/frontend/gulp/build.js @@ -0,0 +1,15 @@ +const gulp = require('gulp'); +const runSequence = require('run-sequence'); + +require('./clean'); +require('./copy'); + +gulp.task('build', () => { + return runSequence('clean', [ + 'webpack', + 'copyHtml', + 'copyFonts', + 'copyImages', + 'copyJs' + ]); +}); diff --git a/frontend/gulp/clean.js b/frontend/gulp/clean.js new file mode 100644 index 000000000..ac2e4026f --- /dev/null +++ b/frontend/gulp/clean.js @@ -0,0 +1,8 @@ +const gulp = require('gulp'); +const del = require('del'); + +const paths = require('./helpers/paths'); + +gulp.task('clean', () => { + return del([paths.dest.root]); +}); diff --git a/frontend/gulp/copy.js b/frontend/gulp/copy.js new file mode 100644 index 000000000..5b48eb755 --- /dev/null +++ b/frontend/gulp/copy.js @@ -0,0 +1,45 @@ +var path = require('path'); +var gulp = require('gulp'); +var print = require('gulp-print').default; +var cache = require('gulp-cached'); +var livereload = require('gulp-livereload'); +var paths = require('./helpers/paths.js'); + +gulp.task('copyJs', () => { + return gulp.src( + [ + path.join(paths.src.root, 'polyfills.js') + ]) + .pipe(cache('copyJs')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.root)) + .pipe(livereload()); +}); + +gulp.task('copyHtml', () => { + return gulp.src(paths.src.html) + .pipe(cache('copyHtml')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.root)) + .pipe(livereload()); +}); + +gulp.task('copyFonts', () => { + return gulp.src( + path.join(paths.src.fonts, '**', '*.*') + ) + .pipe(cache('copyFonts')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.fonts)) + .pipe(livereload()); +}); + +gulp.task('copyImages', () => { + return gulp.src( + path.join(paths.src.images, '**', '*.*') + ) + .pipe(cache('copyImages')) + .pipe(print()) + .pipe(gulp.dest(paths.dest.images)) + .pipe(livereload()); +}); diff --git a/frontend/gulp/gulpFile.js b/frontend/gulp/gulpFile.js new file mode 100644 index 000000000..821935a31 --- /dev/null +++ b/frontend/gulp/gulpFile.js @@ -0,0 +1,10 @@ +require('./build.js'); +require('./clean.js'); +require('./copy.js'); +require('./handlebars.js'); +require('./imageMin.js'); +require('./less.js'); +require('./start.js'); +require('./stripBom.js'); +require('./watch.js'); +require('./webpack.js'); diff --git a/frontend/gulp/handlebars.js b/frontend/gulp/handlebars.js new file mode 100644 index 000000000..679a0b7f6 --- /dev/null +++ b/frontend/gulp/handlebars.js @@ -0,0 +1,70 @@ +var gulp = require('gulp'); +var handlebars = require('gulp-handlebars'); +var declare = require('gulp-declare'); +var concat = require('gulp-concat'); +var wrap = require('gulp-wrap'); +var livereload = require('gulp-livereload'); +var path = require('path'); +var streamqueue = require('streamqueue'); +var stripbom = require('gulp-stripbom'); +var compliler = require('handlebars'); + +var errorHandler = require('./helpers/errorHandler'); +var paths = require('./helpers/paths.js'); + +console.log('Handlebars (gulp) Version: ', compliler.VERSION); +console.log('Handlebars (gulp) Compiler: ', compliler.COMPILER_REVISION); + +gulp.task('handlebars', () => { + var coreStream = gulp.src([ + paths.src.templates, + '!*/**/*Partial.*' + ]) + .pipe(stripbom({ + showLog: false + })) + .pipe(handlebars({ + handlebars: compliler + })) + .on('error', errorHandler) + .pipe(declare({ + namespace: 'T', + noRedeclare: true, + processName: (filePath) => { + filePath = path.relative(paths.src.root, filePath); + + return filePath.replace(/\\/g, '/') + .toLocaleLowerCase() + .replace('template', '') + .replace('.js', ''); + } + })); + + var partialStream = gulp.src([paths.src.partials]) + .pipe(stripbom({ + showLog: false + })) + .pipe(handlebars({ + handlebars: compliler + })) + .on('error', errorHandler) + .pipe(wrap('Handlebars.template(<%= contents %>)')) + .pipe(wrap('Handlebars.registerPartial(<%= processPartialName(file.relative) %>, <%= contents %>)', {}, { + imports: { + processPartialName: function(fileName) { + return JSON.stringify( + path.basename(fileName, '.js') + ); + } + } + })); + + return streamqueue({ + objectMode: true + }, + partialStream, + coreStream + ).pipe(concat('templates.js')) + .pipe(gulp.dest(paths.dest.root)) + .pipe(livereload()); +}); diff --git a/frontend/gulp/helpers/errorHandler.js b/frontend/gulp/helpers/errorHandler.js new file mode 100644 index 000000000..f3e1c113b --- /dev/null +++ b/frontend/gulp/helpers/errorHandler.js @@ -0,0 +1,6 @@ +const gulpUtil = require('gulp-util'); + +module.exports = function errorHandler(error) { + gulpUtil.log(gulpUtil.colors.red(`Error (${error.plugin}): ${error.message}`)); + this.emit('end'); +}; diff --git a/frontend/gulp/helpers/html-annotate-loader.js b/frontend/gulp/helpers/html-annotate-loader.js new file mode 100644 index 000000000..6c7ce10b8 --- /dev/null +++ b/frontend/gulp/helpers/html-annotate-loader.js @@ -0,0 +1,15 @@ +const path = require('path'); +const rootPath = path.resolve(__dirname + '/../../src/'); +module.exports = function(source) { + if (this.cacheable) { + this.cacheable(); + } + + const resourcePath = this.resourcePath.replace(rootPath, ''); + const wrappedSource =` + + ${source} + `; + + return wrappedSource; +}; diff --git a/frontend/gulp/helpers/paths.js b/frontend/gulp/helpers/paths.js new file mode 100644 index 000000000..88498d6f7 --- /dev/null +++ b/frontend/gulp/helpers/paths.js @@ -0,0 +1,26 @@ +const root = './frontend/src/'; + +const paths = { + src: { + root, + templates: root + '**/*.hbs', + html: root + '*.html', + partials: root + '**/*Partial.hbs', + scripts: root + '**/*.js', + less: [root + '**/*.less'], + content: root + 'Content/', + fonts: root + 'Content/Fonts/', + images: root + 'Content/Images/', + exclude: { + libs: `!${root}JsLibraries/**` + } + }, + dest: { + root: './_output/UI.Phantom/', + content: './_output/UI.Phantom/Content/', + fonts: './_output/UI.Phantom/Content/Fonts/', + images: './_output/UI.Phantom/Content/Images/' + } +}; + +module.exports = paths; diff --git a/frontend/gulp/helpers/phantom.js b/frontend/gulp/helpers/phantom.js new file mode 100644 index 000000000..1d696853a --- /dev/null +++ b/frontend/gulp/helpers/phantom.js @@ -0,0 +1,10 @@ +var phantom = false; +process.argv.forEach((val) => { + if (val === '--phantom') { + phantom = true; + } +}); + +console.log('Phantom:', phantom); + +module.exports = phantom; diff --git a/frontend/gulp/imageMin.js b/frontend/gulp/imageMin.js new file mode 100644 index 000000000..8988c7ad4 --- /dev/null +++ b/frontend/gulp/imageMin.js @@ -0,0 +1,15 @@ +var gulp = require('gulp'); +var print = require('gulp-print').default; +var paths = require('./helpers/paths.js'); + +gulp.task('imageMin', () => { + var imagemin = require('gulp-imagemin'); + return gulp.src(paths.src.images) + .pipe(imagemin({ + progressive: false, + optimizationLevel: 4, + svgoPlugins: [{ removeViewBox: false }] + })) + .pipe(print()) + .pipe(gulp.dest(paths.src.content + 'Images/')); +}); diff --git a/frontend/gulp/less.js b/frontend/gulp/less.js new file mode 100644 index 000000000..797cb80cf --- /dev/null +++ b/frontend/gulp/less.js @@ -0,0 +1,46 @@ +const gulp = require('gulp'); + +const less = require('gulp-less'); +const postcss = require('gulp-postcss'); +const sourcemaps = require('gulp-sourcemaps'); +const autoprefixer = require('autoprefixer'); +const livereload = require('gulp-livereload'); +const path = require('path'); + +const print = require('gulp-print'); +const paths = require('./helpers/paths'); +const errorHandler = require('./helpers/errorHandler'); + +gulp.task('less', () => { + const src = [ + path.join(paths.src.content, 'Bootstrap', 'bootstrap.less'), + path.join(paths.src.content, 'Vendor', 'vendor.less'), + path.join(paths.src.content, 'sonarr.less') + ]; + + return gulp.src(src) + .pipe(print()) + .pipe(sourcemaps.init()) + .pipe(less({ + paths: [paths.src.root], + dumpLineNumbers: 'false', + compress: true, + yuicompress: true, + ieCompat: true, + strictImports: true + })) + .on('error', errorHandler) + .pipe(postcss([autoprefixer({ + browsers: ['last 2 versions'] + })])) + .on('error', errorHandler) + + // not providing a path will cause the source map + // to be embeded. which makes livereload much happier + // since it doesn't reload the whole page to load the map. + // this should be switched to sourcemaps.write('./') for production builds + .pipe(sourcemaps.write()) + .pipe(gulp.dest(paths.dest.content)) + .on('error', errorHandler) + .pipe(livereload()); +}); diff --git a/frontend/gulp/start.js b/frontend/gulp/start.js new file mode 100644 index 000000000..eda9f0dba --- /dev/null +++ b/frontend/gulp/start.js @@ -0,0 +1,104 @@ +// will download and run sonarr (server) in a non-windows enviroment +// you can use this if you don't care about the server code and just want to work +// with the web code. + +var http = require('http'); +var gulp = require('gulp'); +var fs = require('fs'); +var targz = require('tar.gz'); +var del = require('del'); +var spawn = require('child_process').spawn; + +function download(url, dest, cb) { + console.log('Downloading ' + url + ' to ' + dest); + var file = fs.createWriteStream(dest); + http.get(url, function(response) { + response.pipe(file); + file.on('finish', function() { + console.log('Download completed'); + file.close(cb); + }); + }); +} + +function getLatest(cb) { + var branch = 'develop'; + process.argv.forEach(function(val) { + var branchMatch = /branch=([\S]*)/.exec(val); + if (branchMatch && branchMatch.length > 1) { + branch = branchMatch[1]; + } + }); + + var url = 'http://services.sonarr.tv/v1/update/' + branch + '?os=osx'; + + console.log('Checking for latest version:', url); + + http.get(url, function(res) { + var data = ''; + + res.on('data', function(chunk) { + data += chunk; + }); + + res.on('end', function() { + var updatePackage = JSON.parse(data).updatePackage; + console.log('Latest version available: ' + updatePackage.version + ' Release Date: ' + updatePackage.releaseDate); + cb(updatePackage); + }); + }).on('error', function(e) { + console.log('problem with request: ' + e.message); + }); +} + +function extract(source, dest, cb) { + console.log('extracting download page to ' + dest); + new targz().extract(source, dest, function(err) { + if (err) { + console.log(err); + } + console.log('Update package extracted.'); + cb(); + }); +} + +gulp.task('getSonarr', function() { + try { + fs.mkdirSync('./_start/'); + } catch (e) { + if (e.code !== 'EEXIST') { + throw e; + } + } + + getLatest(function(updatePackage) { + var packagePath = './_start/' + updatePackage.filename; + var dirName = './_start/' + updatePackage.version; + download(updatePackage.url, packagePath, function() { + extract(packagePath, dirName, function() { + // clean old binaries + console.log('Cleaning old binaries'); + del.sync(['./_output/*', '!./_output/UI/']); + console.log('copying binaries to target'); + gulp.src(dirName + '/NzbDrone/*.*') + .pipe(gulp.dest('./_output/')); + }); + }); + }); +}); + +gulp.task('startSonarr', function() { + var ls = spawn('mono', ['--debug', './_output/NzbDrone.exe']); + + ls.stdout.on('data', function(data) { + process.stdout.write(data); + }); + + ls.stderr.on('data', function(data) { + process.stdout.write(data); + }); + + ls.on('close', function(code) { + console.log('child process exited with code ' + code); + }); +}); diff --git a/frontend/gulp/stripBom.js b/frontend/gulp/stripBom.js new file mode 100644 index 000000000..86a20bbe9 --- /dev/null +++ b/frontend/gulp/stripBom.js @@ -0,0 +1,21 @@ +const gulp = require('gulp'); +const paths = require('./helpers/paths.js'); +const stripbom = require('gulp-stripbom'); + +function stripBom(dest) { + gulp.src([paths.src.scripts, paths.src.exclude.libs]) + .pipe(stripbom({ showLog: false })) + .pipe(gulp.dest(dest)); + + gulp.src(paths.src.less) + .pipe(stripbom({ showLog: false })) + .pipe(gulp.dest(dest)); + + gulp.src(paths.src.templates) + .pipe(stripbom({ showLog: false })) + .pipe(gulp.dest(dest)); +} + +gulp.task('stripBom', () => { + stripBom(paths.src.root); +}); diff --git a/frontend/gulp/watch.js b/frontend/gulp/watch.js new file mode 100644 index 000000000..ba1a47b66 --- /dev/null +++ b/frontend/gulp/watch.js @@ -0,0 +1,27 @@ +const gulp = require('gulp'); +const livereload = require('gulp-livereload'); +const watch = require('gulp-watch'); +const paths = require('./helpers/paths.js'); + +require('./copy.js'); +require('./webpack.js'); + +function watchTask(glob, task) { + const options = { + name: `watch: ${task}`, + verbose: true + }; + return watch(glob, options, () => { + gulp.start(task); + }); +} + +gulp.task('watch', ['copyHtml', 'copyFonts', 'copyImages', 'copyJs'], () => { + livereload.listen({ start: true }); + + gulp.start('webpackWatch'); + + watchTask(paths.src.html, 'copyHtml'); + watchTask(`${paths.src.fonts}**/*.*`, 'copyFonts'); + watchTask(`${paths.src.images}**/*.*`, 'copyImages'); +}); diff --git a/frontend/gulp/webpack.js b/frontend/gulp/webpack.js index 2593b0de4..50aefcc1a 100644 --- a/frontend/gulp/webpack.js +++ b/frontend/gulp/webpack.js @@ -1,15 +1,11 @@ -const _ = require('lodash'); const gulp = require('gulp'); -const simpleVars = require('postcss-simple-vars'); -const nested = require('postcss-nested'); -const autoprefixer = require('autoprefixer'); const webpackStream = require('webpack-stream'); const livereload = require('gulp-livereload'); const path = require('path'); const webpack = require('webpack'); const errorHandler = require('./helpers/errorHandler'); -const reload = require('require-nocache')(module); const ExtractTextPlugin = require('extract-text-webpack-plugin'); +const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); const uiFolder = 'UI'; const root = path.join(__dirname, '..', 'src'); @@ -18,66 +14,94 @@ const isProduction = process.argv.indexOf('--production') > -1; console.log('ROOT:', root); console.log('isProduction:', isProduction); -const cssVariables = [ +const cssVarsFiles = [ '../src/Styles/Variables/colors', '../src/Styles/Variables/dimensions', '../src/Styles/Variables/fonts', '../src/Styles/Variables/animations' ].map(require.resolve); +const extractCSSPlugin = new ExtractTextPlugin({ + filename: path.join('_output', uiFolder, 'Content', 'styles.css'), + allChunks: true, + disable: false, + ignoreOrder: true +}); + +const plugins = [ + extractCSSPlugin, + + new webpack.optimize.CommonsChunkPlugin({ + name: 'vendor' + }), + + new webpack.DefinePlugin({ + __DEV__: !isProduction, + 'process.env.NODE_ENV': isProduction ? JSON.stringify('production') : JSON.stringify('development') + }) +]; + +if (isProduction) { + plugins.push(new UglifyJSPlugin({ + sourceMap: true, + uglifyOptions: { + mangle: false, + output: { + comments: false, + beautify: true + } + } + })); +} + const config = { devtool: '#source-map', + stats: { children: false }, + watchOptions: { ignored: /node_modules/ }, + entry: { preload: 'preload.js', vendor: 'vendor.js', index: 'index.js' }, + resolve: { - root: [ + modules: [ root, path.join(root, 'Shims'), - path.join(root, 'JsLibraries') - ] - }, - output: { - filename: path.join('_output', uiFolder, '[name].js'), - sourceMapFilename: path.join('_output', uiFolder, '[file].map') - }, - plugins: [ - new ExtractTextPlugin(path.join('_output', uiFolder, 'Content', 'styles.css'), { allChunks: true }), - new webpack.optimize.CommonsChunkPlugin({ - name: 'vendor' - }), - new webpack.DefinePlugin({ - __DEV__: !isProduction, - 'process.env': { - NODE_ENV: isProduction ? JSON.stringify('production') : JSON.stringify('development') - } - }) - ], - resolveLoader: { - modulesDirectories: [ - 'node_modules', - 'gulp/webpack/' - ] - }, - eslint: { - formatter: function(results) { - return JSON.stringify(results); + '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: { - loaders: [ + rules: [ { test: /\.js?$/, exclude: /(node_modules|JsLibraries)/, - loader: 'babel', + loader: 'babel-loader', query: { plugins: ['transform-class-properties'], presets: ['es2015', 'decorators-legacy', 'react', 'stage-2'], @@ -93,51 +117,80 @@ const config = { { test: /\.css$/, exclude: /(node_modules|globals.css)/, - loader: ExtractTextPlugin.extract('style', 'css-loader?modules&importLoaders=1&sourceMap&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader') + use: extractCSSPlugin.extract({ + fallback: 'style-loader', + use: [ + { + loader: 'css-variables-loader', + options: { + cssVarsFiles + } + }, + { + loader: 'css-loader', + options: { + modules: true, + importLoaders: 1, + localIdentName: '[name]-[local]-[hash:base64:5]', + sourceMap: true + } + }, + { + loader: 'postcss-loader', + options: { + config: { + ctx: { + cssVarsFiles + }, + path: 'frontend/postcss.config.js' + } + } + } + ] + }) }, // Global styles { test: /\.css$/, include: /(node_modules|globals.css)/, - loader: 'style!css-loader' + use: [ + 'style-loader', + { + loader: 'css-loader' + } + ] }, // Fonts { test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/, - loader: 'url?limit=10240&mimetype=application/font-woff&emitFile=false&name=Content/Fonts/[name].[ext]' + use: [ + { + loader: 'url-loader', + options: { + limit: 10240, + mimetype: 'application/font-woff', + emitFile: false, + name: 'Content/Fonts/[name].[ext]' + } + } + ] }, + { test: /\.(ttf|eot|eot?#iefix|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, - loader: 'file-loader?emitFile=false&name=Content/Fonts/[name].[ext]' + use: [ + { + loader: 'file-loader', + options: { + emitFile: false, + name: 'Content/Fonts/[name].[ext]' + } + } + ] } ] - }, - postcss: function(wpack) { - cssVariables.forEach(wpack.addDependency); - - return [ - simpleVars({ - variables: function() { - return cssVariables.reduce(function(obj, vars) { - return _.extend(obj, reload(vars)); - }, {}); - } - }), - nested(), - autoprefixer({ - browsers: [ - 'Chrome >= 30', - 'Firefox >= 30', - 'Safari >= 6', - 'Edge >= 12', - 'Explorer >= 10', - 'iOS >= 7', - 'Android >= 4.4' - ] - }) - ]; } }; diff --git a/frontend/gulp/webpack/css-variables-loader.js b/frontend/gulp/webpack/css-variables-loader.js new file mode 100644 index 000000000..5683c98be --- /dev/null +++ b/frontend/gulp/webpack/css-variables-loader.js @@ -0,0 +1,11 @@ +const loaderUtils = require('loader-utils'); + +module.exports = function cssVariablesLoader(source) { + const options = loaderUtils.getOptions(this); + + options.cssVarsFiles.forEach((cssVarsFile) => { + this.addDependency(cssVarsFile); + }); + + return source; +}; diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 000000000..f82554ba8 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,33 @@ +const reload = require('require-nocache')(module); + +module.exports = (ctx, configPath, options) => { + const config = { + plugins: { + 'postcss-mixins': { + mixinsDir: [ + 'frontend/src/Styles/Mixins' + ] + }, + 'postcss-simple-vars': { + variables: () => + ctx.options.cssVarsFiles.reduce((acc, vars) => { + return Object.assign(acc, reload(vars)); + }, {}) + }, + 'postcss-nested': {}, + autoprefixer: { + browsers: [ + 'Chrome >= 30', + 'Firefox >= 30', + 'Safari >= 6', + 'Edge >= 12', + 'Explorer >= 11', + 'iOS >= 7', + 'Android >= 4.4' + ] + } + } + }; + + return config; +}; diff --git a/frontend/src/.vscode/settings.json b/frontend/src/.vscode/settings.json new file mode 100644 index 000000000..0fb2bf460 --- /dev/null +++ b/frontend/src/.vscode/settings.json @@ -0,0 +1,4 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "files.insertFinalNewline": true +} \ No newline at end of file diff --git a/frontend/src/Activity/Blacklist/Blacklist.js b/frontend/src/Activity/Blacklist/Blacklist.js new file mode 100644 index 000000000..e3ecd2ff7 --- /dev/null +++ b/frontend/src/Activity/Blacklist/Blacklist.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import BlacklistRowConnector from './BlacklistRowConnector'; + +class Blacklist extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + columns, + totalRecords, + isClearingBlacklistExecuting, + onClearBlacklistPress, + ...otherProps + } = this.props; + + return ( + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load blacklist
+ } + + { + isPopulated && !error && !items.length && +
+ No history blacklist +
+ } + + { + isPopulated && !error && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+
+ ); + } +} + +Blacklist.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isClearingBlacklistExecuting: PropTypes.bool.isRequired, + onClearBlacklistPress: PropTypes.func.isRequired +}; + +export default Blacklist; diff --git a/frontend/src/Activity/Blacklist/BlacklistConnector.js b/frontend/src/Activity/Blacklist/BlacklistConnector.js new file mode 100644 index 000000000..29cf3e08a --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistConnector.js @@ -0,0 +1,145 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import * as blacklistActions from 'Store/Actions/blacklistActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import Blacklist from './Blacklist'; + +function createMapStateToProps() { + return createSelector( + (state) => state.blacklist, + createCommandExecutingSelector(commandNames.CLEAR_BLACKLIST), + (blacklist, isClearingBlacklistExecuting) => { + return { + isClearingBlacklistExecuting, + ...blacklist + }; + } + ); +} + +const mapDispatchToProps = { + ...blacklistActions, + executeCommand +}; + +class BlacklistConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchBlacklist, + gotoBlacklistFirstPage + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchBlacklist(); + } else { + gotoBlacklistFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (prevProps.isClearingBlacklistExecuting && !this.props.isClearingBlacklistExecuting) { + this.props.gotoBlacklistFirstPage(); + } + } + + componentWillUnmount() { + this.props.clearBlacklist(); + unregisterPagePopulator(this.repopulate); + } + + // + // Control + + repopulate = () => { + this.props.fetchBlacklist(); + } + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoBlacklistFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoBlacklistPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoBlacklistNextPage(); + } + + onLastPagePress = () => { + this.props.gotoBlacklistLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoBlacklistPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setBlacklistSort({ sortKey }); + } + + onTableOptionChange = (payload) => { + this.props.setBlacklistTableOption(payload); + + if (payload.pageSize) { + this.props.gotoBlacklistFirstPage(); + } + } + + onClearBlacklistPress = () => { + this.props.executeCommand({ name: commandNames.CLEAR_BLACKLIST }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +BlacklistConnector.propTypes = { + isClearingBlacklistExecuting: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchBlacklist: PropTypes.func.isRequired, + gotoBlacklistFirstPage: PropTypes.func.isRequired, + gotoBlacklistPreviousPage: PropTypes.func.isRequired, + gotoBlacklistNextPage: PropTypes.func.isRequired, + gotoBlacklistLastPage: PropTypes.func.isRequired, + gotoBlacklistPage: PropTypes.func.isRequired, + setBlacklistSort: PropTypes.func.isRequired, + setBlacklistTableOption: PropTypes.func.isRequired, + clearBlacklist: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector) +); diff --git a/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js new file mode 100644 index 000000000..356512a9d --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +class BlacklistDetailsModal extends Component { + + // + // Render + + render() { + const { + isOpen, + sourceTitle, + protocol, + indexer, + message, + onModalClose + } = this.props; + + return ( + + + + Details + + + + + + + + + { + !!message && + + } + + { + !!message && + + } + + + + + + + + + ); + } +} + +BlacklistDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + sourceTitle: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + message: PropTypes.string, + onModalClose: PropTypes.func.isRequired +}; + +export default BlacklistDetailsModal; diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.css b/frontend/src/Activity/Blacklist/BlacklistRow.css new file mode 100644 index 000000000..b62d1e750 --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistRow.css @@ -0,0 +1,18 @@ +.language, +.quality { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.indexer { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 80px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.js b/frontend/src/Activity/Blacklist/BlacklistRow.js new file mode 100644 index 000000000..47859a80f --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistRow.js @@ -0,0 +1,186 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import BlacklistDetailsModal from './BlacklistDetailsModal'; +import styles from './BlacklistRow.css'; + +class BlacklistRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + series, + sourceTitle, + language, + quality, + date, + protocol, + indexer, + message, + columns, + onRemovePress + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'sourceTitle') { + return ( + + {sourceTitle} + + ); + } + + if (name === 'language') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'date') { + return ( + + ); + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'actions') { + return ( + + + + + + ); + } + + return null; + }) + } + + + + ); + } + +} + +BlacklistRow.propTypes = { + id: PropTypes.number.isRequired, + series: PropTypes.object.isRequired, + sourceTitle: PropTypes.string.isRequired, + language: PropTypes.object.isRequired, + quality: PropTypes.object.isRequired, + date: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + message: PropTypes.string, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onRemovePress: PropTypes.func.isRequired +}; + +export default BlacklistRow; diff --git a/frontend/src/Activity/Blacklist/BlacklistRowConnector.js b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js new file mode 100644 index 000000000..efba18fab --- /dev/null +++ b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { removeFromBlacklist } from 'Store/Actions/blacklistActions'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import BlacklistRow from './BlacklistRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + (series) => { + return { + series + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onRemovePress() { + dispatch(removeFromBlacklist({ id: props.id })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(BlacklistRow); diff --git a/frontend/src/Activity/History/Details/HistoryDetails.css b/frontend/src/Activity/History/Details/HistoryDetails.css new file mode 100644 index 000000000..03f8fd3ce --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.css @@ -0,0 +1,5 @@ +.description { + composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css'; + + overflow-wrap: break-word; +} diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js new file mode 100644 index 000000000..928b16bfa --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -0,0 +1,244 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import Link from 'Components/Link/Link'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; +import styles from './HistoryDetails.css'; + +function HistoryDetails(props) { + const { + eventType, + sourceTitle, + data, + shortDateFormat, + timeFormat + } = props; + + if (eventType === 'grabbed') { + const { + indexer, + releaseGroup, + nzbInfoUrl, + downloadClient, + downloadId, + age, + ageHours, + ageMinutes, + publishedDate + } = data; + + return ( + + + + { + !!indexer && + + } + + { + !!releaseGroup && + + } + + { + !!nzbInfoUrl && + + + Info URL + + + + {nzbInfoUrl} + + + } + + { + !!downloadClient && + + } + + { + !!downloadId && + + } + + { + !!indexer && + + } + + { + !!publishedDate && + + } + + ); + } + + if (eventType === 'downloadFailed') { + const { + message + } = data; + + return ( + + + + { + !!message && + + } + + ); + } + + if (eventType === 'downloadFolderImported') { + const { + droppedPath, + importedPath + } = data; + + return ( + + + + { + !!droppedPath && + + } + + { + !!importedPath && + + } + + ); + } + + if (eventType === 'episodeFileDeleted') { + const { + reason + } = data; + + let reasonMessage = ''; + + switch (reason) { + case 'Manual': + reasonMessage = 'File was deleted by via UI'; + break; + case 'MissingFromDisk': + reasonMessage = 'Sonarr was unable to find the file on disk so it was removed'; + break; + case 'Upgrade': + reasonMessage = 'File was deleted to import an upgrade'; + break; + default: + reasonMessage = ''; + } + + return ( + + + + + + ); + } + + if (eventType === 'episodeFileRenamed') { + const { + sourcePath, + sourceRelativePath, + path, + relativePath + } = data; + + return ( + + + + + + + + + + ); + } +} + +HistoryDetails.propTypes = { + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default HistoryDetails; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsAge.js b/frontend/src/Activity/History/Details/HistoryDetailsAge.js new file mode 100644 index 000000000..c702014ce --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsAge.js @@ -0,0 +1,21 @@ +var Handlebars = require('handlebars'); +var FormatHelpers = require('Shared/FormatHelpers'); + +Handlebars.registerHelper('historyAge', function() { + var age = this.age; + var unit = FormatHelpers.plural(Math.round(age), 'day'); + var ageHours = parseFloat(this.ageHours); + var ageMinutes = this.ageMinutes ? parseFloat(this.ageMinutes) : null; + + if (age < 2) { + age = ageHours.toFixed(1); + unit = FormatHelpers.plural(Math.round(ageHours), 'hour'); + } + + if (age < 2 && ageMinutes) { + age = parseFloat(ageMinutes).toFixed(1); + unit = FormatHelpers.plural(Math.round(ageMinutes), 'minute'); + } + + return new Handlebars.SafeString(`
Age (when grabbed):
${age} ${unit}
`); +}); diff --git a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js new file mode 100644 index 000000000..0848c7905 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js @@ -0,0 +1,19 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import HistoryDetails from './HistoryDetails'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return _.pick(uiSettings, [ + 'shortDateFormat', + 'timeFormat' + ]); + } + ); +} + +export default connect(createMapStateToProps)(HistoryDetails); diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.css b/frontend/src/Activity/History/Details/HistoryDetailsModal.css new file mode 100644 index 000000000..bdcb7f918 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.css @@ -0,0 +1,5 @@ +.markAsFailedButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.js new file mode 100644 index 000000000..2cf9294f6 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.js @@ -0,0 +1,104 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import HistoryDetails from './HistoryDetails'; +import styles from './HistoryDetailsModal.css'; + +function getHeaderTitle(eventType) { + switch (eventType) { + case 'grabbed': + return 'Grabbed'; + case 'downloadFailed': + return 'Download Failed'; + case 'downloadFolderImported': + return 'Episode Imported'; + case 'episodeFileDeleted': + return 'Episode File Deleted'; + case 'episodeFileRenamed': + return 'Episode File Renamed'; + default: + return 'Unknown'; + } +} + +function HistoryDetailsModal(props) { + const { + isOpen, + eventType, + sourceTitle, + data, + isMarkingAsFailed, + shortDateFormat, + timeFormat, + onMarkAsFailedPress, + onModalClose + } = props; + + return ( + + + + {getHeaderTitle(eventType)} + + + + + + + + { + eventType === 'grabbed' && + + Mark as Failed + + } + + + + + + ); +} + +HistoryDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + isMarkingAsFailed: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +HistoryDetailsModal.defaultProps = { + isMarkingAsFailed: false +}; + +export default HistoryDetailsModal; diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js new file mode 100644 index 000000000..75f504d22 --- /dev/null +++ b/frontend/src/Activity/History/History.js @@ -0,0 +1,161 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons } from 'Helpers/Props'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import HistoryRowConnector from './HistoryRowConnector'; + +class History extends Component { + + // + // Lifecycle + + shouldComponentUpdate(nextProps) { + // Don't update when fetching has completed if items have changed, + // before episodes start fetching or when episodes start fetching. + + if ( + ( + this.props.isFetching && + nextProps.isPopulated && + hasDifferentItems(this.props.items, nextProps.items) + ) || + (!this.props.isEpisodesFetching && nextProps.isEpisodesFetching) + ) { + return false; + } + + return true; + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + totalRecords, + isEpisodesFetching, + isEpisodesPopulated, + episodesError, + onFilterSelect, + onFirstPagePress, + ...otherProps + } = this.props; + + const isFetchingAny = isFetching || isEpisodesFetching; + const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length); + const hasError = error || episodesError; + + return ( + + + + + + + + + + + + + { + isFetchingAny && !isAllPopulated && + + } + + { + !isFetchingAny && hasError && +
Unable to load history
+ } + + { + // If history isPopulated and it's empty show no history found and don't + // wait for the episodes to populate because they are never coming. + + isPopulated && !hasError && !items.length && +
+ No history found +
+ } + + { + isAllPopulated && !hasError && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+
+ ); + } +} + +History.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isEpisodesFetching: PropTypes.bool.isRequired, + isEpisodesPopulated: PropTypes.bool.isRequired, + episodesError: PropTypes.object, + onFilterSelect: PropTypes.func.isRequired, + onFirstPagePress: PropTypes.func.isRequired +}; + +export default History; diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js new file mode 100644 index 000000000..35591f258 --- /dev/null +++ b/frontend/src/Activity/History/HistoryConnector.js @@ -0,0 +1,157 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; +import * as historyActions from 'Store/Actions/historyActions'; +import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions'; +import History from './History'; + +function createMapStateToProps() { + return createSelector( + (state) => state.history, + (state) => state.episodes, + (history, episodes) => { + return { + isEpisodesFetching: episodes.isFetching, + isEpisodesPopulated: episodes.isPopulated, + episodesError: episodes.error, + ...history + }; + } + ); +} + +const mapDispatchToProps = { + ...historyActions, + fetchEpisodes, + clearEpisodes +}; + +class HistoryConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchHistory, + gotoHistoryFirstPage + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchHistory(); + } else { + gotoHistoryFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + const episodeIds = selectUniqueIds(this.props.items, 'episodeId'); + + if (episodeIds.length) { + this.props.fetchEpisodes({ episodeIds }); + } else { + this.props.clearEpisodes(); + } + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearHistory(); + this.props.clearEpisodes(); + } + + // + // Control + + repopulate = () => { + this.props.fetchHistory(); + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoHistoryFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoHistoryPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoHistoryNextPage(); + } + + onLastPagePress = () => { + this.props.gotoHistoryLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoHistoryPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setHistorySort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setHistoryFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setHistoryTableOption(payload); + + if (payload.pageSize) { + this.props.gotoHistoryFirstPage(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +HistoryConnector.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchHistory: PropTypes.func.isRequired, + gotoHistoryFirstPage: PropTypes.func.isRequired, + gotoHistoryPreviousPage: PropTypes.func.isRequired, + gotoHistoryNextPage: PropTypes.func.isRequired, + gotoHistoryLastPage: PropTypes.func.isRequired, + gotoHistoryPage: PropTypes.func.isRequired, + setHistorySort: PropTypes.func.isRequired, + setHistoryFilter: PropTypes.func.isRequired, + setHistoryTableOption: PropTypes.func.isRequired, + clearHistory: PropTypes.func.isRequired, + fetchEpisodes: PropTypes.func.isRequired, + clearEpisodes: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector) +); diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.css b/frontend/src/Activity/History/HistoryEventTypeCell.css new file mode 100644 index 000000000..fac97a6c7 --- /dev/null +++ b/frontend/src/Activity/History/HistoryEventTypeCell.css @@ -0,0 +1,6 @@ +.cell { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 35px; + text-align: center; +} diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.js new file mode 100644 index 000000000..f013b3f55 --- /dev/null +++ b/frontend/src/Activity/History/HistoryEventTypeCell.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './HistoryEventTypeCell.css'; + +function getIconName(eventType) { + switch (eventType) { + case 'grabbed': + return icons.DOWNLOADING; + case 'seriesFolderImported': + return icons.DRIVE; + case 'downloadFolderImported': + return icons.DOWNLOADED; + case 'downloadFailed': + return icons.DOWNLOADING; + case 'episodeFileDeleted': + return icons.DELETE; + case 'episodeFileRenamed': + return icons.ORGANIZE; + default: + return icons.UNKNOWN; + } +} + +function getIconKind(eventType) { + switch (eventType) { + case 'downloadFailed': + return kinds.DANGER; + default: + return kinds.DEFAULT; + } +} + +function getTooltip(eventType, data) { + switch (eventType) { + case 'grabbed': + return `Episode grabbed from ${data.indexer} and sent to ${data.downloadClient}`; + case 'seriesFolderImported': + return 'Episode imported from series folder'; + case 'downloadFolderImported': + return 'Episode downloaded successfully and picked up from download client'; + case 'downloadFailed': + return 'Episode download failed'; + case 'episodeFileDeleted': + return 'Episode file deleted'; + case 'episodeFileRenamed': + return 'Episode file renamed'; + default: + return 'Unknown event'; + } +} + +function HistoryEventTypeCell({ eventType, data }) { + const iconName = getIconName(eventType); + const iconKind = getIconKind(eventType); + const tooltip = getTooltip(eventType, data); + + return ( + + + + ); +} + +HistoryEventTypeCell.propTypes = { + eventType: PropTypes.string.isRequired, + data: PropTypes.object +}; + +HistoryEventTypeCell.defaultProps = { + data: {} +}; + +export default HistoryEventTypeCell; diff --git a/frontend/src/Activity/History/HistoryRow.css b/frontend/src/Activity/History/HistoryRow.css new file mode 100644 index 000000000..83586af58 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.css @@ -0,0 +1,23 @@ +.downloadClient { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 120px; +} + +.indexer { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 80px; +} + +.releaseGroup { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 110px; +} + +.details { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 30px; +} diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js new file mode 100644 index 000000000..454913412 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.js @@ -0,0 +1,263 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import episodeEntities from 'Episode/episodeEntities'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import HistoryEventTypeCell from './HistoryEventTypeCell'; +import HistoryDetailsModal from './Details/HistoryDetailsModal'; +import styles from './HistoryRow.css'; + +class HistoryRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if ( + prevProps.isMarkingAsFailed && + !this.props.isMarkingAsFailed && + !this.props.markAsFailedError + ) { + this.setState({ isDetailsModalOpen: false }); + } + } + + // + // Listeners + + onDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + episodeId, + series, + episode, + language, + languageCutoffNotMet, + quality, + qualityCutoffNotMet, + eventType, + sourceTitle, + date, + data, + isMarkingAsFailed, + columns, + shortDateFormat, + timeFormat, + onMarkAsFailedPress + } = this.props; + + if (!episode) { + return null; + } + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'eventType') { + return ( + + ); + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'episode') { + return ( + + + + ); + } + + if (name === 'episodeTitle') { + return ( + + + + ); + } + + if (name === 'language') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'date') { + return ( + + ); + } + + if (name === 'downloadClient') { + return ( + + {data.downloadClient} + + ); + } + + if (name === 'indexer') { + return ( + + {data.indexer} + + ); + } + + if (name === 'releaseGroup') { + return ( + + {data.releaseGroup} + + ); + } + + if (name === 'details') { + return ( + + + + ); + } + + return null; + }) + } + + + + ); + } + +} + +HistoryRow.propTypes = { + episodeId: PropTypes.number, + series: PropTypes.object.isRequired, + episode: PropTypes.object, + language: PropTypes.object.isRequired, + languageCutoffNotMet: PropTypes.bool.isRequired, + quality: PropTypes.object.isRequired, + qualityCutoffNotMet: PropTypes.bool.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + date: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + isMarkingAsFailed: PropTypes.bool, + markAsFailedError: PropTypes.object, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired +}; + +export default HistoryRow; diff --git a/frontend/src/Activity/History/HistoryRowConnector.js b/frontend/src/Activity/History/HistoryRowConnector.js new file mode 100644 index 000000000..271000193 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRowConnector.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import HistoryRow from './HistoryRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createEpisodeSelector(), + createUISettingsSelector(), + (series, episode, uiSettings) => { + return { + series, + episode, + shortDateFormat: uiSettings.shortDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +const mapDispatchToProps = { + fetchHistory, + markAsFailed +}; + +class HistoryRowConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + if ( + prevProps.isMarkingAsFailed && + !this.props.isMarkingAsFailed && + !this.props.markAsFailedError + ) { + this.props.fetchHistory(); + } + } + + // + // Listeners + + onMarkAsFailedPress = () => { + this.props.markAsFailed({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } + +} + +HistoryRowConnector.propTypes = { + id: PropTypes.number.isRequired, + isMarkingAsFailed: PropTypes.bool, + markAsFailedError: PropTypes.object, + fetchHistory: PropTypes.func.isRequired, + markAsFailed: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(HistoryRowConnector); diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css b/frontend/src/Activity/Queue/ProtocolLabel.css new file mode 100644 index 000000000..15e8e4fc6 --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.css @@ -0,0 +1,13 @@ +.torrent { + composes: label from 'Components/Label.css'; + + border-color: $torrentColor; + background-color: $torrentColor; +} + +.usenet { + composes: label from 'Components/Label.css'; + + border-color: $usenetColor; + background-color: $usenetColor; +} diff --git a/frontend/src/Activity/Queue/ProtocolLabel.js b/frontend/src/Activity/Queue/ProtocolLabel.js new file mode 100644 index 000000000..e8a08943c --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Label from 'Components/Label'; +import styles from './ProtocolLabel.css'; + +function ProtocolLabel({ protocol }) { + const protocolName = protocol === 'usenet' ? 'nzb' : protocol; + + return ( + + ); +} + +ProtocolLabel.propTypes = { + protocol: PropTypes.string.isRequired +}; + +export default ProtocolLabel; diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js new file mode 100644 index 000000000..9aadc828b --- /dev/null +++ b/frontend/src/Activity/Queue/Queue.js @@ -0,0 +1,266 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import RemoveQueueItemsModal from './RemoveQueueItemsModal'; +import QueueOptionsConnector from './QueueOptionsConnector'; +import QueueRowConnector from './QueueRowConnector'; + +class Queue extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isPendingSelected: false, + isConfirmRemoveModalOpen: false + }; + } + + shouldComponentUpdate(nextProps) { + // Don't update when fetching has completed if items have changed, + // before episodes start fetching or when episodes start fetching. + + if ( + ( + this.props.isFetching && + nextProps.isPopulated && + hasDifferentItems(this.props.items, nextProps.items) + ) || + (!this.props.isEpisodesFetching && nextProps.isEpisodesFetching) + ) { + return false; + } + + return true; + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + this.setState({ selectedState: {} }); + return; + } + + const selectedIds = this.getSelectedIds(); + const isPendingSelected = _.some(this.props.items, (item) => { + return selectedIds.indexOf(item.id) > -1 && item.status === 'Delay'; + }); + + if (isPendingSelected !== this.state.isPendingSelected) { + this.setState({ isPendingSelected }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onGrabSelectedPress = () => { + this.props.onGrabSelectedPress(this.getSelectedIds()); + } + + onRemoveSelectedPress = () => { + this.setState({ isConfirmRemoveModalOpen: true }); + } + + onRemoveSelectedConfirmed = (blacklist) => { + this.props.onRemoveSelectedPress(this.getSelectedIds(), blacklist); + this.setState({ isConfirmRemoveModalOpen: false }); + } + + onConfirmRemoveModalClose = () => { + this.setState({ isConfirmRemoveModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + isEpisodesFetching, + isEpisodesPopulated, + episodesError, + columns, + totalRecords, + isGrabbing, + isRemoving, + isCheckForFinishedDownloadExecuting, + onRefreshPress, + ...otherProps + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmRemoveModalOpen, + isPendingSelected + } = this.state; + + const isRefreshing = isFetching || isEpisodesFetching || isCheckForFinishedDownloadExecuting; + const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length); + const hasError = error || episodesError; + const selectedCount = this.getSelectedIds().length; + const disableSelectedActions = selectedCount === 0; + + return ( + + + + + + + + + + + + + + + { + isRefreshing && !isAllPopulated && + + } + + { + !isRefreshing && hasError && +
+ Failed to load Queue +
+ } + + { + isPopulated && !hasError && !items.length && +
+ Queue is empty +
+ } + + { + isAllPopulated && !hasError && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+ + +
+ ); + } +} + +Queue.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isEpisodesFetching: PropTypes.bool.isRequired, + isEpisodesPopulated: PropTypes.bool.isRequired, + episodesError: PropTypes.object, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isGrabbing: PropTypes.bool.isRequired, + isRemoving: PropTypes.bool.isRequired, + isCheckForFinishedDownloadExecuting: PropTypes.bool.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onGrabSelectedPress: PropTypes.func.isRequired, + onRemoveSelectedPress: PropTypes.func.isRequired +}; + +export default Queue; diff --git a/frontend/src/Activity/Queue/QueueConnector.js b/frontend/src/Activity/Queue/QueueConnector.js new file mode 100644 index 000000000..c598bbea5 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueConnector.js @@ -0,0 +1,186 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as queueActions from 'Store/Actions/queueActions'; +import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions'; +import * as commandNames from 'Commands/commandNames'; +import Queue from './Queue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.episodes, + (state) => state.queue.options, + (state) => state.queue.paged, + createCommandExecutingSelector(commandNames.CHECK_FOR_FINISHED_DOWNLOAD), + (episodes, options, queue, isCheckForFinishedDownloadExecuting) => { + return { + isEpisodesFetching: episodes.isFetching, + isEpisodesPopulated: episodes.isPopulated, + episodesError: episodes.error, + isCheckForFinishedDownloadExecuting, + ...options, + ...queue + }; + } + ); +} + +const mapDispatchToProps = { + ...queueActions, + fetchEpisodes, + clearEpisodes, + executeCommand +}; + +class QueueConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchQueue, + gotoQueueFirstPage + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchQueue(); + } else { + gotoQueueFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + const episodeIds = selectUniqueIds(this.props.items, 'episodeId'); + + if (episodeIds.length) { + this.props.fetchEpisodes({ episodeIds }); + } else { + this.props.clearEpisodes(); + } + } + + if ( + this.props.includeUnknownSeriesItems !== + prevProps.includeUnknownSeriesItems + ) { + this.repopulate(); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearQueue(); + this.props.clearEpisodes(); + } + + // + // Control + + repopulate = () => { + this.props.fetchQueue(); + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoQueueFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoQueuePreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoQueueNextPage(); + } + + onLastPagePress = () => { + this.props.gotoQueueLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoQueuePage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setQueueSort({ sortKey }); + } + + onTableOptionChange = (payload) => { + this.props.setQueueTableOption(payload); + + if (payload.pageSize) { + this.props.gotoQueueFirstPage(); + } + } + + onRefreshPress = () => { + this.props.executeCommand({ + name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD + }); + } + + onGrabSelectedPress = (ids) => { + this.props.grabQueueItems({ ids }); + } + + onRemoveSelectedPress = (ids, blacklist) => { + this.props.removeQueueItems({ ids, blacklist }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueueConnector.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchQueue: PropTypes.func.isRequired, + gotoQueueFirstPage: PropTypes.func.isRequired, + gotoQueuePreviousPage: PropTypes.func.isRequired, + gotoQueueNextPage: PropTypes.func.isRequired, + gotoQueueLastPage: PropTypes.func.isRequired, + gotoQueuePage: PropTypes.func.isRequired, + setQueueSort: PropTypes.func.isRequired, + setQueueTableOption: PropTypes.func.isRequired, + clearQueue: PropTypes.func.isRequired, + grabQueueItems: PropTypes.func.isRequired, + removeQueueItems: PropTypes.func.isRequired, + fetchEpisodes: PropTypes.func.isRequired, + clearEpisodes: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(QueueConnector) +); diff --git a/frontend/src/Activity/Queue/QueueDetails.js b/frontend/src/Activity/Queue/QueueDetails.js new file mode 100644 index 000000000..f6e360c0a --- /dev/null +++ b/frontend/src/Activity/Queue/QueueDetails.js @@ -0,0 +1,97 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; + +function QueueDetails(props) { + const { + title, + size, + sizeleft, + estimatedCompletionTime, + status: queueStatus, + errorMessage, + progressBar + } = props; + + const status = queueStatus.toLowerCase(); + + const progress = (100 - sizeleft / size * 100); + + if (status === 'pending') { + return ( + + ); + } + + if (status === 'completed') { + if (errorMessage) { + return ( + + ); + } + + // TODO: show an icon when download is complete, but not imported yet? + } + + if (errorMessage) { + return ( + + ); + } + + if (status === 'failed') { + return ( + + ); + } + + if (status === 'warning') { + return ( + + ); + } + + if (progress < 5) { + return ( + + ); + } + + return progressBar; +} + +QueueDetails.propTypes = { + title: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + sizeleft: PropTypes.number.isRequired, + estimatedCompletionTime: PropTypes.string, + status: PropTypes.string.isRequired, + errorMessage: PropTypes.string, + progressBar: PropTypes.node.isRequired +}; + +export default QueueDetails; diff --git a/frontend/src/Activity/Queue/QueueOptions.js b/frontend/src/Activity/Queue/QueueOptions.js new file mode 100644 index 000000000..900cf85cb --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptions.js @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +class QueueOptions extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + includeUnknownSeriesItems: props.includeUnknownSeriesItems + }; + } + + componentDidUpdate(prevProps) { + const { + includeUnknownSeriesItems + } = this.props; + + if (includeUnknownSeriesItems !== prevProps.includeUnknownSeriesItems) { + this.setState({ + includeUnknownSeriesItems + }); + } + } + + // + // Listeners + + onOptionChange = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onOptionChange({ + [name]: value + }); + }); + } + + // + // Render + + render() { + const { + includeUnknownSeriesItems + } = this.state; + + return ( + + + Show Unknown Series Items + + + + + ); + } +} + +QueueOptions.propTypes = { + includeUnknownSeriesItems: PropTypes.bool.isRequired, + onOptionChange: PropTypes.func.isRequired +}; + +export default QueueOptions; diff --git a/frontend/src/Activity/Queue/QueueOptionsConnector.js b/frontend/src/Activity/Queue/QueueOptionsConnector.js new file mode 100644 index 000000000..b2c99511c --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptionsConnector.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setQueueOption } from 'Store/Actions/queueActions'; +import QueueOptions from './QueueOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.queue.options, + (options) => { + return options; + } + ); +} + +const mapDispatchToProps = { + onOptionChange: setQueueOption +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions); diff --git a/frontend/src/Activity/Queue/QueueRow.css b/frontend/src/Activity/Queue/QueueRow.css new file mode 100644 index 000000000..6aa4a1622 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.css @@ -0,0 +1,23 @@ +.quality { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.protocol { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.progress { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js new file mode 100644 index 000000000..1d5610168 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -0,0 +1,369 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ProgressBar from 'Components/ProgressBar'; +import TableRow from 'Components/Table/TableRow'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import QueueStatusCell from './QueueStatusCell'; +import TimeleftCell from './TimeleftCell'; +import RemoveQueueItemModal from './RemoveQueueItemModal'; +import styles from './QueueRow.css'; + +class QueueRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRemoveQueueItemModalOpen: false, + isInteractiveImportModalOpen: false + }; + } + + // + // Listeners + + onRemoveQueueItemPress = () => { + this.setState({ isRemoveQueueItemModalOpen: true }); + } + + onRemoveQueueItemModalConfirmed = (blacklist) => { + this.props.onRemoveQueueItemPress(blacklist); + this.setState({ isRemoveQueueItemModalOpen: false }); + } + + onRemoveQueueItemModalClose = () => { + this.setState({ isRemoveQueueItemModalOpen: false }); + } + + onInteractiveImportPress = () => { + this.setState({ isInteractiveImportModalOpen: true }); + } + + onInteractiveImportModalClose = () => { + this.setState({ isInteractiveImportModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + downloadId, + title, + status, + trackedDownloadStatus, + statusMessages, + errorMessage, + series, + episode, + quality, + protocol, + indexer, + downloadClient, + estimatedCompletionTime, + timeleft, + size, + sizeleft, + showRelativeDates, + shortDateFormat, + timeFormat, + isGrabbing, + grabError, + isRemoving, + isSelected, + columns, + onSelectedChange, + onGrabPress + } = this.props; + + const { + isRemoveQueueItemModalOpen, + isInteractiveImportModalOpen + } = this.state; + + const progress = 100 - (sizeleft / size * 100); + const showInteractiveImport = status === 'Completed' && trackedDownloadStatus === 'Warning'; + const isPending = status === 'Delay' || status === 'DownloadClientUnavailable'; + + return ( + + + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'series.sortTitle') { + return ( + + { + series ? + : + title + } + + ); + } + + if (name === 'episode') { + return ( + + { + episode ? + : + '-' + } + + ); + } + + if (name === 'episode.title') { + return ( + + { + episode ? + : + '-' + } + + ); + } + + if (name === 'episode.airDateUtc') { + if (episode) { + return ( + + ); + } + + return ( + + - + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'protocol') { + return ( + + + + ); + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'downloadClient') { + return ( + + {downloadClient} + + ); + } + + if (name === 'estimatedCompletionTime') { + return ( + + ); + } + + if (name === 'progress') { + return ( + + { + !!progress && + + } + + ); + } + + if (name === 'actions') { + return ( + + { + showInteractiveImport && + + } + + { + isPending && + + } + + + + ); + } + + return null; + }) + } + + + + + + ); + } + +} + +QueueRow.propTypes = { + id: PropTypes.number.isRequired, + downloadId: PropTypes.string, + title: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + trackedDownloadStatus: PropTypes.string, + statusMessages: PropTypes.arrayOf(PropTypes.object), + errorMessage: PropTypes.string, + series: PropTypes.object, + episode: PropTypes.object, + quality: PropTypes.object.isRequired, + protocol: PropTypes.string.isRequired, + indexer: PropTypes.string, + downloadClient: PropTypes.string, + estimatedCompletionTime: PropTypes.string, + timeleft: PropTypes.string, + size: PropTypes.number, + sizeleft: PropTypes.number, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isGrabbing: PropTypes.bool.isRequired, + grabError: PropTypes.object, + isRemoving: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onSelectedChange: PropTypes.func.isRequired, + onGrabPress: PropTypes.func.isRequired, + onRemoveQueueItemPress: PropTypes.func.isRequired +}; + +QueueRow.defaultProps = { + isGrabbing: false, + isRemoving: false +}; + +export default QueueRow; diff --git a/frontend/src/Activity/Queue/QueueRowConnector.js b/frontend/src/Activity/Queue/QueueRowConnector.js new file mode 100644 index 000000000..10442e30f --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRowConnector.js @@ -0,0 +1,71 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import QueueRow from './QueueRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createEpisodeSelector(), + createUISettingsSelector(), + (series, episode, uiSettings) => { + const result = _.pick(uiSettings, [ + 'showRelativeDates', + 'shortDateFormat', + 'timeFormat' + ]); + + result.series = series; + result.episode = episode; + + return result; + } + ); +} + +const mapDispatchToProps = { + grabQueueItem, + removeQueueItem +}; + +class QueueRowConnector extends Component { + + // + // Listeners + + onGrabPress = () => { + this.props.grabQueueItem({ id: this.props.id }); + } + + onRemoveQueueItemPress = (blacklist) => { + this.props.removeQueueItem({ id: this.props.id, blacklist }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueueRowConnector.propTypes = { + id: PropTypes.number.isRequired, + episode: PropTypes.object, + grabQueueItem: PropTypes.func.isRequired, + removeQueueItem: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueRowConnector); diff --git a/frontend/src/Activity/Queue/QueueStatusCell.css b/frontend/src/Activity/Queue/QueueStatusCell.css new file mode 100644 index 000000000..6291ec949 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatusCell.css @@ -0,0 +1,5 @@ +.status { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 30px; +} diff --git a/frontend/src/Activity/Queue/QueueStatusCell.js b/frontend/src/Activity/Queue/QueueStatusCell.js new file mode 100644 index 000000000..f8cbc65ff --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatusCell.js @@ -0,0 +1,132 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import styles from './QueueStatusCell.css'; + +function getDetailedPopoverBody(statusMessages) { + return ( +
+ { + statusMessages.map(({ title, messages }) => { + return ( +
+ {title} +
    + { + messages.map((message) => { + return ( +
  • + {message} +
  • + ); + }) + } +
+
+ ); + }) + } +
+ ); +} + +function QueueStatusCell(props) { + const { + sourceTitle, + status, + trackedDownloadStatus = 'Ok', + statusMessages, + errorMessage + } = props; + + const hasWarning = trackedDownloadStatus === 'Warning'; + const hasError = trackedDownloadStatus === 'Error'; + + // status === 'downloading' + let iconName = icons.DOWNLOADING; + let iconKind = kinds.DEFAULT; + let title = 'Downloading'; + + if (hasWarning) { + iconKind = kinds.WARNING; + } + + if (status === 'Paused') { + iconName = icons.PAUSED; + title = 'Paused'; + } + + if (status === 'Queued') { + iconName = icons.QUEUED; + title = 'Queued'; + } + + if (status === 'Completed') { + iconName = icons.DOWNLOADED; + title = 'Downloaded'; + } + + if (status === 'Delay') { + iconName = icons.PENDING; + title = 'Pending'; + } + + if (status === 'DownloadClientUnavailable') { + iconName = icons.PENDING; + iconKind = kinds.WARNING; + title = 'Pending - Download client is unavailable'; + } + + if (status === 'Failed') { + iconName = icons.DOWNLOADING; + iconKind = kinds.DANGER; + title = 'Download failed'; + } + + if (status === 'Warning') { + iconName = icons.DOWNLOADING; + iconKind = kinds.WARNING; + title = `Download warning: ${errorMessage || 'check download client for more details'}`; + } + + if (hasError) { + if (status === 'Completed') { + iconName = icons.DOWNLOAD; + iconKind = kinds.DANGER; + title = `Import failed: ${sourceTitle}`; + } else { + iconName = icons.DOWNLOADING; + iconKind = kinds.DANGER; + title = 'Download failed'; + } + } + + return ( + + + } + title={title} + body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle} + position={tooltipPositions.RIGHT} + /> + + ); +} + +QueueStatusCell.propTypes = { + sourceTitle: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + trackedDownloadStatus: PropTypes.string, + statusMessages: PropTypes.arrayOf(PropTypes.object), + errorMessage: PropTypes.string +}; + +export default QueueStatusCell; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.css b/frontend/src/Activity/Queue/RemoveQueueItemModal.css new file mode 100644 index 000000000..c9ef59ec1 --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.css @@ -0,0 +1,3 @@ +.message { + margin-bottom: 30px; +} diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js new file mode 100644 index 000000000..52c2bc1cc --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './RemoveQueueItemModal.css'; + +class RemoveQueueItemModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + blacklist: false + }; + } + + // + // Listeners + + onBlacklistChange = ({ value }) => { + this.setState({ blacklist: value }); + } + + onRemoveQueueItemConfirmed = () => { + const blacklist = this.state.blacklist; + + this.setState({ blacklist: false }); + this.props.onRemovePress(blacklist); + } + + onModalClose = () => { + this.setState({ blacklist: false }); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + isOpen, + sourceTitle + } = this.props; + + const blacklist = this.state.blacklist; + + return ( + + + + Remove - {sourceTitle} + + + +
+ Are you sure you want to remove '{sourceTitle}' from the queue? +
+ + + Blacklist Release + + + +
+ + + + + + +
+
+ ); + } +} + +RemoveQueueItemModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + sourceTitle: PropTypes.string.isRequired, + onRemovePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RemoveQueueItemModal; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.css b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css new file mode 100644 index 000000000..c9ef59ec1 --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css @@ -0,0 +1,3 @@ +.message { + margin-bottom: 30px; +} diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js new file mode 100644 index 000000000..8e8009ab1 --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './RemoveQueueItemsModal.css'; + +class RemoveQueueItemsModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + blacklist: false + }; + } + + // + // Listeners + + onBlacklistChange = ({ value }) => { + this.setState({ blacklist: value }); + } + + onRemoveQueueItemConfirmed = () => { + const blacklist = this.state.blacklist; + + this.setState({ blacklist: false }); + this.props.onRemovePress(blacklist); + } + + onModalClose = () => { + this.setState({ blacklist: false }); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + isOpen, + selectedCount + } = this.props; + + const blacklist = this.state.blacklist; + + return ( + + + + Remove Selected Item{selectedCount > 1 ? 's' : ''} + + + +
+ Are you sure you want to remove {selectedCount} item{selectedCount > 1 ? 's' : ''} from the queue? +
+ + + Blacklist Release + + + +
+ + + + + + +
+
+ ); + } +} + +RemoveQueueItemsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + selectedCount: PropTypes.number.isRequired, + onRemovePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RemoveQueueItemsModal; diff --git a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js new file mode 100644 index 000000000..0f6c9159d --- /dev/null +++ b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchQueueStatus } from 'Store/Actions/queueActions'; +import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app, + (state) => state.queue.status, + (state) => state.queue.options.includeUnknownSeriesItems, + (app, status, includeUnknownSeriesItems) => { + const { + count, + unknownCount + } = status.item; + + return { + isConnected: app.isConnected, + isReconnecting: app.isReconnecting, + isPopulated: status.isPopulated, + ...status.item, + count: includeUnknownSeriesItems ? count : count - unknownCount + }; + } + ); +} + +const mapDispatchToProps = { + fetchQueueStatus +}; + +class QueueStatusConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.fetchQueueStatus(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.isConnected && prevProps.isReconnecting) { + this.props.fetchQueueStatus(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueueStatusConnector.propTypes = { + isConnected: PropTypes.bool.isRequired, + isReconnecting: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + fetchQueueStatus: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector); diff --git a/frontend/src/Activity/Queue/TimeleftCell.css b/frontend/src/Activity/Queue/TimeleftCell.css new file mode 100644 index 000000000..eb58cf297 --- /dev/null +++ b/frontend/src/Activity/Queue/TimeleftCell.css @@ -0,0 +1,5 @@ +.timeleft { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Activity/Queue/TimeleftCell.js b/frontend/src/Activity/Queue/TimeleftCell.js new file mode 100644 index 000000000..c9515f172 --- /dev/null +++ b/frontend/src/Activity/Queue/TimeleftCell.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatTime from 'Utilities/Date/formatTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './TimeleftCell.css'; + +function TimeleftCell(props) { + const { + estimatedCompletionTime, + timeleft, + status, + size, + sizeleft, + showRelativeDates, + shortDateFormat, + timeFormat + } = props; + + if (status === 'Delay') { + const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); + const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); + + return ( + + - + + ); + } + + if (status === 'DownloadClientUnavailable') { + const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); + const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); + + return ( + + - + + ); + } + + if (!timeleft) { + return ( + + - + + ); + } + + const totalSize = formatBytes(size); + const remainingSize = formatBytes(sizeleft); + + return ( + + {formatTimeSpan(timeleft)} + + ); +} + +TimeleftCell.propTypes = { + estimatedCompletionTime: PropTypes.string, + timeleft: PropTypes.string, + status: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + sizeleft: PropTypes.number.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default TimeleftCell; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css new file mode 100644 index 000000000..0bf8b0e15 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css @@ -0,0 +1,54 @@ +.searchContainer { + display: flex; + margin-bottom: 10px; +} + +.searchIconContainer { + width: 58px; + height: 46px; + border: 1px solid $inputBorderColor; + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: #edf1f2; + text-align: center; + line-height: 46px; +} + +.searchInput { + composes: input from 'Components/Form/TextInput.css'; + + height: 46px; + border-radius: 0; + font-size: 18px; +} + +.clearLookupButton { + border: 1px solid $inputBorderColor; + border-left: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} + +.message { + margin-top: 30px; + text-align: center; +} + +.helpText { + margin-bottom: 10px; + font-weight: 300; + font-size: 24px; +} + +.noResults { + margin-bottom: 10px; + font-weight: 300; + font-size: 30px; +} + +.searchResults { + margin-top: 30px; +} diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js new file mode 100644 index 000000000..4c389e940 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js @@ -0,0 +1,182 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import TextInput from 'Components/Form/TextInput'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import AddNewSeriesSearchResultConnector from './AddNewSeriesSearchResultConnector'; +import styles from './AddNewSeries.css'; + +class AddNewSeries extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + term: props.term || '', + isFetching: false + }; + } + + componentDidMount() { + const term = this.state.term; + + if (term) { + this.props.onSeriesLookupChange(term); + } + } + + componentDidUpdate(prevProps) { + const { + term, + isFetching + } = this.props; + + if (term && term !== prevProps.term) { + this.setState({ + term, + isFetching: true + }); + this.props.onSeriesLookupChange(term); + } else if (isFetching !== prevProps.isFetching) { + this.setState({ + isFetching + }); + } + } + + // + // Listeners + + onSearchInputChange = ({ value }) => { + const hasValue = !!value.trim(); + + this.setState({ term: value, isFetching: hasValue }, () => { + if (hasValue) { + this.props.onSeriesLookupChange(value); + } else { + this.props.onClearSeriesLookup(); + } + }); + } + + onClearSeriesLookupPress = () => { + this.setState({ term: '' }); + this.props.onClearSeriesLookup(); + } + + // + // Render + + render() { + const { + error, + items + } = this.props; + + const term = this.state.term; + const isFetching = this.state.isFetching; + + return ( + + +
+
+ +
+ + + + +
+ + { + isFetching && + + } + + { + !isFetching && !!error && +
Failed to load search results, please try again.
+ } + + { + !isFetching && !error && !!items.length && +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+ } + + { + !isFetching && !error && !items.length && !!term && +
+
Couldn't find any results for '{term}'
+
You can also search using TVDB ID of a show. eg. tvdb:71663
+
+ + Why can't I find my show? + +
+
+ } + + { + !term && +
+
It's easy to add a new series, just start typing the name the series you want to add.
+
You can also search using TVDB ID of a show. eg. tvdb:71663
+
+ } + +
+ + + ); + } +} + +AddNewSeries.propTypes = { + term: PropTypes.string, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isAdding: PropTypes.bool.isRequired, + addError: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onSeriesLookupChange: PropTypes.func.isRequired, + onClearSeriesLookup: PropTypes.func.isRequired +}; + +export default AddNewSeries; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js new file mode 100644 index 000000000..9374fb54e --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js @@ -0,0 +1,102 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import parseUrl from 'Utilities/String/parseUrl'; +import { lookupSeries, clearAddSeries } from 'Store/Actions/addSeriesActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import AddNewSeries from './AddNewSeries'; + +function createMapStateToProps() { + return createSelector( + (state) => state.addSeries, + (state) => state.routing.location, + (addSeries, location) => { + const { params } = parseUrl(location.search); + + return { + term: params.term, + ...addSeries + }; + } + ); +} + +const mapDispatchToProps = { + lookupSeries, + clearAddSeries, + fetchRootFolders +}; + +class AddNewSeriesConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._seriesLookupTimeout = null; + } + + componentDidMount() { + this.props.fetchRootFolders(); + } + + componentWillUnmount() { + if (this._seriesLookupTimeout) { + clearTimeout(this._seriesLookupTimeout); + } + + this.props.clearAddSeries(); + } + + // + // Listeners + + onSeriesLookupChange = (term) => { + if (this._seriesLookupTimeout) { + clearTimeout(this._seriesLookupTimeout); + } + + if (term.trim() === '') { + this.props.clearAddSeries(); + } else { + this._seriesLookupTimeout = setTimeout(() => { + this.props.lookupSeries({ term }); + }, 300); + } + } + + onClearSeriesLookup = () => { + this.props.clearAddSeries(); + } + + // + // Render + + render() { + const { + term, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +AddNewSeriesConnector.propTypes = { + term: PropTypes.string, + lookupSeries: PropTypes.func.isRequired, + clearAddSeries: PropTypes.func.isRequired, + fetchRootFolders: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesConnector); diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js new file mode 100644 index 000000000..cb603e7a6 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddNewSeriesModalContentConnector from './AddNewSeriesModalContentConnector'; + +function AddNewSeriesModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +AddNewSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddNewSeriesModal; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.css b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.css new file mode 100644 index 000000000..a58e22b53 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.css @@ -0,0 +1,74 @@ +.container { + display: flex; +} + +.year { + margin-left: 5px; + color: $disabledColor; +} + +.poster { + flex: 0 0 170px; + margin-right: 20px; + height: 250px; +} + +.info { + flex-grow: 1; +} + +.overview { + margin-bottom: 30px; +} + +.labelIcon { + margin-left: 8px; +} + +.searchForMissingEpisodesLabelContainer { + display: flex; + margin-top: 2px; +} + +.searchForMissingEpisodesLabel { + margin-right: 8px; + font-weight: normal; +} + +.searchForMissingEpisodesContainer { + composes: container from 'Components/Form/CheckInput.css'; + + flex: 0 1 0; +} + +.searchForMissingEpisodesInput { + composes: input from 'Components/Form/CheckInput.css'; + + margin-top: 0; +} + +.modalFooter { + composes: modalFooter from 'Components/Modal/ModalFooter.css'; +} + +.addButton { + @add-mixin truncate; + composes: button from 'Components/Link/SpinnerButton.css'; +} + +.hideLanguageProfile { + composes: group from 'Components/Form/FormGroup.css'; + + display: none; +} + +@media only screen and (max-width: $breakpointSmall) { + .modalFooter { + display: block; + text-align: center; + } + + .addButton { + margin-top: 10px; + } +} diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js new file mode 100644 index 000000000..1585ee492 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js @@ -0,0 +1,265 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, inputTypes, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import CheckInput from 'Components/Form/CheckInput'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Popover from 'Components/Tooltip/Popover'; +import SeriesPoster from 'Series/SeriesPoster'; +import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent'; +import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent'; +import styles from './AddNewSeriesModalContent.css'; + +class AddNewSeriesModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + searchForMissingEpisodes: false + }; + } + + // + // Listeners + + onSearchForMissingEpisodesChange = ({ value }) => { + this.setState({ searchForMissingEpisodes: value }); + } + + onQualityProfileIdChange = ({ value }) => { + this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) }); + } + + onLanguageProfileIdChange = ({ value }) => { + this.props.onInputChange({ name: 'languageProfileId', value: parseInt(value) }); + } + + onAddSeriesPress = () => { + this.props.onAddSeriesPress(this.state.searchForMissingEpisodes); + } + + // + // Render + + render() { + const { + title, + year, + overview, + images, + isAdding, + rootFolderPath, + monitor, + qualityProfileId, + languageProfileId, + seriesType, + seasonFolder, + tags, + showLanguageProfile, + isSmallScreen, + onModalClose, + onInputChange + } = this.props; + + return ( + + + {title} + + { + !title.contains(year) && !!year && + ({year}) + } + + + +
+ { + !isSmallScreen && +
+ +
+ } + +
+
+ {overview} +
+ +
+ + Root Folder + + + + + + + Monitor + + + } + title="Monitoring Options" + body={} + position={tooltipPositions.RIGHT} + /> + + + + + + + Quality Profile + + + + + + Language Profile + + + + + + + Series Type + + + } + title="Series Types" + body={} + position={tooltipPositions.RIGHT} + /> + + + + + + + Season Folder + + + + + + Tags + + + +
+
+
+
+ + + + + + Add {title} + + +
+ ); + } +} + +AddNewSeriesModalContent.propTypes = { + title: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + overview: PropTypes.string, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + isAdding: PropTypes.bool.isRequired, + addError: PropTypes.object, + rootFolderPath: PropTypes.object, + monitor: PropTypes.object.isRequired, + qualityProfileId: PropTypes.object, + languageProfileId: PropTypes.object, + seriesType: PropTypes.object.isRequired, + seasonFolder: PropTypes.object.isRequired, + tags: PropTypes.object.isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired, + onAddSeriesPress: PropTypes.func.isRequired +}; + +export default AddNewSeriesModalContent; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js new file mode 100644 index 000000000..dc351933e --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setAddSeriesDefault, addSeries } from 'Store/Actions/addSeriesActions'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import selectSettings from 'Store/Selectors/selectSettings'; +import AddNewSeriesModalContent from './AddNewSeriesModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.addSeries, + (state) => state.settings.languageProfiles, + createDimensionsSelector(), + (addSeriesState, languageProfiles, dimensions) => { + const { + isAdding, + addError, + defaults + } = addSeriesState; + + const { + settings, + validationErrors, + validationWarnings + } = selectSettings(defaults, {}, addError); + + return { + isAdding, + addError, + showLanguageProfile: languageProfiles.items.length > 1, + isSmallScreen: dimensions.isSmallScreen, + validationErrors, + validationWarnings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + setAddSeriesDefault, + addSeries +}; + +class AddNewSeriesModalContentConnector extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setAddSeriesDefault({ [name]: value }); + } + + onAddSeriesPress = (searchForMissingEpisodes) => { + const { + tvdbId, + rootFolderPath, + monitor, + qualityProfileId, + languageProfileId, + seriesType, + seasonFolder, + tags + } = this.props; + + this.props.addSeries({ + tvdbId, + rootFolderPath: rootFolderPath.value, + monitor: monitor.value, + qualityProfileId: qualityProfileId.value, + languageProfileId: languageProfileId.value, + seriesType: seriesType.value, + seasonFolder: seasonFolder.value, + tags: tags.value, + searchForMissingEpisodes + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddNewSeriesModalContentConnector.propTypes = { + tvdbId: PropTypes.number.isRequired, + rootFolderPath: PropTypes.object, + monitor: PropTypes.object.isRequired, + qualityProfileId: PropTypes.object, + languageProfileId: PropTypes.object, + seriesType: PropTypes.object.isRequired, + seasonFolder: PropTypes.object.isRequired, + tags: PropTypes.object.isRequired, + onModalClose: PropTypes.func.isRequired, + setAddSeriesDefault: PropTypes.func.isRequired, + addSeries: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddNewSeriesModalContentConnector); diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css new file mode 100644 index 000000000..38ccffb4d --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.css @@ -0,0 +1,40 @@ +.searchResult { + display: flex; + margin: 20px 0; + padding: 20px; + width: 100%; + background-color: $white; + color: inherit; + transition: background 500ms; + + &:hover { + background-color: #eaf2ff; + color: inherit; + text-decoration: none; + } +} + +.poster { + flex: 0 0 170px; + margin-right: 20px; + height: 250px; +} + +.title { + font-weight: 300; + font-size: 36px; +} + +.year { + margin-left: 10px; + color: $disabledColor; +} + +.alreadyExistsIcon { + margin-left: 10px; + color: #37bc9b; +} + +.overview { + margin-top: 20px; +} diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js new file mode 100644 index 000000000..57f34dff4 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js @@ -0,0 +1,177 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import HeartRating from 'Components/HeartRating'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import SeriesPoster from 'Series/SeriesPoster'; +import AddNewSeriesModal from './AddNewSeriesModal'; +import styles from './AddNewSeriesSearchResult.css'; + +class AddNewSeriesSearchResult extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isNewAddSeriesModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (!prevProps.isExistingSeries && this.props.isExistingSeries) { + this.onAddSerisModalClose(); + } + } + + // + // Listeners + + onPress = () => { + this.setState({ isNewAddSeriesModalOpen: true }); + } + + onAddSerisModalClose = () => { + this.setState({ isNewAddSeriesModalOpen: false }); + } + + // + // Render + + render() { + const { + tvdbId, + title, + titleSlug, + year, + network, + status, + overview, + statistics, + ratings, + images, + isExistingSeries, + isSmallScreen + } = this.props; + + const seasonCount = statistics.seasonCount; + + const { + isNewAddSeriesModalOpen + } = this.state; + + const linkProps = isExistingSeries ? { to: `/series/${titleSlug}` } : { onPress: this.onPress }; + let seasons = '1 Season'; + + if (seasonCount > 1) { + seasons = `${seasonCount} Seasons`; + } + + return ( +
+ + { + !isSmallScreen && + + } + +
+
+ {title} + + { + !title.contains(year) && !!year && + ({year}) + } + + { + isExistingSeries && + + } +
+ +
+ + + { + !!network && + + } + + { + !!seasonCount && + + } + + { + status === 'ended' && + + } +
+ +
+ {overview} +
+
+ + + +
+ ); + } +} + +AddNewSeriesSearchResult.propTypes = { + tvdbId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + network: PropTypes.string, + status: PropTypes.string.isRequired, + overview: PropTypes.string, + statistics: PropTypes.object.isRequired, + ratings: PropTypes.object.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + isExistingSeries: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired +}; + +export default AddNewSeriesSearchResult; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js new file mode 100644 index 000000000..5ba942270 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResultConnector.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import AddNewSeriesSearchResult from './AddNewSeriesSearchResult'; + +function createMapStateToProps() { + return createSelector( + createExistingSeriesSelector(), + createDimensionsSelector(), + (isExistingSeries, dimensions) => { + return { + isExistingSeries, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +export default connect(createMapStateToProps)(AddNewSeriesSearchResult); diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js new file mode 100644 index 000000000..0f0e2ce1f --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js @@ -0,0 +1,173 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import ImportSeriesTableConnector from './ImportSeriesTableConnector'; +import ImportSeriesFooterConnector from './ImportSeriesFooterConnector'; + +class ImportSeries extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + contentBody: null, + scrollTop: 0 + }; + } + + // + // Control + + setContentBodyRef = (ref) => { + this.setState({ contentBody: ref }); + } + + // + // Listeners + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState, { parseIds: false }); + } + + onSelectAllChange = ({ value }) => { + // Only select non-dupes + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onRemoveSelectedStateItem = (id) => { + this.setState((state) => { + const selectedState = Object.assign({}, state.selectedState); + delete selectedState[id]; + + return { + ...state, + selectedState + }; + }); + } + + onInputChange = ({ name, value }) => { + this.props.onInputChange(this.getSelectedIds(), name, value); + } + + onImportPress = () => { + this.props.onImportPress(this.getSelectedIds()); + } + + onScroll = ({ scrollTop }) => { + this.setState({ scrollTop }); + } + + // + // Render + + render() { + const { + rootFolderId, + path, + rootFoldersFetching, + rootFoldersPopulated, + rootFoldersError, + unmappedFolders, + showLanguageProfile + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + contentBody + } = this.state; + + return ( + + + { + rootFoldersFetching && !rootFoldersPopulated && + + } + + { + !rootFoldersFetching && !!rootFoldersError && +
Unable to load root folders
+ } + + { + !rootFoldersError && rootFoldersPopulated && !unmappedFolders.length && +
+ All series in {path} have been imported +
+ } + + { + !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && contentBody && + + } +
+ + { + !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && + + } +
+ ); + } +} + +ImportSeries.propTypes = { + rootFolderId: PropTypes.number.isRequired, + path: PropTypes.string, + rootFoldersFetching: PropTypes.bool.isRequired, + rootFoldersPopulated: PropTypes.bool.isRequired, + rootFoldersError: PropTypes.object, + unmappedFolders: PropTypes.arrayOf(PropTypes.object), + items: PropTypes.arrayOf(PropTypes.object), + showLanguageProfile: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onImportPress: PropTypes.func.isRequired +}; + +ImportSeries.defaultProps = { + unmappedFolders: [] +}; + +export default ImportSeries; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesConnector.js new file mode 100644 index 000000000..0ab206ef9 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesConnector.js @@ -0,0 +1,169 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setImportSeriesValue, importSeries, clearImportSeries } from 'Store/Actions/importSeriesActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions'; +import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape'; +import ImportSeries from './ImportSeries'; + +function createMapStateToProps() { + return createSelector( + (state, { match }) => match, + (state) => state.rootFolders, + (state) => state.addSeries, + (state) => state.importSeries, + (state) => state.settings.qualityProfiles, + (state) => state.settings.languageProfiles, + ( + match, + rootFolders, + addSeries, + importSeriesState, + qualityProfiles, + languageProfiles + ) => { + const { + isFetching: rootFoldersFetching, + isPopulated: rootFoldersPopulated, + error: rootFoldersError, + items + } = rootFolders; + + const rootFolderId = parseInt(match.params.rootFolderId); + + const result = { + rootFolderId, + rootFoldersFetching, + rootFoldersPopulated, + rootFoldersError, + qualityProfiles: qualityProfiles.items, + languageProfiles: languageProfiles.items, + showLanguageProfile: languageProfiles.items.length > 1, + defaultQualityProfileId: addSeries.defaults.qualityProfileId, + defaultLanguageProfileId: addSeries.defaults.languageProfileId + }; + + if (items.length) { + const rootFolder = _.find(items, { id: rootFolderId }); + + return { + ...result, + ...rootFolder, + items: importSeriesState.items + }; + } + + return result; + } + ); +} + +const mapDispatchToProps = { + dispatchSetImportSeriesValue: setImportSeriesValue, + dispatchImportSeries: importSeries, + dispatchClearImportSeries: clearImportSeries, + dispatchFetchRootFolders: fetchRootFolders, + dispatchSetAddSeriesDefault: setAddSeriesDefault +}; + +class ImportSeriesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + qualityProfiles, + languageProfiles, + defaultQualityProfileId, + defaultLanguageProfileId, + dispatchFetchRootFolders, + dispatchSetAddSeriesDefault + } = this.props; + + if (!this.props.rootFoldersPopulated) { + dispatchFetchRootFolders(); + } + + let setDefaults = false; + const setDefaultPayload = {}; + + if ( + !defaultQualityProfileId || + !qualityProfiles.some((p) => p.id === defaultQualityProfileId) + ) { + setDefaults = true; + setDefaultPayload.qualityProfileId = qualityProfiles[0].id; + } + + if ( + !defaultLanguageProfileId || + !languageProfiles.some((p) => p.id === defaultLanguageProfileId) + ) { + setDefaults = true; + setDefaultPayload.languageProfileId = languageProfiles[0].id; + } + + if (setDefaults) { + dispatchSetAddSeriesDefault(setDefaultPayload); + } + } + + componentWillUnmount() { + this.props.dispatchClearImportSeries(); + } + + // + // Listeners + + onInputChange = (ids, name, value) => { + this.props.dispatchSetAddSeriesDefault({ [name]: value }); + + ids.forEach((id) => { + this.props.dispatchSetImportSeriesValue({ + id, + [name]: value + }); + }); + } + + onImportPress = (ids) => { + this.props.dispatchImportSeries({ ids }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +const routeMatchShape = createRouteMatchShape({ + rootFolderId: PropTypes.string.isRequired +}); + +ImportSeriesConnector.propTypes = { + match: routeMatchShape.isRequired, + rootFoldersPopulated: PropTypes.bool.isRequired, + qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + languageProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + defaultQualityProfileId: PropTypes.number.isRequired, + defaultLanguageProfileId: PropTypes.number.isRequired, + dispatchSetImportSeriesValue: PropTypes.func.isRequired, + dispatchImportSeries: PropTypes.func.isRequired, + dispatchClearImportSeries: PropTypes.func.isRequired, + dispatchFetchRootFolders: PropTypes.func.isRequired, + dispatchSetAddSeriesDefault: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesConnector); diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css new file mode 100644 index 000000000..0a61ca509 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.css @@ -0,0 +1,33 @@ +.inputContainer { + margin-right: 20px; + min-width: 150px; +} + +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.importButtonContainer { + display: flex; + align-items: center; +} + +.importButton { + composes: button from 'Components/Link/SpinnerButton.css'; + + height: 35px; +} + +.loadingButton { + composes: importButton; + + margin-left: 10px; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin: 0 10px 0 12px; + text-align: left; +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js new file mode 100644 index 000000000..9d28dd299 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooter.js @@ -0,0 +1,291 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import CheckInput from 'Components/Form/CheckInput'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import styles from './ImportSeriesFooter.css'; + +const MIXED = 'mixed'; + +class ImportSeriesFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + defaultMonitor, + defaultQualityProfileId, + defaultLanguageProfileId, + defaultSeasonFolder, + defaultSeriesType + } = props; + + this.state = { + monitor: defaultMonitor, + qualityProfileId: defaultQualityProfileId, + languageProfileId: defaultLanguageProfileId, + seriesType: defaultSeriesType, + seasonFolder: defaultSeasonFolder + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + defaultMonitor, + defaultQualityProfileId, + defaultLanguageProfileId, + defaultSeriesType, + defaultSeasonFolder, + isMonitorMixed, + isQualityProfileIdMixed, + isLanguageProfileIdMixed, + isSeriesTypeMixed, + isSeasonFolderMixed + } = this.props; + + const { + monitor, + qualityProfileId, + languageProfileId, + seriesType, + seasonFolder + } = this.state; + + const newState = {}; + + if (isMonitorMixed && monitor !== MIXED) { + newState.monitor = MIXED; + } else if (!isMonitorMixed && monitor !== defaultMonitor) { + newState.monitor = defaultMonitor; + } + + if (isQualityProfileIdMixed && qualityProfileId !== MIXED) { + newState.qualityProfileId = MIXED; + } else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) { + newState.qualityProfileId = defaultQualityProfileId; + } + + if (isLanguageProfileIdMixed && languageProfileId !== MIXED) { + newState.languageProfileId = MIXED; + } else if (!isLanguageProfileIdMixed && languageProfileId !== defaultLanguageProfileId) { + newState.languageProfileId = defaultLanguageProfileId; + } + + if (isSeriesTypeMixed && seriesType !== MIXED) { + newState.seriesType = MIXED; + } else if (!isSeriesTypeMixed && seriesType !== defaultSeriesType) { + newState.seriesType = defaultSeriesType; + } + + if (isSeasonFolderMixed && seasonFolder != null) { + newState.seasonFolder = null; + } else if (!isSeasonFolderMixed && seasonFolder !== defaultSeasonFolder) { + newState.seasonFolder = defaultSeasonFolder; + } + + if (!_.isEmpty(newState)) { + this.setState(newState); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + this.props.onInputChange({ name, value }); + } + + // + // Render + + render() { + const { + selectedCount, + isImporting, + isLookingUpSeries, + isMonitorMixed, + isQualityProfileIdMixed, + isLanguageProfileIdMixed, + isSeriesTypeMixed, + hasUnsearchedItems, + showLanguageProfile, + onImportPress, + onLookupPress, + onCancelLookupPress + } = this.props; + + const { + monitor, + qualityProfileId, + languageProfileId, + seriesType, + seasonFolder + } = this.state; + + return ( + +
+
+ Monitor +
+ + +
+ +
+
+ Quality Profile +
+ + +
+ + { + showLanguageProfile && +
+
+ Language Profile +
+ + +
+ } + +
+
+ Series Type +
+ + +
+ +
+
+ Season Folder +
+ + +
+ +
+
+   +
+ +
+ + Import {selectedCount} Series + + + { + isLookingUpSeries && + + } + + { + hasUnsearchedItems && + + } + + { + isLookingUpSeries && + + } + + { + isLookingUpSeries && + 'Processing Folders' + } +
+
+
+ ); + } +} + +ImportSeriesFooter.propTypes = { + selectedCount: PropTypes.number.isRequired, + isImporting: PropTypes.bool.isRequired, + isLookingUpSeries: PropTypes.bool.isRequired, + defaultMonitor: PropTypes.string.isRequired, + defaultQualityProfileId: PropTypes.number, + defaultLanguageProfileId: PropTypes.number, + defaultSeriesType: PropTypes.string.isRequired, + defaultSeasonFolder: PropTypes.bool.isRequired, + isMonitorMixed: PropTypes.bool.isRequired, + isQualityProfileIdMixed: PropTypes.bool.isRequired, + isLanguageProfileIdMixed: PropTypes.bool.isRequired, + isSeriesTypeMixed: PropTypes.bool.isRequired, + isSeasonFolderMixed: PropTypes.bool.isRequired, + hasUnsearchedItems: PropTypes.bool.isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onImportPress: PropTypes.func.isRequired, + onLookupPress: PropTypes.func.isRequired, + onCancelLookupPress: PropTypes.func.isRequired +}; + +export default ImportSeriesFooter; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooterConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooterConnector.js new file mode 100644 index 000000000..4983f5663 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesFooterConnector.js @@ -0,0 +1,65 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { lookupUnsearchedSeries, cancelLookupSeries } from 'Store/Actions/importSeriesActions'; +import ImportSeriesFooter from './ImportSeriesFooter'; + +function isMixed(items, selectedIds, defaultValue, key) { + return _.some(items, (series) => { + return selectedIds.indexOf(series.id) > -1 && series[key] !== defaultValue; + }); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.addSeries, + (state) => state.importSeries, + (state, { selectedIds }) => selectedIds, + (addSeries, importSeries, selectedIds) => { + const { + monitor: defaultMonitor, + qualityProfileId: defaultQualityProfileId, + languageProfileId: defaultLanguageProfileId, + seriesType: defaultSeriesType, + seasonFolder: defaultSeasonFolder + } = addSeries.defaults; + + const { + isLookingUpSeries, + isImporting, + items + } = importSeries; + + const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor'); + const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId'); + const isLanguageProfileIdMixed = isMixed(items, selectedIds, defaultLanguageProfileId, 'languageProfileId'); + const isSeriesTypeMixed = isMixed(items, selectedIds, defaultSeriesType, 'seriesType'); + const isSeasonFolderMixed = isMixed(items, selectedIds, defaultSeasonFolder, 'seasonFolder'); + const hasUnsearchedItems = !isLookingUpSeries && items.some((item) => !item.isPopulated); + + return { + selectedCount: selectedIds.length, + isLookingUpSeries, + isImporting, + defaultMonitor, + defaultQualityProfileId, + defaultLanguageProfileId, + defaultSeriesType, + defaultSeasonFolder, + isMonitorMixed, + isQualityProfileIdMixed, + isLanguageProfileIdMixed, + isSeriesTypeMixed, + isSeasonFolderMixed, + hasUnsearchedItems + }; + } + ); +} + +const mapDispatchToProps = { + onLookupPress: lookupUnsearchedSeries, + onCancelLookupPress: cancelLookupSeries +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesFooter); diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.css new file mode 100644 index 000000000..36a57ea73 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.css @@ -0,0 +1,45 @@ +.folder { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 200px; +} + +.monitor { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 200px; + min-width: 185px; +} + +.qualityProfile, +.languageProfile { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 250px; + min-width: 170px; +} + +.seriesType { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 200px; + min-width: 120px; +} + +.seasonFolder { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 150px; + min-width: 120px; +} + +.series { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 400px; + min-width: 300px; +} + +.detailsIcon { + margin-left: 8px; +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js new file mode 100644 index 000000000..fe60a4d5d --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Popover from 'Components/Tooltip/Popover'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell'; +import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent'; +import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent'; +import styles from './ImportSeriesHeader.css'; + +function ImportSeriesHeader(props) { + const { + showLanguageProfile, + allSelected, + allUnselected, + onSelectAllChange + } = props; + + return ( + + + + + Folder + + + + Monitor + + + } + title="Monitoring Options" + body={} + position={tooltipPositions.RIGHT} + /> + + + + Quality Profile + + + { + showLanguageProfile && + + Language Profile + + } + + + Series Type + + + } + title="Series Type" + body={} + position={tooltipPositions.RIGHT} + /> + + + + Season Folder + + + + Series + + + ); +} + +ImportSeriesHeader.propTypes = { + showLanguageProfile: PropTypes.bool.isRequired, + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + onSelectAllChange: PropTypes.func.isRequired +}; + +export default ImportSeriesHeader; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.css new file mode 100644 index 000000000..10329ea1c --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.css @@ -0,0 +1,52 @@ +.selectInput { + composes: input from 'Components/Form/CheckInput.css'; +} + +.folder { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 1 0 200px; + line-height: 36px; +} + +.monitor { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 200px; + min-width: 185px; +} + +.qualityProfile, +.languageProfile { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 250px; + min-width: 170px; +} + +.seriesType { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 200px; + min-width: 120px; +} + +.seasonFolder { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 150px; + min-width: 120px; +} + +.series { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 1 400px; + min-width: 300px; +} + +.hideLanguageProfile { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + display: none; +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js new file mode 100644 index 000000000..a9dad2af0 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.js @@ -0,0 +1,120 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import VirtualTableRow from 'Components/Table/VirtualTableRow'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; +import ImportSeriesSelectSeriesConnector from './SelectSeries/ImportSeriesSelectSeriesConnector'; +import styles from './ImportSeriesRow.css'; + +function ImportSeriesRow(props) { + const { + style, + id, + monitor, + qualityProfileId, + languageProfileId, + seasonFolder, + seriesType, + selectedSeries, + isExistingSeries, + showLanguageProfile, + isSelected, + onSelectedChange, + onInputChange + } = props; + + return ( + + + + + {id} + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +ImportSeriesRow.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.string.isRequired, + monitor: PropTypes.string.isRequired, + qualityProfileId: PropTypes.number.isRequired, + languageProfileId: PropTypes.number.isRequired, + seriesType: PropTypes.string.isRequired, + seasonFolder: PropTypes.bool.isRequired, + selectedSeries: PropTypes.object, + isExistingSeries: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +ImportSeriesRow.defaultsProps = { + items: [] +}; + +export default ImportSeriesRow; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRowConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRowConnector.js new file mode 100644 index 000000000..1eb8f01ff --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRowConnector.js @@ -0,0 +1,89 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setImportSeriesValue } from 'Store/Actions/importSeriesActions'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import ImportSeriesRow from './ImportSeriesRow'; + +function createImportSeriesItemSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.importSeries.items, + (id, items) => { + return _.find(items, { id }) || {}; + } + ); +} + +function createMapStateToProps() { + return createSelector( + createImportSeriesItemSelector(), + createAllSeriesSelector(), + (item, series) => { + const selectedSeries = item && item.selectedSeries; + const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId }); + + return { + ...item, + isExistingSeries + }; + } + ); +} + +const mapDispatchToProps = { + setImportSeriesValue +}; + +class ImportSeriesRowConnector extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setImportSeriesValue({ + id: this.props.id, + [name]: value + }); + } + + // + // Render + + render() { + // Don't show the row until we have the information we require for it. + + const { + items, + monitor, + seriesType, + seasonFolder + } = this.props; + + if (!items || !monitor || !seriesType || !seasonFolder == null) { + return null; + } + + return ( + + ); + } +} + +ImportSeriesRowConnector.propTypes = { + rootFolderId: PropTypes.number.isRequired, + id: PropTypes.string.isRequired, + monitor: PropTypes.string, + seriesType: PropTypes.string, + seasonFolder: PropTypes.bool, + items: PropTypes.arrayOf(PropTypes.object), + setImportSeriesValue: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRowConnector); diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesSelected.css b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesSelected.css new file mode 100644 index 000000000..efc6dccb3 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesSelected.css @@ -0,0 +1,3 @@ +.input { + composes: input from 'Components/Form/CheckInput.css'; +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js new file mode 100644 index 000000000..1f4c6e251 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTable.js @@ -0,0 +1,197 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import VirtualTable from 'Components/Table/VirtualTable'; +import ImportSeriesHeader from './ImportSeriesHeader'; +import ImportSeriesRowConnector from './ImportSeriesRowConnector'; + +class ImportSeriesTable extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + unmappedFolders, + defaultMonitor, + defaultQualityProfileId, + defaultLanguageProfileId, + defaultSeriesType, + defaultSeasonFolder, + onSeriesLookup, + onSetImportSeriesValue + } = this.props; + + const values = { + monitor: defaultMonitor, + qualityProfileId: defaultQualityProfileId, + languageProfileId: defaultLanguageProfileId, + seriesType: defaultSeriesType, + seasonFolder: defaultSeasonFolder + }; + + unmappedFolders.forEach((unmappedFolder) => { + const id = unmappedFolder.name; + + onSeriesLookup(id, unmappedFolder.path); + + onSetImportSeriesValue({ + id, + ...values + }); + }); + } + + // This isn't great, but it's the most reliable way to ensure the items + // are checked off even if they aren't actually visible since the cells + // are virtualized. + + componentDidUpdate(prevProps) { + const { + items, + selectedState, + onSelectedChange, + onRemoveSelectedStateItem + } = this.props; + + prevProps.items.forEach((prevItem) => { + const { + id + } = prevItem; + + const item = _.find(items, { id }); + + if (!item) { + onRemoveSelectedStateItem(id); + return; + } + + const selectedSeries = item.selectedSeries; + const isSelected = selectedState[id]; + + const isExistingSeries = !!selectedSeries && + _.some(prevProps.allSeries, { tvdbId: selectedSeries.tvdbId }); + + // Props doesn't have a selected series or + // the selected series is an existing series. + if ((!selectedSeries && prevItem.selectedSeries) || (isExistingSeries && !prevItem.selectedSeries)) { + onSelectedChange({ id, value: false }); + + return; + } + + // State is selected, but a series isn't selected or + // the selected series is an existing series. + if (isSelected && (!selectedSeries || isExistingSeries)) { + onSelectedChange({ id, value: false }); + + return; + } + + // A series is being selected that wasn't previously selected. + if (selectedSeries && selectedSeries !== prevItem.selectedSeries) { + onSelectedChange({ id, value: true }); + + return; + } + }); + } + + // + // Control + + rowRenderer = ({ key, rowIndex, style }) => { + const { + rootFolderId, + items, + selectedState, + showLanguageProfile, + onSelectedChange + } = this.props; + + const item = items[rowIndex]; + + return ( + + ); + } + + // + // Render + + render() { + const { + items, + allSelected, + allUnselected, + isSmallScreen, + contentBody, + showLanguageProfile, + scrollTop, + selectedState, + onSelectAllChange, + onScroll + } = this.props; + + if (!items.length) { + return null; + } + + return ( + + } + selectedState={selectedState} + onScroll={onScroll} + /> + ); + } +} + +ImportSeriesTable.propTypes = { + rootFolderId: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object), + unmappedFolders: PropTypes.arrayOf(PropTypes.object), + defaultMonitor: PropTypes.string.isRequired, + defaultQualityProfileId: PropTypes.number, + defaultLanguageProfileId: PropTypes.number, + defaultSeriesType: PropTypes.string.isRequired, + defaultSeasonFolder: PropTypes.bool.isRequired, + allSelected: PropTypes.bool.isRequired, + allUnselected: PropTypes.bool.isRequired, + selectedState: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + allSeries: PropTypes.arrayOf(PropTypes.object), + contentBody: PropTypes.object.isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + scrollTop: PropTypes.number.isRequired, + onSelectAllChange: PropTypes.func.isRequired, + onSelectedChange: PropTypes.func.isRequired, + onRemoveSelectedStateItem: PropTypes.func.isRequired, + onSeriesLookup: PropTypes.func.isRequired, + onSetImportSeriesValue: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default ImportSeriesTable; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTableConnector.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTableConnector.js new file mode 100644 index 000000000..a09d5fa80 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesTableConnector.js @@ -0,0 +1,44 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import ImportSeriesTable from './ImportSeriesTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.addSeries, + (state) => state.importSeries, + (state) => state.app.dimensions, + createAllSeriesSelector(), + (addSeries, importSeries, dimensions, allSeries) => { + return { + defaultMonitor: addSeries.defaults.monitor, + defaultQualityProfileId: addSeries.defaults.qualityProfileId, + defaultLanguageProfileId: addSeries.defaults.languageProfileId, + defaultSeriesType: addSeries.defaults.seriesType, + defaultSeasonFolder: addSeries.defaults.seasonFolder, + items: importSeries.items, + isSmallScreen: dimensions.isSmallScreen, + allSeries + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSeriesLookup(name, path) { + dispatch(queueLookupSeries({ + name, + path, + term: name + })); + }, + + onSetImportSeriesValue(values) { + dispatch(setImportSeriesValue(values)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(ImportSeriesTable); diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css new file mode 100644 index 000000000..a862c117c --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css @@ -0,0 +1,8 @@ +.series { + padding: 10px 20px; + width: 100%; + + &:hover { + background-color: $menuItemHoverBackgroundColor; + } +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js new file mode 100644 index 000000000..d82cdc924 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import ImportSeriesTitle from './ImportSeriesTitle'; +import styles from './ImportSeriesSearchResult.css'; + +class ImportSeriesSearchResult extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.tvdbId); + } + + // + // Render + + render() { + const { + title, + year, + network, + isExistingSeries + } = this.props; + + return ( + + + + ); + } +} + +ImportSeriesSearchResult.propTypes = { + tvdbId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + network: PropTypes.string, + isExistingSeries: PropTypes.bool.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default ImportSeriesSearchResult; diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResultConnector.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResultConnector.js new file mode 100644 index 000000000..81bb3059b --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResultConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector'; +import ImportSeriesSearchResult from './ImportSeriesSearchResult'; + +function createMapStateToProps() { + return createSelector( + createExistingSeriesSelector(), + (isExistingSeries) => { + return { + isExistingSeries + }; + } + ); +} + +export default connect(createMapStateToProps)(ImportSeriesSearchResult); diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.css b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.css new file mode 100644 index 000000000..32ff0489b --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.css @@ -0,0 +1,81 @@ +.tether { + z-index: 2000; +} + +.button { + composes: link from 'Components/Link/Link.css'; + + position: relative; + display: flex; + align-items: center; + padding: 6px 16px; + width: 100%; + height: 35px; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; +} + +.loading { + display: inline-block; +} + +.warningIcon { + margin-right: 8px; +} + +.existing { + margin-left: 5px; +} + +.dropdownArrowContainer { + flex: 1 0 auto; + margin-left: 5px; + text-align: right; +} + +.contentContainer { + margin-top: 4px; + padding: 0 8px; + width: 400px; +} + +.content { + padding: 4px; + border: 1px solid $inputBorderColor; + border-radius: 4px; + background-color: $white; +} + +.searchContainer { + display: flex; +} + +.searchIconContainer { + width: 58px; + border: 1px solid $inputBorderColor; + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: #edf1f2; + text-align: center; + line-height: 33px; +} + +.searchInput { + composes: input from 'Components/Form/TextInput.css'; + + border-radius: 0; +} + +.results { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; + + overflow-x: hidden; + overflow-y: scroll; + max-height: 165px; +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js new file mode 100644 index 000000000..8e21dcb05 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeries.js @@ -0,0 +1,280 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import TetherComponent from 'react-tether'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import FormInputButton from 'Components/Form/FormInputButton'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import TextInput from 'Components/Form/TextInput'; +import ImportSeriesSearchResultConnector from './ImportSeriesSearchResultConnector'; +import ImportSeriesTitle from './ImportSeriesTitle'; +import styles from './ImportSeriesSelectSeries.css'; + +const tetherOptions = { + skipMoveElement: true, + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ], + attachment: 'top center', + targetAttachment: 'bottom center' +}; + +class ImportSeriesSelectSeries extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._seriesLookupTimeout = null; + + this.state = { + term: props.id, + isOpen: false + }; + } + + // + // Control + + _setButtonRef = (ref) => { + this._buttonRef = ref; + } + + _setContentRef = (ref) => { + this._contentRef = ref; + } + + _addListener() { + window.addEventListener('click', this.onWindowClick); + } + + _removeListener() { + window.removeEventListener('click', this.onWindowClick); + } + + // + // Listeners + + onWindowClick = (event) => { + const button = ReactDOM.findDOMNode(this._buttonRef); + const content = ReactDOM.findDOMNode(this._contentRef); + + if (!button) { + return; + } + + if (!button.contains(event.target) && content && !content.contains(event.target) && this.state.isOpen) { + this.setState({ isOpen: false }); + this._removeListener(); + } + } + + onPress = () => { + if (this.state.isOpen) { + this._removeListener(); + } else { + this._addListener(); + } + + this.setState({ isOpen: !this.state.isOpen }); + } + + onSearchInputChange = ({ value }) => { + if (this._seriesLookupTimeout) { + clearTimeout(this._seriesLookupTimeout); + } + + this.setState({ term: value }, () => { + this._seriesLookupTimeout = setTimeout(() => { + this.props.onSearchInputChange(value); + }, 200); + }); + } + + onRefreshPress = () => { + this.props.onSearchInputChange(this.state.term); + } + + onSeriesSelect = (tvdbId) => { + this.setState({ isOpen: false }); + + this.props.onSeriesSelect(tvdbId); + } + + // + // Render + + render() { + const { + selectedSeries, + isExistingSeries, + isFetching, + isPopulated, + error, + items, + isQueued, + isLookingUpSeries + } = this.props; + + const errorMessage = error && + error.responseJSON && + error.responseJSON.message; + + return ( + + + { + isLookingUpSeries && isQueued && !isPopulated && + + } + + { + isPopulated && selectedSeries && isExistingSeries && + + } + + { + isPopulated && selectedSeries && + + } + + { + isPopulated && !selectedSeries && +
+ + + No match found! +
+ } + + { + !isFetching && !!error && +
+ + + Search failed, please try again later. +
+ } + +
+ +
+ + + { + this.state.isOpen && +
+
+
+
+ +
+ + + + + + +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+
+
+ } +
+ ); + } +} + +ImportSeriesSelectSeries.propTypes = { + id: PropTypes.string.isRequired, + selectedSeries: PropTypes.object, + isExistingSeries: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isQueued: PropTypes.bool.isRequired, + isLookingUpSeries: PropTypes.bool.isRequired, + onSearchInputChange: PropTypes.func.isRequired, + onSeriesSelect: PropTypes.func.isRequired +}; + +ImportSeriesSelectSeries.defaultProps = { + isFetching: true, + isPopulated: false, + items: [], + isQueued: true +}; + +export default ImportSeriesSelectSeries; diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js new file mode 100644 index 000000000..a460d6caf --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSelectSeriesConnector.js @@ -0,0 +1,76 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { queueLookupSeries, setImportSeriesValue } from 'Store/Actions/importSeriesActions'; +import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector'; +import ImportSeriesSelectSeries from './ImportSeriesSelectSeries'; + +function createMapStateToProps() { + return createSelector( + (state) => state.importSeries.isLookingUpSeries, + createImportSeriesItemSelector(), + (isLookingUpSeries, item) => { + return { + isLookingUpSeries, + ...item + }; + } + ); +} + +const mapDispatchToProps = { + queueLookupSeries, + setImportSeriesValue +}; + +class ImportSeriesSelectSeriesConnector extends Component { + + // + // Listeners + + onSearchInputChange = (term) => { + this.props.queueLookupSeries({ + name: this.props.id, + term, + topOfQueue: true + }); + } + + onSeriesSelect = (tvdbId) => { + const { + id, + items + } = this.props; + + this.props.setImportSeriesValue({ + id, + selectedSeries: _.find(items, { tvdbId }) + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ImportSeriesSelectSeriesConnector.propTypes = { + id: PropTypes.string.isRequired, + items: PropTypes.arrayOf(PropTypes.object), + selectedSeries: PropTypes.object, + isSelected: PropTypes.bool, + queueLookupSeries: PropTypes.func.isRequired, + setImportSeriesValue: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectSeriesConnector); diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css new file mode 100644 index 000000000..222623179 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css @@ -0,0 +1,20 @@ +.titleContainer { + display: flex; + align-items: center; + flex: 0 1 auto; + overflow: hidden; +} + +.title { + @add-mixin truncate; +} + +.year { + margin-right: 5px; + margin-left: 5px; + color: $disabledColor; +} + +.existing { + margin-left: 5px; +} diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js new file mode 100644 index 000000000..5dfabe6f4 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; +import styles from './ImportSeriesTitle.css'; + +function ImportSeriesTitle(props) { + const { + title, + year, + network, + isExistingSeries + } = props; + + return ( +
+
+ {title} +
+ + { + !title.contains(year) && + year > 0 && + + ({year}) + + } + + { + !!network && + + } + + { + isExistingSeries && + + } +
+ ); +} + +ImportSeriesTitle.propTypes = { + title: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + network: PropTypes.string, + isExistingSeries: PropTypes.bool.isRequired +}; + +export default ImportSeriesTitle; diff --git a/frontend/src/AddSeries/ImportSeries/ImportSeries.js b/frontend/src/AddSeries/ImportSeries/ImportSeries.js new file mode 100644 index 000000000..15362a7d0 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/ImportSeries.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; +import { Route } from 'react-router-dom'; +import Switch from 'Components/Router/Switch'; +import ImportSeriesSelectFolderConnector from 'AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector'; +import ImportSeriesConnector from 'AddSeries/ImportSeries/Import/ImportSeriesConnector'; + +class ImportSeries extends Component { + + // + // Render + + render() { + return ( + + + + + + ); + } +} + +export default ImportSeries; diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.css b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.css new file mode 100644 index 000000000..d9c5ccb01 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.css @@ -0,0 +1,18 @@ +.link { + composes: link from 'Components/Link/Link.css'; + + display: block; +} + +.freeSpace, +.unmappedFolders { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 45px; +} diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.js new file mode 100644 index 000000000..89ea713a0 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRow.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './ImportSeriesRootFolderRow.css'; + +function ImportSeriesRootFolderRow(props) { + const { + id, + path, + freeSpace, + unmappedFolders, + onDeletePress + } = props; + + const unmappedFoldersCount = unmappedFolders.length || '-'; + + return ( + + + + {path} + + + + + {formatBytes(freeSpace) || '-'} + + + + {unmappedFoldersCount} + + + + + + + ); +} + +ImportSeriesRootFolderRow.propTypes = { + id: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + freeSpace: PropTypes.number.isRequired, + unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired, + onDeletePress: PropTypes.func.isRequired +}; + +ImportSeriesRootFolderRow.defaultProps = { + freeSpace: 0, + unmappedFolders: [] +}; + +export default ImportSeriesRootFolderRow; diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRowConnector.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRowConnector.js new file mode 100644 index 000000000..f0fb03921 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesRootFolderRowConnector.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { deleteRootFolder } from 'Store/Actions/rootFolderActions'; +import ImportSeriesRootFolderRow from './ImportSeriesRootFolderRow'; + +function createMapStateToProps() { + return createSelector( + () => { + return { + }; + } + ); +} + +const mapDispatchToProps = { + deleteRootFolder +}; + +class ImportSeriesRootFolderRowConnector extends Component { + + // + // Listeners + + onDeletePress = () => { + this.props.deleteRootFolder({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ImportSeriesRootFolderRowConnector.propTypes = { + id: PropTypes.number.isRequired, + deleteRootFolder: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesRootFolderRowConnector); diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css new file mode 100644 index 000000000..030da96fb --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css @@ -0,0 +1,32 @@ +.header { + margin-bottom: 40px; + text-align: center; + font-weight: 300; + font-size: 36px; +} + +.tips { + font-size: 20px; +} + +.tip { + font-size: $defaultFontSize; +} + +.code { + font-size: 12px; + font-family: $monoSpaceFontFamily; +} + +.recentFolders { + margin-top: 40px; +} + +.startImport { + margin-top: 40px; + text-align: center; +} + +.importButtonIcon { + margin-right: 8px; +} diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js new file mode 100644 index 000000000..13b9d5f6d --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js @@ -0,0 +1,188 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import ImportSeriesRootFolderRowConnector from './ImportSeriesRootFolderRowConnector'; +import styles from './ImportSeriesSelectFolder.css'; + +const rootFolderColumns = [ + { + name: 'path', + label: 'Path', + isVisible: true + }, + { + name: 'freeSpace', + label: 'Free Space', + isVisible: true + }, + { + name: 'unmappedFolders', + label: 'Unmapped Folders', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +class ImportSeriesSelectFolder extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddNewRootFolderModalOpen: false + }; + } + + // + // Lifecycle + + onAddNewRootFolderPress = () => { + this.setState({ isAddNewRootFolderModalOpen: true }); + } + + onNewRootFolderSelect = ({ value }) => { + this.props.onNewRootFolderSelect(value); + } + + onAddRootFolderModalClose = () => { + this.setState({ isAddNewRootFolderModalOpen: false }); + } + + // + // Render + + render() { + const { + isWindows, + isFetching, + isPopulated, + error, + items + } = this.props; + + return ( + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load root folders
+ } + + { + !error && isPopulated && +
+
+ Import series you already have +
+ +
+ Some tips to ensure the import goes smoothly: +
    +
  • + Make sure your files include the quality in the name. eg. episode.s02e15.bluray.mkv +
  • +
  • + Point Sonarr to the folder containing all of your tv shows not a specific one. eg. "{isWindows ? 'C:\\tv shows' : '/tv shows'}" and not "{isWindows ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons'}" +
  • +
+
+ + { + items.length > 0 ? +
+
+ + + { + items.map((rootFolder) => { + return ( + + ); + }) + } + +
+
+ + +
: + +
+ +
+ } + + +
+ } +
+
+ ); + } +} + +ImportSeriesSelectFolder.propTypes = { + isWindows: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onNewRootFolderSelect: PropTypes.func.isRequired, + onDeleteRootFolderPress: PropTypes.func.isRequired +}; + +export default ImportSeriesSelectFolder; diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js new file mode 100644 index 000000000..60729cd6a --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js @@ -0,0 +1,91 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { push } from 'react-router-redux'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { fetchRootFolders, addRootFolder, deleteRootFolder } from 'Store/Actions/rootFolderActions'; +import ImportSeriesSelectFolder from './ImportSeriesSelectFolder'; + +function createMapStateToProps() { + return createSelector( + (state) => state.rootFolders, + createSystemStatusSelector(), + (rootFolders, systemStatus) => { + return { + ...rootFolders, + isWindows: systemStatus.isWindows + }; + } + ); +} + +const mapDispatchToProps = { + fetchRootFolders, + addRootFolder, + deleteRootFolder, + push +}; + +class ImportSeriesSelectFolderConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchRootFolders(); + } + + componentDidUpdate(prevProps) { + const { + items, + isSaving, + saveError + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id); + + if (newRootFolders.length === 1) { + this.props.push(`${window.Sonarr.urlBase}/add/import/${newRootFolders[0].id}`); + } + } + } + + // + // Listeners + + onNewRootFolderSelect = (path) => { + this.props.addRootFolder({ path }); + } + + onDeleteRootFolderPress = (id) => { + this.props.deleteRootFolder({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ImportSeriesSelectFolderConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchRootFolders: PropTypes.func.isRequired, + addRootFolder: PropTypes.func.isRequired, + deleteRootFolder: PropTypes.func.isRequired, + push: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportSeriesSelectFolderConnector); diff --git a/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js b/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js new file mode 100644 index 000000000..e889fbb09 --- /dev/null +++ b/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js @@ -0,0 +1,46 @@ +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; + +function SeriesMonitoringOptionsPopoverContent() { + return ( + + + + + + + + + + + + + + + + ); +} + +export default SeriesMonitoringOptionsPopoverContent; diff --git a/frontend/src/AddSeries/SeriesTypePopoverContent.js b/frontend/src/AddSeries/SeriesTypePopoverContent.js new file mode 100644 index 000000000..e57d49a9e --- /dev/null +++ b/frontend/src/AddSeries/SeriesTypePopoverContent.js @@ -0,0 +1,26 @@ +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; + +function SeriesTypePopoverContent() { + return ( + + + + + + + + ); +} + +export default SeriesTypePopoverContent; diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js new file mode 100644 index 000000000..a05ff1fc6 --- /dev/null +++ b/frontend/src/App/App.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DocumentTitle from 'react-document-title'; +import { Provider } from 'react-redux'; +import { ConnectedRouter } from 'react-router-redux'; +import PageConnector from 'Components/Page/PageConnector'; +import AppRoutes from './AppRoutes'; + +function App({ store, history }) { + return ( + + + + + + + + + + ); +} + +App.propTypes = { + store: PropTypes.object.isRequired, + history: PropTypes.object.isRequired +}; + +export default App; diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js new file mode 100644 index 000000000..4ab75eb22 --- /dev/null +++ b/frontend/src/App/AppRoutes.js @@ -0,0 +1,248 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Route, Redirect } from 'react-router-dom'; +import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; +import NotFound from 'Components/NotFound'; +import Switch from 'Components/Router/Switch'; +import SeriesIndexConnector from 'Series/Index/SeriesIndexConnector'; +import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector'; +import ImportSeries from 'AddSeries/ImportSeries/ImportSeries'; +import SeriesEditorConnector from 'Series/Editor/SeriesEditorConnector'; +import SeasonPassConnector from 'SeasonPass/SeasonPassConnector'; +import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector'; +import CalendarPageConnector from 'Calendar/CalendarPageConnector'; +import HistoryConnector from 'Activity/History/HistoryConnector'; +import QueueConnector from 'Activity/Queue/QueueConnector'; +import BlacklistConnector from 'Activity/Blacklist/BlacklistConnector'; +import MissingConnector from 'Wanted/Missing/MissingConnector'; +import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector'; +import Settings from 'Settings/Settings'; +import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector'; +import Profiles from 'Settings/Profiles/Profiles'; +import Quality from 'Settings/Quality/Quality'; +import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector'; +import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; +import NotificationSettings from 'Settings/Notifications/NotificationSettings'; +import MetadataSettings from 'Settings/Metadata/MetadataSettings'; +import TagSettings from 'Settings/Tags/TagSettings'; +import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; +import UISettingsConnector from 'Settings/UI/UISettingsConnector'; +import Status from 'System/Status/Status'; +import Tasks from 'System/Tasks/Tasks'; +import BackupsConnector from 'System/Backup/BackupsConnector'; +import UpdatesConnector from 'System/Updates/UpdatesConnector'; +import LogsTableConnector from 'System/Events/LogsTableConnector'; +import Logs from 'System/Logs/Logs'; + +function AppRoutes(props) { + const { + app + } = props; + + return ( + + {/* + Series + */} + + + + { + window.Sonarr.urlBase && + { + return ( + + ); + }} + /> + } + + + + + + + + + + + + {/* + Calendar + */} + + + + {/* + Activity + */} + + + + + + + + {/* + Wanted + */} + + + + + + {/* + Settings + */} + + + + + + + + + + + + + + + + + + + + + + + + {/* + System + */} + + + + + + + + + + + + + + {/* + Not Found + */} + + + + ); +} + +AppRoutes.propTypes = { + app: PropTypes.func.isRequired +}; + +export default AppRoutes; diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js new file mode 100644 index 000000000..abc7f8832 --- /dev/null +++ b/frontend/src/App/AppUpdatedModal.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector'; + +function AppUpdatedModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +AppUpdatedModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AppUpdatedModal; diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js new file mode 100644 index 000000000..a21afbc5a --- /dev/null +++ b/frontend/src/App/AppUpdatedModalConnector.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; +import AppUpdatedModal from './AppUpdatedModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + location.reload(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(AppUpdatedModal); diff --git a/frontend/src/App/AppUpdatedModalContent.css b/frontend/src/App/AppUpdatedModalContent.css new file mode 100644 index 000000000..37b89c9be --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.css @@ -0,0 +1,15 @@ +.version { + margin: 0 3px; + font-weight: bold; +} + +.maintenance { + margin-top: 20px; +} + +.changes { + margin-top: 20px; + padding-bottom: 5px; + border-bottom: 1px solid #e5e5e5; + font-size: 18px; +} diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js new file mode 100644 index 000000000..c08dc6177 --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.js @@ -0,0 +1,98 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import UpdateChanges from 'System/Updates/UpdateChanges'; +import styles from './AppUpdatedModalContent.css'; + +function AppUpdatedModalContent(props) { + const { + version, + isPopulated, + error, + items, + onSeeChangesPress, + onModalClose + } = props; + + const update = items[0]; + + return ( + + + Sonarr Updated + + + +
+ Version {version} of Sonarr has been installed, in order to get the latest changes you'll need to reload Sonarr. +
+ + { + isPopulated && !error && !!update && +
+ { + !update.changes && +
Maintenance release
+ } + + { + !!update.changes && +
+
+ What's new? +
+ + + + +
+ } +
+ } + + { + !isPopulated && !error && + + } +
+ + + + + + +
+ ); +} + +AppUpdatedModalContent.propTypes = { + version: PropTypes.string.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onSeeChangesPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AppUpdatedModalContent; diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js new file mode 100644 index 000000000..b252868ce --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContentConnector.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import AppUpdatedModalContent from './AppUpdatedModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.version, + (state) => state.system.updates, + (version, updates) => { + const { + isPopulated, + error, + items + } = updates; + + return { + version, + isPopulated, + error, + items + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchUpdates() { + dispatch(fetchUpdates()); + }, + + onSeeChangesPress() { + window.location = `${window.Sonarr.urlBase}/system/updates`; + } + }; +} + +class AppUpdatedModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchUpdates(); + } + + componentDidUpdate(prevProps) { + if (prevProps.version !== this.props.version) { + this.props.dispatchFetchUpdates(); + } + } + + // + // Render + + render() { + const { + dispatchFetchUpdates, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +AppUpdatedModalContentConnector.propTypes = { + version: PropTypes.string.isRequired, + dispatchFetchUpdates: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector); diff --git a/frontend/src/App/ConnectionLostModal.css b/frontend/src/App/ConnectionLostModal.css new file mode 100644 index 000000000..f0a9d220f --- /dev/null +++ b/frontend/src/App/ConnectionLostModal.css @@ -0,0 +1,3 @@ +.automatic { + margin-top: 20px; +} diff --git a/frontend/src/App/ConnectionLostModal.js b/frontend/src/App/ConnectionLostModal.js new file mode 100644 index 000000000..9ebed8ed3 --- /dev/null +++ b/frontend/src/App/ConnectionLostModal.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './ConnectionLostModal.css'; + +function ConnectionLostModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + Connnection Lost + + + +
+ Sonarr has lost it's connection to the backend and will need to be reloaded to restore functionality. +
+ +
+ Sonarr will try to connect automatically, or you can click reload below. +
+
+ + + +
+
+ ); +} + +ConnectionLostModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ConnectionLostModal; diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js new file mode 100644 index 000000000..8ab8e3cd0 --- /dev/null +++ b/frontend/src/App/ConnectionLostModalConnector.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; +import ConnectionLostModal from './ConnectionLostModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + location.reload(); + } + }; +} + +export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal); diff --git a/frontend/src/Calendar/Agenda/Agenda.css b/frontend/src/Calendar/Agenda/Agenda.css new file mode 100644 index 000000000..0304d9db5 --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.css @@ -0,0 +1,3 @@ +.agenda { + margin-top: 10px; +} diff --git a/frontend/src/Calendar/Agenda/Agenda.js b/frontend/src/Calendar/Agenda/Agenda.js new file mode 100644 index 000000000..89472301d --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.js @@ -0,0 +1,38 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import AgendaEventConnector from './AgendaEventConnector'; +import styles from './Agenda.css'; + +function Agenda(props) { + const { + items + } = props; + + return ( +
+ { + items.map((item, index) => { + const momentDate = moment(item.airDateUtc); + const showDate = index === 0 || + !moment(items[index - 1].airDateUtc).isSame(momentDate, 'day'); + + return ( + + ); + }) + } +
+ ); +} + +Agenda.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Agenda; diff --git a/frontend/src/Calendar/Agenda/AgendaConnector.js b/frontend/src/Calendar/Agenda/AgendaConnector.js new file mode 100644 index 000000000..b6f238873 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaConnector.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import Agenda from './Agenda'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + (calendar) => { + return calendar; + } + ); +} + +export default connect(createMapStateToProps)(Agenda); diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css b/frontend/src/Calendar/Agenda/AgendaEvent.css new file mode 100644 index 000000000..e62cf63c2 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.css @@ -0,0 +1,117 @@ +.event { + display: flex; + overflow-x: hidden; + padding: 5px; + border-bottom: 1px solid $borderColor; + font-size: $defaultFontSize; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} + +.eventWrapper { + display: flex; + flex: 1 0 1px; + overflow-x: hidden; + padding-left: 6px; + border-left-width: 4px; + border-left-style: solid; +} + +.date { + flex: 0 0 250px; + font-weight: bold; +} + +.time { + flex: 0 0 120px; + margin-right: 10px; + border: none !important; +} + +.seriesTitle, +.episodeTitle { + @add-mixin truncate; + + flex: 0 1 300px; + margin-right: 10px; +} + +.episodeTitle { + flex: 1 1 1px; +} + +.seasonEpisodeNumber { + flex: 0 0 100px; +} + +.episodeSeparator { + display: none; +} + +.absoluteEpisodeNumber { + margin-left: 3px; +} + +.statusIcon { + margin-left: 3px; +} + +/* + * Status + */ + +.downloaded { + composes: downloaded from 'Calendar/Events/CalendarEvent.css'; +} + +.downloading { + composes: downloading from 'Calendar/Events/CalendarEvent.css'; +} + +.unmonitored { + composes: unmonitored from 'Calendar/Events/CalendarEvent.css'; +} + +.onAir { + composes: onAir from 'Calendar/Events/CalendarEvent.css'; +} + +.missing { + composes: missing from 'Calendar/Events/CalendarEvent.css'; +} + +.premiere { + composes: premiere from 'Calendar/Events/CalendarEvent.css'; +} + +@media only screen and (max-width: $breakpointSmall) { + .event { + flex-direction: column; + } + + .eventWrapper { + display: block; + flex: 0 0 auto; + } + + .date { + margin-left: 10px; + } + + .date, + .time, + .seriesTitle { + flex: 0 0 100%; + } + + .seasonEpisodeNumber { + flex: 0 0 auto; + } + + .episodeSeparator { + display: inline-block; + margin: 0 5px; + } +} diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js new file mode 100644 index 000000000..3d5aa36fb --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.js @@ -0,0 +1,253 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import { icons, kinds } from 'Helpers/Props'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import episodeEntities from 'Episode/episodeEntities'; +import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; +import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; +import styles from './AgendaEvent.css'; + +class AgendaEvent extends Component { + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + series, + episodeFile, + title, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + airDateUtc, + monitored, + hasFile, + grabbed, + queueItem, + showDate, + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + timeFormat, + longDateFormat, + colorImpairedMode + } = this.props; + + const startTime = moment(airDateUtc); + const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); + const downloading = !!(queueItem || grabbed); + const isMonitored = series.monitored && monitored; + const statusStyle = getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored); + const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; + const season = series.seasons.find((s) => s.seasonNumber === seasonNumber); + const seasonStatistics = season.statistics || {}; + + return ( +
+ +
+ { + showDate && + startTime.format(longDateFormat) + } +
+ +
+
+ {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} +
+ +
+ {series.title} +
+ + { + showEpisodeInformation && +
+ {seasonNumber}x{padNumber(episodeNumber, 2)} + + { + series.seriesType === 'anime' && absoluteEpisodeNumber && + ({absoluteEpisodeNumber}) + } + +
-
+
+ } + +
+ { + showEpisodeInformation && + title + } +
+ + { + missingAbsoluteNumber && + + } + + { + !!queueItem && + + + + } + + { + !queueItem && grabbed && + + } + + { + showCutoffUnmetIcon && + !!episodeFile && + episodeFile.qualityCutoffNotMet && + + } + + { + showCutoffUnmetIcon && + !!episodeFile && + episodeFile.languageCutoffNotMet && + !episodeFile.qualityCutoffNotMet && + + } + + { + episodeNumber === 1 && seasonNumber > 0 && + + } + + { + showFinaleIcon && + episodeNumber !== 1 && + seasonNumber > 0 && + episodeNumber === seasonStatistics.totalEpisodeCount && + + } + + { + showSpecialIcon && + (episodeNumber === 0 || seasonNumber === 0) && + + } +
+ + + +
+ ); + } +} + +AgendaEvent.propTypes = { + id: PropTypes.number.isRequired, + series: PropTypes.object.isRequired, + episodeFile: PropTypes.object, + title: PropTypes.string.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + airDateUtc: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + hasFile: PropTypes.bool.isRequired, + grabbed: PropTypes.bool, + queueItem: PropTypes.object, + showDate: PropTypes.bool.isRequired, + showEpisodeInformation: PropTypes.bool.isRequired, + showFinaleIcon: PropTypes.bool.isRequired, + showSpecialIcon: PropTypes.bool.isRequired, + showCutoffUnmetIcon: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired +}; + +export default AgendaEvent; diff --git a/frontend/src/Calendar/Agenda/AgendaEventConnector.js b/frontend/src/Calendar/Agenda/AgendaEventConnector.js new file mode 100644 index 000000000..e1d996225 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEventConnector.js @@ -0,0 +1,30 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import AgendaEvent from './AgendaEvent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createSeriesSelector(), + createEpisodeFileSelector(), + createQueueItemSelector(), + createUISettingsSelector(), + (calendarOptions, series, episodeFile, queueItem, uiSettings) => { + return { + series, + episodeFile, + queueItem, + ...calendarOptions, + timeFormat: uiSettings.timeFormat, + longDateFormat: uiSettings.longDateFormat, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(AgendaEvent); diff --git a/frontend/src/Calendar/Calendar.css b/frontend/src/Calendar/Calendar.css new file mode 100644 index 000000000..37e6ff618 --- /dev/null +++ b/frontend/src/Calendar/Calendar.css @@ -0,0 +1,8 @@ +.calendar { + flex-grow: 1; + width: 100%; +} + +.calendarContent { + width: 100%; +} diff --git a/frontend/src/Calendar/Calendar.js b/frontend/src/Calendar/Calendar.js new file mode 100644 index 000000000..6ceb1f3bb --- /dev/null +++ b/frontend/src/Calendar/Calendar.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import * as calendarViews from './calendarViews'; +import CalendarHeaderConnector from './Header/CalendarHeaderConnector'; +import DaysOfWeekConnector from './Day/DaysOfWeekConnector'; +import CalendarDaysConnector from './Day/CalendarDaysConnector'; +import AgendaConnector from './Agenda/AgendaConnector'; +import styles from './Calendar.css'; + +class Calendar extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + view + } = this.props; + + return ( +
+ { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load the calendar
+ } + + { + !error && isPopulated && view === calendarViews.AGENDA && +
+ + +
+ } + + { + !error && isPopulated && view !== calendarViews.AGENDA && +
+ + + +
+ } +
+ ); + } +} + +Calendar.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + view: PropTypes.string.isRequired +}; + +export default Calendar; diff --git a/frontend/src/Calendar/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js new file mode 100644 index 000000000..6b743c2c7 --- /dev/null +++ b/frontend/src/Calendar/CalendarConnector.js @@ -0,0 +1,195 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import * as calendarActions from 'Store/Actions/calendarActions'; +import { fetchEpisodeFiles, clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import * as commandNames from 'Commands/commandNames'; +import Calendar from './Calendar'; + +const UPDATE_DELAY = 3600000; // 1 hour + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + (state) => state.settings.ui.item.firstDayOfWeek, + createCommandExecutingSelector(commandNames.REFRESH_SERIES), + (calendar, firstDayOfWeek, isRefreshingSeries) => { + return { + ...calendar, + isRefreshingSeries, + firstDayOfWeek + }; + } + ); +} + +const mapDispatchToProps = { + ...calendarActions, + fetchEpisodeFiles, + clearEpisodeFiles, + fetchQueueDetails, + clearQueueDetails +}; + +class CalendarConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.updateTimeoutId = null; + } + + componentDidMount() { + const { + useCurrentPage, + fetchCalendar, + gotoCalendarToday + } = this.props; + + registerPagePopulator(this.repopulate); + + if (useCurrentPage) { + fetchCalendar(); + } else { + gotoCalendarToday(); + } + + this.scheduleUpdate(); + } + + componentDidUpdate(prevProps) { + const { + items, + time, + view, + isRefreshingSeries, + firstDayOfWeek + } = this.props; + + if (hasDifferentItems(prevProps.items, items)) { + const episodeIds = selectUniqueIds(items, 'id'); + const episodeFileIds = selectUniqueIds(items, 'episodeFileId'); + + if (items.length) { + this.props.fetchQueueDetails({ episodeIds }); + } + + if (episodeFileIds.length) { + this.props.fetchEpisodeFiles({ episodeFileIds }); + } + } + + if (prevProps.time !== time) { + this.scheduleUpdate(); + } + + if (prevProps.firstDayOfWeek !== firstDayOfWeek) { + this.props.fetchCalendar({ time, view }); + } + + if (prevProps.isRefreshingSeries && !isRefreshingSeries) { + this.props.fetchCalendar({ time, view }); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearCalendar(); + this.props.clearQueueDetails(); + this.props.clearEpisodeFiles(); + this.clearUpdateTimeout(); + } + + // + // Control + + repopulate = () => { + const { + time, + view + } = this.props; + + this.props.fetchQueueDetails({ time, view }); + this.props.fetchCalendar({ time, view }); + } + + scheduleUpdate = () => { + this.clearUpdateTimeout(); + + this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY); + } + + clearUpdateTimeout = () => { + if (this.updateTimeoutId) { + clearTimeout(this.updateTimeoutId); + } + } + + updateCalendar = () => { + this.props.gotoCalendarToday(); + this.scheduleUpdate(); + } + + // + // Listeners + + onCalendarViewChange = (view) => { + this.props.setCalendarView({ view }); + } + + onTodayPress = () => { + this.props.gotoCalendarToday(); + } + + onPreviousPress = () => { + this.props.gotoCalendarPreviousRange(); + } + + onNextPress = () => { + this.props.gotoCalendarNextRange(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CalendarConnector.propTypes = { + time: PropTypes.string, + view: PropTypes.string.isRequired, + firstDayOfWeek: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isRefreshingSeries: PropTypes.bool.isRequired, + setCalendarView: PropTypes.func.isRequired, + gotoCalendarToday: PropTypes.func.isRequired, + gotoCalendarPreviousRange: PropTypes.func.isRequired, + gotoCalendarNextRange: PropTypes.func.isRequired, + clearCalendar: PropTypes.func.isRequired, + fetchCalendar: PropTypes.func.isRequired, + fetchEpisodeFiles: PropTypes.func.isRequired, + clearEpisodeFiles: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector); diff --git a/frontend/src/Calendar/CalendarPage.css b/frontend/src/Calendar/CalendarPage.css new file mode 100644 index 000000000..776f3100f --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.css @@ -0,0 +1,14 @@ +.calendarPageBody { + composes: contentBody from 'Components/Page/PageContentBody.css'; + + display: flex; +} + +.calendarInnerPageBody { + composes: innerContentBody from 'Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; + width: 100%; +} diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js new file mode 100644 index 000000000..734bd88ff --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.js @@ -0,0 +1,178 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import Measure from 'Components/Measure'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import NoSeries from 'Series/NoSeries'; +import CalendarLinkModal from './iCal/CalendarLinkModal'; +import CalendarOptionsModal from './Options/CalendarOptionsModal'; +import LegendConnector from './Legend/LegendConnector'; +import CalendarConnector from './CalendarConnector'; +import styles from './CalendarPage.css'; + +const MINIMUM_DAY_WIDTH = 120; + +class CalendarPage extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isCalendarLinkModalOpen: false, + isOptionsModalOpen: false, + width: 0 + }; + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.setState({ width }); + const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH))); + + this.props.onDaysCountChange(days); + } + + onGetCalendarLinkPress = () => { + this.setState({ isCalendarLinkModalOpen: true }); + } + + onGetCalendarLinkModalClose = () => { + this.setState({ isCalendarLinkModalOpen: false }); + } + + onOptionsPress = () => { + this.setState({ isOptionsModalOpen: true }); + } + + onOptionsModalClose = () => { + this.setState({ isOptionsModalOpen: false }); + } + + onSearchMissingPress = () => { + const { + missingEpisodeIds, + onSearchMissingPress + } = this.props; + + onSearchMissingPress(missingEpisodeIds); + } + + // + // Render + + render() { + const { + selectedFilterKey, + filters, + hasSeries, + missingEpisodeIds, + isSearchingForMissing, + useCurrentPage, + onFilterSelect + } = this.props; + + const { + isCalendarLinkModalOpen, + isOptionsModalOpen + } = this.state; + + const isMeasured = this.state.width > 0; + const PageComponent = hasSeries ? CalendarConnector : NoSeries; + + return ( + + + + + + + + + + + + + + + + + + { + isMeasured ? + : +
+ } + + + { + hasSeries && + + } + + + + + + + ); + } +} + +CalendarPage.propTypes = { + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + hasSeries: PropTypes.bool.isRequired, + missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired, + isSearchingForMissing: PropTypes.bool.isRequired, + useCurrentPage: PropTypes.bool.isRequired, + onSearchMissingPress: PropTypes.func.isRequired, + onDaysCountChange: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired +}; + +export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js new file mode 100644 index 000000000..74261c0cd --- /dev/null +++ b/frontend/src/Calendar/CalendarPageConnector.js @@ -0,0 +1,101 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import moment from 'moment'; +import { isCommandExecuting } from 'Utilities/Command'; +import isBefore from 'Utilities/Date/isBefore'; +import withCurrentPage from 'Components/withCurrentPage'; +import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; +import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import CalendarPage from './CalendarPage'; + +function createMissingEpisodeIdsSelector() { + return createSelector( + (state) => state.calendar.start, + (state) => state.calendar.end, + (state) => state.calendar.items, + (state) => state.queue.details.items, + (start, end, episodes, queueDetails) => { + return episodes.reduce((acc, episode) => { + const airDateUtc = episode.airDateUtc; + + if ( + !episode.episodeFileId && + moment(airDateUtc).isAfter(start) && + moment(airDateUtc).isBefore(end) && + isBefore(episode.airDateUtc) && + !queueDetails.some((details) => details.episode.id === episode.id) + ) { + acc.push(episode.id); + } + + return acc; + }, []); + } + ); +} + +function createIsSearchingSelector() { + return createSelector( + (state) => state.calendar.searchMissingCommandId, + createCommandsSelector(), + (searchMissingCommandId, commands) => { + if (searchMissingCommandId == null) { + return false; + } + + return isCommandExecuting(commands.find((command) => { + return command.id === searchMissingCommandId; + })); + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.selectedFilterKey, + (state) => state.calendar.filters, + createSeriesCountSelector(), + createUISettingsSelector(), + createMissingEpisodeIdsSelector(), + createIsSearchingSelector(), + ( + selectedFilterKey, + filters, + seriesCount, + uiSettings, + missingEpisodeIds, + isSearchingForMissing + ) => { + return { + selectedFilterKey, + filters, + colorImpairedMode: uiSettings.enableColorImpairedMode, + hasSeries: !!seriesCount, + missingEpisodeIds, + isSearchingForMissing + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSearchMissingPress(episodeIds) { + dispatch(searchMissing({ episodeIds })); + }, + + onDaysCountChange(dayCount) { + dispatch(setCalendarDaysCount({ dayCount })); + }, + + onFilterSelect(selectedFilterKey) { + dispatch(setCalendarFilter({ selectedFilterKey })); + } + }; +} + +export default withCurrentPage( + connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage) +); diff --git a/frontend/src/Calendar/Day/CalendarDay.css b/frontend/src/Calendar/Day/CalendarDay.css new file mode 100644 index 000000000..1c7694f0b --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDay.css @@ -0,0 +1,25 @@ +.day { + flex: 1 0 14.28%; + overflow: hidden; + min-height: 70px; + border-bottom: 1px solid $borderColor; + border-left: 1px solid $borderColor; +} + +.isSingleDay { + width: 100%; +} + +.dayOfMonth { + padding-right: 5px; + border-bottom: 1px solid $borderColor; + text-align: right; +} + +.isToday { + background-color: $calendarTodayBackgroundColor; +} + +.isDifferentMonth { + color: $disabledColor; +} diff --git a/frontend/src/Calendar/Day/CalendarDay.js b/frontend/src/Calendar/Day/CalendarDay.js new file mode 100644 index 000000000..faa45b28b --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDay.js @@ -0,0 +1,74 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import * as calendarViews from 'Calendar/calendarViews'; +import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; +import CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector'; +import styles from './CalendarDay.css'; + +function CalendarDay(props) { + const { + date, + time, + isTodaysDate, + events, + view, + onEventModalOpenToggle + } = props; + + return ( +
+ { + view === calendarViews.MONTH && +
+ {moment(date).date()} +
+ } +
+ { + events.map((event) => { + if (event.isGroup) { + return ( + + ); + } + + return ( + + ); + }) + } +
+
+ ); +} + +CalendarDay.propTypes = { + date: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + isTodaysDate: PropTypes.bool.isRequired, + events: PropTypes.arrayOf(PropTypes.object).isRequired, + view: PropTypes.string.isRequired, + onEventModalOpenToggle: PropTypes.func.isRequired +}; + +export default CalendarDay; diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js new file mode 100644 index 000000000..8fd6cc5a1 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDayConnector.js @@ -0,0 +1,91 @@ +import _ from 'lodash'; +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import CalendarDay from './CalendarDay'; + +function sort(items) { + return _.sortBy(items, (item) => { + if (item.isGroup) { + return moment(item.events[0].airDateUtc).unix(); + } + + return moment(item.airDateUtc).unix(); + }); +} + +function createCalendarEventsConnector() { + return createSelector( + (state, { date }) => date, + (state) => state.calendar.items, + (state) => state.calendar.options.collapseMultipleEpisodes, + (date, items, collapseMultipleEpisodes) => { + const filtered = _.filter(items, (item) => { + return moment(date).isSame(moment(item.airDateUtc), 'day'); + }); + + if (!collapseMultipleEpisodes) { + return sort(filtered); + } + + const groupedObject = _.groupBy(filtered, (item) => `${item.seriesId}-${item.seasonNumber}`); + const grouped = []; + + Object.keys(groupedObject).forEach((key) => { + const events = groupedObject[key]; + + if (events.length === 1) { + grouped.push(events[0]); + } else { + grouped.push({ + isGroup: true, + seriesId: events[0].seriesId, + seasonNumber: events[0].seasonNumber, + episodeIds: events.map((event) => event.id), + events: _.sortBy(events, (item) => moment(item.airDateUtc).unix()) + }); + } + }); + + const sorted = sort(grouped); + + return sorted; + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + createCalendarEventsConnector(), + (calendar, events) => { + return { + time: calendar.time, + view: calendar.view, + events + }; + } + ); +} + +class CalendarDayConnector extends Component { + + // + // Render + + render() { + return ( + + ); + } +} + +CalendarDayConnector.propTypes = { + date: PropTypes.string.isRequired +}; + +export default connect(createMapStateToProps)(CalendarDayConnector); diff --git a/frontend/src/Calendar/Day/CalendarDays.css b/frontend/src/Calendar/Day/CalendarDays.css new file mode 100644 index 000000000..22005e3e6 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.css @@ -0,0 +1,14 @@ +.days { + display: flex; + border-right: 1px solid $borderColor; +} + +.day, +.week, +.forecast { + flex-wrap: nowrap; +} + +.month { + flex-wrap: wrap; +} diff --git a/frontend/src/Calendar/Day/CalendarDays.js b/frontend/src/Calendar/Day/CalendarDays.js new file mode 100644 index 000000000..0a1a36172 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.js @@ -0,0 +1,164 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import isToday from 'Utilities/Date/isToday'; +import * as calendarViews from 'Calendar/calendarViews'; +import CalendarDayConnector from './CalendarDayConnector'; +import styles from './CalendarDays.css'; + +class CalendarDays extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._touchStart = null; + + this.state = { + todaysDate: moment().startOf('day').toISOString(), + isEventModalOpen: false + }; + + this.updateTimeoutId = null; + } + + // Lifecycle + + componentDidMount() { + const view = this.props.view; + + if (view === calendarViews.MONTH) { + this.scheduleUpdate(); + } + + window.addEventListener('touchstart', this.onTouchStart); + window.addEventListener('touchend', this.onTouchEnd); + window.addEventListener('touchcancel', this.onTouchCancel); + window.addEventListener('touchmove', this.onTouchMove); + } + + componentWillUnmount() { + this.clearUpdateTimeout(); + + window.removeEventListener('touchstart', this.onTouchStart); + window.removeEventListener('touchend', this.onTouchEnd); + window.removeEventListener('touchcancel', this.onTouchCancel); + window.removeEventListener('touchmove', this.onTouchMove); + } + + // + // Control + + scheduleUpdate = () => { + this.clearUpdateTimeout(); + const todaysDate = moment().startOf('day'); + const diff = moment().diff(todaysDate.clone().add(1, 'day')); + + this.setState({ todaysDate: todaysDate.toISOString() }); + + this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); + } + + clearUpdateTimeout = () => { + if (this.updateTimeoutId) { + clearTimeout(this.updateTimeoutId); + } + } + + // + // Listeners + + onEventModalOpenToggle = (isEventModalOpen) => { + this.setState({ isEventModalOpen }); + } + + onTouchStart = (event) => { + const touches = event.touches; + const touchStart = touches[0].pageX; + + if (touches.length !== 1) { + return; + } + + if ( + touchStart < 50 || + this.props.isSidebarVisible || + this.state.isEventModalOpen + ) { + return; + } + + this._touchStart = touchStart; + } + + onTouchEnd = (event) => { + const touches = event.changedTouches; + const currentTouch = touches[0].pageX; + + if (!this._touchStart) { + return; + } + + if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) { + this.props.onNavigatePrevious(); + } else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) { + this.props.onNavigateNext(); + } + + this._touchStart = null; + } + + onTouchCancel = (event) => { + this._touchStart = null; + } + + onTouchMove = (event) => { + if (!this._touchStart) { + return; + } + } + + // + // Render + + render() { + const { + dates, + view + } = this.props; + + return ( +
+ { + dates.map((date) => { + return ( + + ); + }) + } +
+ ); + } +} + +CalendarDays.propTypes = { + dates: PropTypes.arrayOf(PropTypes.string).isRequired, + view: PropTypes.string.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + onNavigatePrevious: PropTypes.func.isRequired, + onNavigateNext: PropTypes.func.isRequired +}; + +export default CalendarDays; diff --git a/frontend/src/Calendar/Day/CalendarDaysConnector.js b/frontend/src/Calendar/Day/CalendarDaysConnector.js new file mode 100644 index 000000000..3dea906a7 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDaysConnector.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions'; +import CalendarDays from './CalendarDays'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + (state) => state.app.isSidebarVisible, + (calendar, isSidebarVisible) => { + return { + dates: calendar.dates, + view: calendar.view, + isSidebarVisible + }; + } + ); +} + +const mapDispatchToProps = { + onNavigatePrevious: gotoCalendarPreviousRange, + onNavigateNext: gotoCalendarNextRange +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays); diff --git a/frontend/src/Calendar/Day/DayOfWeek.css b/frontend/src/Calendar/Day/DayOfWeek.css new file mode 100644 index 000000000..8c3552e55 --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.css @@ -0,0 +1,13 @@ +.dayOfWeek { + flex: 1 0 14.28%; + background-color: #e4eaec; + text-align: center; +} + +.isSingleDay { + width: 100%; +} + +.isToday { + background-color: $calendarTodayBackgroundColor; +} diff --git a/frontend/src/Calendar/Day/DayOfWeek.js b/frontend/src/Calendar/Day/DayOfWeek.js new file mode 100644 index 000000000..d97671522 --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.js @@ -0,0 +1,56 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import * as calendarViews from 'Calendar/calendarViews'; +import styles from './DayOfWeek.css'; + +class DayOfWeek extends Component { + + // + // Render + + render() { + const { + date, + view, + isTodaysDate, + calendarWeekColumnHeader, + shortDateFormat, + showRelativeDates + } = this.props; + + const highlightToday = view !== calendarViews.MONTH && isTodaysDate; + const momentDate = moment(date); + let formatedDate = momentDate.format('dddd'); + + if (view === calendarViews.WEEK) { + formatedDate = momentDate.format(calendarWeekColumnHeader); + } else if (view === calendarViews.FORECAST) { + formatedDate = getRelativeDate(date, shortDateFormat, showRelativeDates); + } + + return ( +
+ {formatedDate} +
+ ); + } +} + +DayOfWeek.propTypes = { + date: PropTypes.string.isRequired, + view: PropTypes.string.isRequired, + isTodaysDate: PropTypes.bool.isRequired, + calendarWeekColumnHeader: PropTypes.string.isRequired, + shortDateFormat: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired +}; + +export default DayOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.css b/frontend/src/Calendar/Day/DaysOfWeek.css new file mode 100644 index 000000000..518664633 --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.css @@ -0,0 +1,4 @@ +.daysOfWeek { + display: flex; + margin-top: 10px; +} diff --git a/frontend/src/Calendar/Day/DaysOfWeek.js b/frontend/src/Calendar/Day/DaysOfWeek.js new file mode 100644 index 000000000..a67777f7c --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.js @@ -0,0 +1,97 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import DayOfWeek from './DayOfWeek'; +import * as calendarViews from 'Calendar/calendarViews'; +import styles from './DaysOfWeek.css'; + +class DaysOfWeek extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + todaysDate: moment().startOf('day').toISOString() + }; + + this.updateTimeoutId = null; + } + + // Lifecycle + + componentDidMount() { + const view = this.props.view; + + if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) { + this.scheduleUpdate(); + } + } + + componentWillUnmount() { + this.clearUpdateTimeout(); + } + + // + // Control + + scheduleUpdate = () => { + this.clearUpdateTimeout(); + const todaysDate = moment().startOf('day'); + const diff = todaysDate.clone().add(1, 'day').diff(moment()); + + this.setState({ + todaysDate: todaysDate.toISOString() + }); + + this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); + } + + clearUpdateTimeout = () => { + if (this.updateTimeoutId) { + clearTimeout(this.updateTimeoutId); + } + } + + // + // Render + + render() { + const { + dates, + view, + ...otherProps + } = this.props; + + if (view === calendarViews.AGENDA) { + return null; + } + + return ( +
+ { + dates.map((date) => { + return ( + + ); + }) + } +
+ ); + } +} + +DaysOfWeek.propTypes = { + dates: PropTypes.arrayOf(PropTypes.string), + view: PropTypes.string.isRequired +}; + +export default DaysOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeekConnector.js b/frontend/src/Calendar/Day/DaysOfWeekConnector.js new file mode 100644 index 000000000..7f5cdef19 --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeekConnector.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import DaysOfWeek from './DaysOfWeek'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + createUISettingsSelector(), + (calendar, UiSettings) => { + return { + dates: calendar.dates.slice(0, 7), + view: calendar.view, + calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader, + shortDateFormat: UiSettings.shortDateFormat, + showRelativeDates: UiSettings.showRelativeDates + }; + } + ); +} + +export default connect(createMapStateToProps)(DaysOfWeek); diff --git a/frontend/src/Calendar/Events/CalendarEvent.css b/frontend/src/Calendar/Events/CalendarEvent.css new file mode 100644 index 000000000..c135dbc5b --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.css @@ -0,0 +1,78 @@ +.event { + overflow-x: hidden; + margin: 4px 2px; + padding: 5px; + border-bottom: 1px solid $borderColor; + border-left: 4px solid $borderColor; + font-size: 12px; +} + +.info, +.episodeInfo { + display: flex; +} + +.seriesTitle, +.episodeTitle { + @add-mixin truncate; + + flex: 1 0 1px; + margin-right: 10px; +} + +.seriesTitle { + color: #3a3f51; + font-size: $defaultFontSize; +} + +.absoluteEpisodeNumber { + margin-left: 3px; +} + +.statusIcon { + margin-left: 3px; +} + +/* + * Status + */ + +.downloaded { + border-left-color: $successColor !important; +} + +.downloading { + border-left-color: $purple !important; +} + +.unmonitored { + border-left-color: $gray !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(45deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} + +.onAir { + border-left-color: $warningColor !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} + +.missing { + border-left-color: $dangerColor !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} + +.unaired { + border-left-color: $primaryColor !important; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, transparent, transparent 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px); + } +} diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js new file mode 100644 index 000000000..d76e6da3f --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.js @@ -0,0 +1,244 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons, kinds } from 'Helpers/Props'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import episodeEntities from 'Episode/episodeEntities'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; +import CalendarEventQueueDetails from './CalendarEventQueueDetails'; +import styles from './CalendarEvent.css'; + +class CalendarEvent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onPress = () => { + this.setState({ isDetailsModalOpen: true }, () => { + this.props.onEventModalOpenToggle(true); + }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }, () => { + this.props.onEventModalOpenToggle(false); + }); + } + + // + // Render + + render() { + const { + id, + series, + episodeFile, + title, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + airDateUtc, + monitored, + hasFile, + grabbed, + queueItem, + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + timeFormat, + colorImpairedMode + } = this.props; + + if (!series) { + return null; + } + + const startTime = moment(airDateUtc); + const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); + const isDownloading = !!(queueItem || grabbed); + const isMonitored = series.monitored && monitored; + const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, endTime, isMonitored); + const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; + const season = series.seasons.find((s) => s.seasonNumber === seasonNumber); + const seasonStatistics = season.statistics || {}; + + return ( +
+ +
+
+ {series.title} +
+ + { + missingAbsoluteNumber && + + } + + { + !!queueItem && + + + + } + + { + !queueItem && grabbed && + + } + + { + showCutoffUnmetIcon && + !!episodeFile && + episodeFile.qualityCutoffNotMet && + + } + + { + showCutoffUnmetIcon && + !!episodeFile && + episodeFile.languageCutoffNotMet && + !episodeFile.qualityCutoffNotMet && + + } + + { + episodeNumber === 1 && seasonNumber > 0 && + + } + + { + showFinaleIcon && + episodeNumber !== 1 && + seasonNumber > 0 && + episodeNumber === seasonStatistics.totalEpisodeCount && + + } + + { + showSpecialIcon && + (episodeNumber === 0 || seasonNumber === 0) && + + } +
+ + { + showEpisodeInformation && +
+
+ {title} +
+ +
+ {seasonNumber}x{padNumber(episodeNumber, 2)} + + { + series.seriesType === 'anime' && absoluteEpisodeNumber && + ({absoluteEpisodeNumber}) + } +
+
+ } + +
+ {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} +
+ + + +
+ ); + } +} + +CalendarEvent.propTypes = { + id: PropTypes.number.isRequired, + series: PropTypes.object.isRequired, + episodeFile: PropTypes.object, + title: PropTypes.string.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + airDateUtc: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + hasFile: PropTypes.bool.isRequired, + grabbed: PropTypes.bool, + queueItem: PropTypes.object, + showEpisodeInformation: PropTypes.bool.isRequired, + showFinaleIcon: PropTypes.bool.isRequired, + showSpecialIcon: PropTypes.bool.isRequired, + showCutoffUnmetIcon: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired, + onEventModalOpenToggle: PropTypes.func.isRequired +}; + +export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEventConnector.js b/frontend/src/Calendar/Events/CalendarEventConnector.js new file mode 100644 index 000000000..f3b663dae --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventConnector.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import CalendarEvent from './CalendarEvent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createSeriesSelector(), + createEpisodeFileSelector(), + createQueueItemSelector(), + createUISettingsSelector(), + (calendarOptions, series, episodeFile, queueItem, uiSettings) => { + return { + series, + episodeFile, + queueItem, + ...calendarOptions, + timeFormat: uiSettings.timeFormat, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(CalendarEvent); diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.css b/frontend/src/Calendar/Events/CalendarEventGroup.css new file mode 100644 index 000000000..7d1c64b9b --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroup.css @@ -0,0 +1,82 @@ +.eventGroup { + overflow-x: hidden; + margin: 4px 2px; + padding: 5px; + border-bottom: 1px solid $borderColor; + border-left: 4px solid $borderColor; + font-size: 12px; +} + +.info, +.airingInfo { + display: flex; +} + +.seriesTitle { + @add-mixin truncate; + + flex: 1 0 1px; + margin-right: 10px; + color: #3a3f51; + font-size: $defaultFontSize; +} + +.airTime { + flex: 1 0 1px; +} + +.episodeInfo { + margin-left: 10px; +} + +.absoluteEpisodeNumber { + margin-left: 3px; +} + +.expandContainerInline { + display: flex; + justify-content: flex-end; + flex: 1 0 20px; +} + +.expandContainer, +.collapseContainer { + display: flex; + justify-content: center; +} + +.collapseContainer { + margin-bottom: 5px; +} + +.statusIcon { + margin-left: 3px; +} + +/* + * Status + */ + +.downloaded { + composes: downloaded from 'Calendar/Events/CalendarEvent.css'; +} + +.downloading { + composes: downloading from 'Calendar/Events/CalendarEvent.css'; +} + +.unmonitored { + composes: unmonitored from 'Calendar/Events/CalendarEvent.css'; +} + +.onAir { + composes: onAir from 'Calendar/Events/CalendarEvent.css'; +} + +.missing { + composes: missing from 'Calendar/Events/CalendarEvent.css'; +} + +.premiere { + composes: premiere from 'Calendar/Events/CalendarEvent.css'; +} diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.js b/frontend/src/Calendar/Events/CalendarEventGroup.js new file mode 100644 index 000000000..147440e94 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroup.js @@ -0,0 +1,245 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; +import styles from './CalendarEventGroup.css'; + +function getEventsInfo(events) { + let files = 0; + let queued = 0; + let monitored = 0; + let absoluteEpisodeNumbers = 0; + + events.forEach((event) => { + if (event.episodeFileId) { + files++; + } + + if (event.queued) { + queued++; + } + + if (event.monitored) { + monitored++; + } + + if (event.absoluteEpisodeNumber) { + absoluteEpisodeNumbers++; + } + }); + + return { + allDownloaded: files === events.length, + anyQueued: queued > 0, + anyMonitored: monitored > 0, + allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length + }; +} + +class CalendarEventGroup extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isExpanded: false + }; + } + + // + // Listeners + + onExpandPress = () => { + this.setState({ isExpanded: !this.state.isExpanded }); + } + + // + // Render + + render() { + const { + series, + events, + isDownloading, + showEpisodeInformation, + showFinaleIcon, + timeFormat, + colorImpairedMode, + onEventModalOpenToggle + } = this.props; + + const { isExpanded } = this.state; + const { + allDownloaded, + anyQueued, + anyMonitored, + allAbsoluteEpisodeNumbers + } = getEventsInfo(events); + const anyDownloading = isDownloading || anyQueued; + const firstEpisode = events[0]; + const lastEpisode = events[events.length -1]; + const airDateUtc = firstEpisode.airDateUtc; + const startTime = moment(airDateUtc); + const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes'); + const seasonNumber = firstEpisode.seasonNumber; + const statusStyle = getStatusStyle(allDownloaded, anyDownloading, startTime, endTime, anyMonitored); + const isMissingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !allAbsoluteEpisodeNumbers; + + if (isExpanded) { + return ( +
+ { + events.map((event) => { + if (event.isGroup) { + return null; + } + + return ( + + ); + }) + } + + + + +
+ ); + } + + return ( +
+
+
+ {series.title} +
+ + { + isMissingAbsoluteNumber && + + } + + { + anyDownloading && + + } + + { + firstEpisode.episodeNumber === 1 && seasonNumber > 0 && + + } + + { + showFinaleIcon && + lastEpisode.episodeNumber !== 1 && + seasonNumber > 0 && + lastEpisode.episodeNumber === series.seasons.find((season) => season.seasonNumber === seasonNumber).statistics.totalEpisodeCount && + + } +
+ +
+
+ {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} +
+ + { + showEpisodeInformation ? +
+ {seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-{padNumber(lastEpisode.episodeNumber, 2)} + + { + series.seriesType === 'anime' && + firstEpisode.absoluteEpisodeNumber && + lastEpisode.absoluteEpisodeNumber && + + ({firstEpisode.absoluteEpisodeNumber}-{lastEpisode.absoluteEpisodeNumber}) + + } +
: + + + + } +
+ + { + showEpisodeInformation && + + + + } +
+ ); + } +} + +CalendarEventGroup.propTypes = { + series: PropTypes.object.isRequired, + events: PropTypes.arrayOf(PropTypes.object).isRequired, + isDownloading: PropTypes.bool.isRequired, + showEpisodeInformation: PropTypes.bool.isRequired, + showFinaleIcon: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired, + onEventModalOpenToggle: PropTypes.func.isRequired +}; + +export default CalendarEventGroup; diff --git a/frontend/src/Calendar/Events/CalendarEventGroupConnector.js b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js new file mode 100644 index 000000000..731038d58 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js @@ -0,0 +1,37 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import CalendarEventGroup from './CalendarEventGroup'; + +function createIsDownloadingSelector() { + return createSelector( + (state, { episodeIds }) => episodeIds, + (state) => state.queue.details, + (episodeIds, details) => { + return details.items.some((item) => { + return episodeIds.includes(item.episode.id); + }); + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createSeriesSelector(), + createIsDownloadingSelector(), + createUISettingsSelector(), + (calendarOptions, series, isDownloading, uiSettings) => { + return { + series, + isDownloading, + ...calendarOptions, + timeFormat: uiSettings.timeFormat, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(CalendarEventGroup); diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js new file mode 100644 index 000000000..81d81465c --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import colors from 'Styles/Variables/colors'; +import CircularProgressBar from 'Components/CircularProgressBar'; +import QueueDetails from 'Activity/Queue/QueueDetails'; + +function CalendarEventQueueDetails(props) { + const { + title, + size, + sizeleft, + estimatedCompletionTime, + status, + errorMessage + } = props; + + const progress = (100 - sizeleft / size * 100); + + return ( + + +
+ } + /> + ); +} + +CalendarEventQueueDetails.propTypes = { + title: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + sizeleft: PropTypes.number.isRequired, + estimatedCompletionTime: PropTypes.string, + status: PropTypes.string.isRequired, + errorMessage: PropTypes.string +}; + +export default CalendarEventQueueDetails; diff --git a/frontend/src/Calendar/Header/CalendarHeader.css b/frontend/src/Calendar/Header/CalendarHeader.css new file mode 100644 index 000000000..1127bb3c3 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.css @@ -0,0 +1,53 @@ +.header { + display: flex; +} + +.navigationButtons { + flex: 1 1 33%; + text-align: left; +} + +.todayButton { + composes: button from 'Components/Link/Button.css'; + + margin-left: 5px; +} + +.titleDesktop, +.titleMobile { + text-align: center; + font-size: 18px; +} + +.titleMobile { + margin-bottom: 5px; +} + +.viewButtonsContainer { + display: flex; + justify-content: flex-end; + flex: 1 1 33%; +} + +.viewMenu { + composes: menu from 'Components/Menu/Menu.css'; + + line-height: 31px; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin-top: 5px; + margin-right: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .navigationButtons { + flex: 1 0 50%; + } + + .viewButtonsContainer { + flex: 0 0 100px; + } +} diff --git a/frontend/src/Calendar/Header/CalendarHeader.js b/frontend/src/Calendar/Header/CalendarHeader.js new file mode 100644 index 000000000..4fea8356d --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.js @@ -0,0 +1,253 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { align, icons } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; +import * as calendarViews from 'Calendar/calendarViews'; +import CalendarHeaderViewButton from './CalendarHeaderViewButton'; +import styles from './CalendarHeader.css'; + +function getTitle(time, start, end, view, longDateFormat) { + const timeMoment = moment(time); + const startMoment = moment(start); + const endMoment = moment(end); + + if (view === 'day') { + return timeMoment.format(longDateFormat); + } else if (view === 'month') { + return timeMoment.format('MMMM YYYY'); + } else if (view === 'agenda') { + return 'Agenda'; + } + + let startFormat = 'MMM D YYYY'; + let endFormat = 'MMM D YYYY'; + + if (startMoment.isSame(endMoment, 'month')) { + startFormat = 'MMM D'; + endFormat = 'D YYYY'; + } else if (startMoment.isSame(endMoment, 'year')) { + startFormat = 'MMM D'; + endFormat = 'MMM D YYYY'; + } + + return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`; +} + +// TODO Convert to a stateful Component so we can track view internally when changed + +class CalendarHeader extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + view: props.view + }; + } + + componentDidUpdate(prevProps) { + const view = this.props.view; + + if (prevProps.view !== view) { + this.setState({ view }); + } + } + + // + // Listeners + + onViewChange = (view) => { + this.setState({ view }, () => { + this.props.onViewChange(view); + }); + } + + // + // Render + + render() { + const { + isFetching, + time, + start, + end, + longDateFormat, + isSmallScreen, + onTodayPress, + onPreviousPress, + onNextPress + } = this.props; + + const view = this.state.view; + + const title = getTitle(time, start, end, view, longDateFormat); + + return ( +
+ { + isSmallScreen && +
+ {title} +
+ } + +
+
+ + + + + +
+ + { + !isSmallScreen && +
+ {title} +
+ } + +
+ { + isFetching && + + } + + { + isSmallScreen ? + + + + + + + + Week + + + + Forecast + + + + Day + + + + Agenda + + + : + +
+ + + + + + + + + +
+ } +
+
+
+ ); + } +} + +CalendarHeader.propTypes = { + isFetching: PropTypes.bool.isRequired, + time: PropTypes.string.isRequired, + start: PropTypes.string.isRequired, + end: PropTypes.string.isRequired, + view: PropTypes.oneOf(calendarViews.all).isRequired, + isSmallScreen: PropTypes.bool.isRequired, + longDateFormat: PropTypes.string.isRequired, + onViewChange: PropTypes.func.isRequired, + onTodayPress: PropTypes.func.isRequired, + onPreviousPress: PropTypes.func.isRequired, + onNextPress: PropTypes.func.isRequired +}; + +export default CalendarHeader; diff --git a/frontend/src/Calendar/Header/CalendarHeaderConnector.js b/frontend/src/Calendar/Header/CalendarHeaderConnector.js new file mode 100644 index 000000000..c96cf2869 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderConnector.js @@ -0,0 +1,84 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { setCalendarView, gotoCalendarToday, gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions'; +import CalendarHeader from './CalendarHeader'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar, + createDimensionsSelector(), + createUISettingsSelector(), + (calendar, dimensions, uiSettings) => { + const result = _.pick(calendar, [ + 'isFetching', + 'view', + 'time', + 'start', + 'end' + ]); + + result.isSmallScreen = dimensions.isSmallScreen; + result.longDateFormat = uiSettings.longDateFormat; + + return result; + } + ); +} + +const mapDispatchToProps = { + setCalendarView, + gotoCalendarToday, + gotoCalendarPreviousRange, + gotoCalendarNextRange +}; + +class CalendarHeaderConnector extends Component { + + // + // Listeners + + onViewChange = (view) => { + this.props.setCalendarView({ view }); + } + + onTodayPress = () => { + this.props.gotoCalendarToday(); + } + + onPreviousPress = () => { + this.props.gotoCalendarPreviousRange(); + } + + onNextPress = () => { + this.props.gotoCalendarNextRange(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CalendarHeaderConnector.propTypes = { + setCalendarView: PropTypes.func.isRequired, + gotoCalendarToday: PropTypes.func.isRequired, + gotoCalendarPreviousRange: PropTypes.func.isRequired, + gotoCalendarNextRange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector); diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js new file mode 100644 index 000000000..8dd5ae9f0 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import Button from 'Components/Link/Button'; +import * as calendarViews from 'Calendar/calendarViews'; +// import styles from './CalendarHeaderViewButton.css'; + +class CalendarHeaderViewButton extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.view); + } + + // + // Render + + render() { + const { + view, + selectedView, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +CalendarHeaderViewButton.propTypes = { + view: PropTypes.oneOf(calendarViews.all).isRequired, + selectedView: PropTypes.oneOf(calendarViews.all).isRequired, + onPress: PropTypes.func.isRequired +}; + +export default CalendarHeaderViewButton; diff --git a/frontend/src/Calendar/Legend/Legend.css b/frontend/src/Calendar/Legend/Legend.css new file mode 100644 index 000000000..296cbd9d5 --- /dev/null +++ b/frontend/src/Calendar/Legend/Legend.css @@ -0,0 +1,6 @@ +.legend { + display: flex; + flex-wrap: wrap; + margin-top: 10px; + padding: 3px 0; +} diff --git a/frontend/src/Calendar/Legend/Legend.js b/frontend/src/Calendar/Legend/Legend.js new file mode 100644 index 000000000..57d3bad2a --- /dev/null +++ b/frontend/src/Calendar/Legend/Legend.js @@ -0,0 +1,109 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import LegendItem from './LegendItem'; +import LegendIconItem from './LegendIconItem'; +import styles from './Legend.css'; + +function Legend(props) { + const { + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + colorImpairedMode + } = props; + + const iconsToShow = []; + if (showFinaleIcon) { + iconsToShow.push( + + ); + } + + if (showSpecialIcon) { + iconsToShow.push( + + ); + } + + if (showCutoffUnmetIcon) { + iconsToShow.push( + + ); + } + + return ( +
+
+ + + +
+ +
+ + + +
+ +
+ + + {iconsToShow[0]} +
+ + { + iconsToShow.length > 1 && +
+ {iconsToShow[1]} + {iconsToShow[2]} +
+ } +
+ ); +} + +Legend.propTypes = { + showFinaleIcon: PropTypes.bool.isRequired, + showSpecialIcon: PropTypes.bool.isRequired, + showCutoffUnmetIcon: PropTypes.bool.isRequired, + colorImpairedMode: PropTypes.bool.isRequired +}; + +export default Legend; diff --git a/frontend/src/Calendar/Legend/LegendConnector.js b/frontend/src/Calendar/Legend/LegendConnector.js new file mode 100644 index 000000000..30bbc4adb --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendConnector.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import Legend from './Legend'; + +function createMapStateToProps() { + return createSelector( + (state) => state.calendar.options, + createUISettingsSelector(), + (calendarOptions, uiSettings) => { + return { + ...calendarOptions, + colorImpairedMode: uiSettings.enableColorImpairedMode + }; + } + ); +} + +export default connect(createMapStateToProps)(Legend); diff --git a/frontend/src/Calendar/Legend/LegendIconItem.css b/frontend/src/Calendar/Legend/LegendIconItem.css new file mode 100644 index 000000000..01db0ba5a --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.css @@ -0,0 +1,10 @@ +.legendIconItem { + margin: 3px 0; + margin-right: 6px; + width: 150px; + cursor: default; +} + +.icon { + margin-right: 5px; +} diff --git a/frontend/src/Calendar/Legend/LegendIconItem.js b/frontend/src/Calendar/Legend/LegendIconItem.js new file mode 100644 index 000000000..2074a9b1e --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import styles from './legendIconItem.css'; + +function LegendIconItem(props) { + const { + name, + icon, + kind, + tooltip + } = props; + + return ( +
+ + + {name} +
+ ); +} + +LegendIconItem.propTypes = { + name: PropTypes.string.isRequired, + icon: PropTypes.object.isRequired, + kind: PropTypes.string.isRequired, + tooltip: PropTypes.string.isRequired +}; + +export default LegendIconItem; diff --git a/frontend/src/Calendar/Legend/LegendItem.css b/frontend/src/Calendar/Legend/LegendItem.css new file mode 100644 index 000000000..d146e9d68 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendItem.css @@ -0,0 +1,41 @@ +.legendItem { + margin: 3px 0; + margin-right: 6px; + padding-left: 5px; + width: 150px; + border-left-width: 4px; + border-left-style: solid; + cursor: default; +} + +/* + * Status + */ + +.downloaded { + composes: downloaded from 'Calendar/Events/CalendarEvent.css'; +} + +.downloading { + composes: downloading from 'Calendar/Events/CalendarEvent.css'; +} + +.unmonitored { + composes: unmonitored from 'Calendar/Events/CalendarEvent.css'; +} + +.onAir { + composes: onAir from 'Calendar/Events/CalendarEvent.css'; +} + +.missing { + composes: missing from 'Calendar/Events/CalendarEvent.css'; +} + +.premiere { + composes: premiere from 'Calendar/Events/CalendarEvent.css'; +} + +.unaired { + composes: unaired from 'Calendar/Events/CalendarEvent.css'; +} diff --git a/frontend/src/Calendar/Legend/LegendItem.js b/frontend/src/Calendar/Legend/LegendItem.js new file mode 100644 index 000000000..961f48b86 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendItem.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import titleCase from 'Utilities/String/titleCase'; +import styles from './LegendItem.css'; + +function LegendItem(props) { + const { + name, + status, + tooltip, + colorImpairedMode + } = props; + + return ( +
+ {name ? name : titleCase(status)} +
+ ); +} + +LegendItem.propTypes = { + name: PropTypes.string, + status: PropTypes.string.isRequired, + tooltip: PropTypes.string.isRequired, + colorImpairedMode: PropTypes.bool.isRequired +}; + +export default LegendItem; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.js b/frontend/src/Calendar/Options/CalendarOptionsModal.js new file mode 100644 index 000000000..b68c83f30 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModal.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector'; + +function CalendarOptionsModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +CalendarOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarOptionsModal; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js new file mode 100644 index 000000000..9ff7b3f82 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js @@ -0,0 +1,258 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import { firstDayOfWeekOptions, weekColumnOptions, timeFormatOptions } from 'Settings/UI/UISettings'; + +class CalendarOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = props; + + this.state = { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + }; + } + + componentDidUpdate(prevProps) { + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = this.props; + + if ( + prevProps.firstDayOfWeek !== firstDayOfWeek || + prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader || + prevProps.timeFormat !== timeFormat || + prevProps.enableColorImpairedMode !== enableColorImpairedMode + ) { + this.setState({ + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + }); + } + } + + // + // Listeners + + onOptionInputChange = ({ name, value }) => { + const { + dispatchSetCalendarOption + } = this.props; + + dispatchSetCalendarOption({ [name]: value }); + } + + onGlobalInputChange = ({ name, value }) => { + const { + dispatchSaveUISettings + } = this.props; + + const setting = { [name]: value }; + + this.setState(setting, () => { + dispatchSaveUISettings(setting); + }); + } + + onLinkFocus = (event) => { + event.target.select(); + } + + // + // Render + + render() { + const { + collapseMultipleEpisodes, + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + onModalClose + } = this.props; + + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode + } = this.state; + + return ( + + + Calendar Options + + + +
+
+ + 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..929958b66 --- /dev/null +++ b/frontend/src/Calendar/calendarViews.js @@ -0,0 +1,7 @@ +export const DAY = 'day'; +export const WEEK = 'week'; +export const MONTH = 'month'; +export const FORECAST = 'forecast'; +export const AGENDA = 'agenda'; + +export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA]; diff --git a/frontend/src/Calendar/getStatusStyle.js b/frontend/src/Calendar/getStatusStyle.js new file mode 100644 index 000000000..b149a8aab --- /dev/null +++ b/frontend/src/Calendar/getStatusStyle.js @@ -0,0 +1,30 @@ +/* eslint max-params: 0 */ +import moment from 'moment'; + +function getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored) { + const currentTime = moment(); + + if (hasFile) { + return 'downloaded'; + } + + if (downloading) { + return 'downloading'; + } + + if (!isMonitored) { + return 'unmonitored'; + } + + if (currentTime.isAfter(startTime) && currentTime.isBefore(endTime)) { + return 'onAir'; + } + + if (endTime.isBefore(currentTime) && !hasFile) { + return 'missing'; + } + + return 'unaired'; +} + +export default getStatusStyle; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js new file mode 100644 index 000000000..8cc487c16 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModal.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector'; + +function CalendarLinkModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +CalendarLinkModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CalendarLinkModal; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js new file mode 100644 index 000000000..e965b862d --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js @@ -0,0 +1,221 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, inputTypes, kinds, sizes } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import ClipboardButton from 'Components/Link/ClipboardButton'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormInputButton from 'Components/Form/FormInputButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +function getUrls(state) { + const { + unmonitored, + premieresOnly, + asAllDay, + tags + } = state; + + let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/calendar/Sonarr.ics?`; + + if (unmonitored) { + icalUrl += 'unmonitored=true&'; + } + + if (premieresOnly) { + icalUrl += 'premieresOnly=true&'; + } + + if (asAllDay) { + icalUrl += 'asAllDay=true&'; + } + + if (tags.length) { + icalUrl += `tags=${tags.toString()}&`; + } + + icalUrl += `apikey=${window.Sonarr.apiKey}`; + + const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`; + const iCalWebCalUrl = `webcal://${icalUrl}`; + + return { + iCalHttpUrl, + iCalWebCalUrl + }; +} + +class CalendarLinkModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const defaultState = { + unmonitored: false, + premieresOnly: false, + asAllDay: false, + tags: [] + }; + + const urls = getUrls(defaultState); + + this.state = { + ...defaultState, + ...urls + }; + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + const state = { + ...this.state, + [name]: value + }; + + const urls = getUrls(state); + + this.setState({ + [name]: value, + ...urls + }); + } + + onLinkFocus = (event) => { + event.target.select(); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + unmonitored, + premieresOnly, + asAllDay, + tags, + iCalHttpUrl, + iCalWebCalUrl + } = this.state; + + return ( + + + Sonarr Calendar Feed + + + +
+ + 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..a48c59157 --- /dev/null +++ b/frontend/src/Commands/commandNames.js @@ -0,0 +1,20 @@ +export const APPLICATION_UPDATE = 'ApplicationUpdate'; +export const BACKUP = 'Backup'; +export const CHECK_FOR_FINISHED_DOWNLOAD = 'CheckForFinishedDownload'; +export const CLEAR_BLACKLIST = 'ClearBlacklist'; +export const CLEAR_LOGS = 'ClearLog'; +export const CUTOFF_UNMET_EPISODE_SEARCH = 'CutoffUnmetEpisodeSearch'; +export const DELETE_LOG_FILES = 'DeleteLogFiles'; +export const DELETE_UPDATE_LOG_FILES = 'DeleteUpdateLogFiles'; +export const DOWNLOADED_EPSIODES_SCAN = 'DownloadedEpisodesScan'; +export const EPISODE_SEARCH = 'EpisodeSearch'; +export const INTERACTIVE_IMPORT = 'ManualImport'; +export const MISSING_EPISODE_SEARCH = 'MissingEpisodeSearch'; +export const MOVE_SERIES = 'MoveSeries'; +export const REFRESH_SERIES = 'RefreshSeries'; +export const RENAME_FILES = 'RenameFiles'; +export const RENAME_SERIES = 'RenameSeries'; +export const RESET_API_KEY = 'ResetApiKey'; +export const RSS_SYNC = 'RssSync'; +export const SEASON_SEARCH = 'SeasonSearch'; +export const SERIES_SEARCH = 'SeriesSearch'; diff --git a/frontend/src/Components/Alert.css b/frontend/src/Components/Alert.css new file mode 100644 index 000000000..312fbb4f2 --- /dev/null +++ b/frontend/src/Components/Alert.css @@ -0,0 +1,31 @@ +.alert { + display: block; + margin: 5px; + padding: 15px; + border: 1px solid transparent; + border-radius: 4px; +} + +.danger { + border-color: $alertDangerBorderColor; + background-color: $alertDangerBackgroundColor; + color: $alertDangerColor; +} + +.info { + border-color: $alertInfoBorderColor; + background-color: $alertInfoBackgroundColor; + color: $alertInfoColor; +} + +.success { + border-color: $alertSuccessBorderColor; + background-color: $alertSuccessBackgroundColor; + color: $alertSuccessColor; +} + +.warning { + border-color: $alertWarningBorderColor; + background-color: $alertWarningBackgroundColor; + color: $alertWarningColor; +} diff --git a/frontend/src/Components/Alert.js b/frontend/src/Components/Alert.js new file mode 100644 index 000000000..dc19a418c --- /dev/null +++ b/frontend/src/Components/Alert.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { kinds } from 'Helpers/Props'; +import styles from './Alert.css'; + +function Alert({ className, kind, children, ...otherProps }) { + return ( +
+ {children} +
+ ); +} + +Alert.propTypes = { + className: PropTypes.string.isRequired, + kind: PropTypes.oneOf(kinds.all).isRequired, + children: PropTypes.node.isRequired +}; + +Alert.defaultProps = { + className: styles.alert, + kind: kinds.INFO +}; + +export default Alert; diff --git a/frontend/src/Components/Card.css b/frontend/src/Components/Card.css new file mode 100644 index 000000000..b54bbcdf4 --- /dev/null +++ b/frontend/src/Components/Card.css @@ -0,0 +1,19 @@ +.card { + position: relative; + margin: 10px; + padding: 10px; + border-radius: 3px; + background-color: $white; + box-shadow: 0 0 10px 1px $cardShadowColor; + color: $defaultColor; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + position: relative; +} diff --git a/frontend/src/Components/Card.js b/frontend/src/Components/Card.js new file mode 100644 index 000000000..c5a4d164c --- /dev/null +++ b/frontend/src/Components/Card.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './Card.css'; + +class Card extends Component { + + // + // Render + + render() { + const { + className, + overlayClassName, + overlayContent, + children, + onPress + } = this.props; + + if (overlayContent) { + return ( +
+ + +
+ {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..95c84f59a --- /dev/null +++ b/frontend/src/Components/CircularProgressBar.js @@ -0,0 +1,139 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import colors from 'Styles/Variables/colors'; +import styles from './CircularProgressBar.css'; + +class CircularProgressBar extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + progress: 0 + }; + } + + componentDidMount() { + this._progressStep(); + } + + componentDidUpdate(prevProps) { + const progress = this.props.progress; + + if (prevProps.progress !== progress) { + this._cancelProgressStep(); + this._progressStep(); + } + } + + componentWillUnmount() { + this._cancelProgressStep(); + } + + // + // Control + + _progressStep() { + this.requestAnimationFrame = window.requestAnimationFrame(() => { + this.setState({ + progress: this.state.progress + 1 + }, () => { + if (this.state.progress < this.props.progress) { + this._progressStep(); + } + }); + }); + } + + _cancelProgressStep() { + if (this.requestAnimationFrame) { + window.cancelAnimationFrame(this.requestAnimationFrame); + } + } + + // + // Render + + render() { + const { + className, + containerClassName, + size, + strokeWidth, + strokeColor, + showProgressText + } = this.props; + + const progress = this.state.progress; + + const center = size / 2; + const radius = center - strokeWidth; + const circumference = Math.PI * (radius * 2); + const sizeInPixels = `${size}px`; + const strokeDashoffset = ((100 - progress) / 100) * circumference; + const progressText = `${Math.round(progress)}%`; + + return ( +
+ + + + + { + showProgressText && +
+ {progressText} +
+ } +
+ ); + } +} + +CircularProgressBar.propTypes = { + className: PropTypes.string, + containerClassName: PropTypes.string, + size: PropTypes.number, + progress: PropTypes.number.isRequired, + strokeWidth: PropTypes.number, + strokeColor: PropTypes.string, + showProgressText: PropTypes.bool +}; + +CircularProgressBar.defaultProps = { + className: styles.circularProgressBar, + containerClassName: styles.circularProgressBarContainer, + size: 60, + strokeWidth: 5, + strokeColor: colors.sonarrBlue, + showProgressText: false +}; + +export default CircularProgressBar; diff --git a/frontend/src/Components/DescriptionList/DescriptionList.css b/frontend/src/Components/DescriptionList/DescriptionList.css new file mode 100644 index 000000000..230347f80 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionList.css @@ -0,0 +1,4 @@ +.descriptionList { + margin-top: 0; + margin-bottom: 0; +} diff --git a/frontend/src/Components/DescriptionList/DescriptionList.js b/frontend/src/Components/DescriptionList/DescriptionList.js new file mode 100644 index 000000000..be2c87c55 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionList.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './DescriptionList.css'; + +class DescriptionList extends Component { + + // + // Render + + render() { + const { + className, + children + } = this.props; + + return ( +
+ {children} +
+ ); + } +} + +DescriptionList.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node +}; + +DescriptionList.defaultProps = { + className: styles.descriptionList +}; + +export default DescriptionList; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js new file mode 100644 index 000000000..4ba70bf33 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItem.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import DescriptionListItemTitle from './DescriptionListItemTitle'; +import DescriptionListItemDescription from './DescriptionListItemDescription'; + +class DescriptionListItem extends Component { + + // + // Render + + render() { + const { + titleClassName, + descriptionClassName, + title, + data + } = this.props; + + return ( + + + {title} + + + + {data} + + + ); + } +} + +DescriptionListItem.propTypes = { + titleClassName: PropTypes.string, + descriptionClassName: PropTypes.string, + title: PropTypes.string, + data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) +}; + +export default DescriptionListItem; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css new file mode 100644 index 000000000..b23415a76 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css @@ -0,0 +1,13 @@ +.description { + line-height: $lineHeight; +} + +.description { + margin-left: 0; +} + +@media (min-width: 768px) { + .description { + margin-left: 180px; + } +} diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js new file mode 100644 index 000000000..4ef3c015e --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './DescriptionListItemDescription.css'; + +function DescriptionListItemDescription(props) { + const { + className, + children + } = props; + + return ( +
+ {children} +
+ ); +} + +DescriptionListItemDescription.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) +}; + +DescriptionListItemDescription.defaultProps = { + className: styles.description +}; + +export default DescriptionListItemDescription; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css new file mode 100644 index 000000000..e496e463d --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css @@ -0,0 +1,18 @@ +.title { + line-height: $lineHeight; +} + +.title { + font-weight: bold; +} + +@media (min-width: 768px) { + .title { + @add-mixin truncate; + + float: left; + clear: left; + width: 160px; + text-align: right; + } +} diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js new file mode 100644 index 000000000..e1632c1cf --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './DescriptionListItemTitle.css'; + +function DescriptionListItemTitle(props) { + const { + className, + children + } = props; + + return ( +
+ {children} +
+ ); +} + +DescriptionListItemTitle.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.string +}; + +DescriptionListItemTitle.defaultProps = { + className: styles.title +}; + +export default DescriptionListItemTitle; diff --git a/frontend/src/Components/DragPreviewLayer.css b/frontend/src/Components/DragPreviewLayer.css new file mode 100644 index 000000000..46f721fef --- /dev/null +++ b/frontend/src/Components/DragPreviewLayer.css @@ -0,0 +1,9 @@ +.dragLayer { + position: fixed; + top: 0; + left: 0; + z-index: 9999; + width: 100%; + height: 100%; + pointer-events: none; +} diff --git a/frontend/src/Components/DragPreviewLayer.js b/frontend/src/Components/DragPreviewLayer.js new file mode 100644 index 000000000..a111df70e --- /dev/null +++ b/frontend/src/Components/DragPreviewLayer.js @@ -0,0 +1,22 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './DragPreviewLayer.css'; + +function DragPreviewLayer({ children, ...otherProps }) { + return ( +
+ {children} +
+ ); +} + +DragPreviewLayer.propTypes = { + children: PropTypes.node, + className: PropTypes.string +}; + +DragPreviewLayer.defaultProps = { + className: styles.dragLayer +}; + +export default DragPreviewLayer; diff --git a/frontend/src/Components/Error/ErrorBoundary.js b/frontend/src/Components/Error/ErrorBoundary.js new file mode 100644 index 000000000..50fcf98b5 --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundary.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import * as sentry from '@sentry/browser'; + +class ErrorBoundary extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + error: null, + info: null + }; + } + + componentDidCatch(error, info) { + this.setState({ + error, + info + }); + + sentry.captureException(error); + } + + // + // Render + + render() { + const { + children, + errorComponent: ErrorComponent, + ...otherProps + } = this.props; + + const { + error, + info + } = this.state; + + if (error) { + return ( + + ); + } + + return children; + } +} + +ErrorBoundary.propTypes = { + children: PropTypes.node.isRequired, + errorComponent: PropTypes.func.isRequired +}; + +export default ErrorBoundary; diff --git a/frontend/src/Components/Error/ErrorBoundaryError.css b/frontend/src/Components/Error/ErrorBoundaryError.css new file mode 100644 index 000000000..b6d1f917e --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundaryError.css @@ -0,0 +1,38 @@ +.container { + text-align: center; +} + +.message { + margin: 50px 0; + text-align: center; + font-weight: 300; + font-size: 36px; +} + +.imageContainer { + display: flex; + justify-content: center; + flex: 0 0 auto; +} + +.image { + height: 350px; +} + +.details { + margin: 20px; + text-align: left; + white-space: pre-wrap; +} + +@media only screen and (max-width: $breakpointMedium) { + .image { + height: 250px; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .image { + height: 150px; + } +} diff --git a/frontend/src/Components/Error/ErrorBoundaryError.js b/frontend/src/Components/Error/ErrorBoundaryError.js new file mode 100644 index 000000000..e0181db96 --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundaryError.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './ErrorBoundaryError.css'; + +function ErrorBoundaryError(props) { + const { + className, + messageClassName, + detailsClassName, + message, + error, + info + } = props; + + return ( +
+
+ {message} +
+ +
+ +
+ +
+ { + error && +
+ {error.toString()} +
+ } + +
+ {info.componentStack} +
+
+
+ ); +} + +ErrorBoundaryError.propTypes = { + className: PropTypes.string.isRequired, + messageClassName: PropTypes.string.isRequired, + detailsClassName: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + error: PropTypes.object.isRequired, + info: PropTypes.object.isRequired +}; + +ErrorBoundaryError.defaultProps = { + className: styles.container, + messageClassName: styles.message, + detailsClassName: styles.details, + message: 'There was an error loading this content' +}; + +export default ErrorBoundaryError; diff --git a/frontend/src/Components/FieldSet.css b/frontend/src/Components/FieldSet.css new file mode 100644 index 000000000..daf3bdf2e --- /dev/null +++ b/frontend/src/Components/FieldSet.css @@ -0,0 +1,19 @@ +.fieldSet { + margin: 0; + margin-bottom: 20px; + padding: 0; + min-width: 0; + border: 0; +} + +.legend { + display: block; + margin-bottom: 21px; + padding: 0; + width: 100%; + border: 0; + border-bottom: 1px solid #e5e5e5; + color: #3a3f51; + font-size: 21px; + line-height: inherit; +} diff --git a/frontend/src/Components/FieldSet.js b/frontend/src/Components/FieldSet.js new file mode 100644 index 000000000..76e68a934 --- /dev/null +++ b/frontend/src/Components/FieldSet.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './FieldSet.css'; + +class FieldSet extends Component { + + // + // Render + + render() { + const { + legend, + children + } = this.props; + + return ( +
+ + {legend} + + {children} +
+ ); + } + +} + +FieldSet.propTypes = { + legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), + children: PropTypes.node +}; + +export default FieldSet; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.css b/frontend/src/Components/FileBrowser/FileBrowserModal.css new file mode 100644 index 000000000..30b936800 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModal.css @@ -0,0 +1,5 @@ +.modal { + composes: modal from 'Components/Modal/Modal.css'; + + height: 600px; +} diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.js b/frontend/src/Components/FileBrowser/FileBrowserModal.js new file mode 100644 index 000000000..6b58dbb8c --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModal.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import FileBrowserModalContentConnector from './FileBrowserModalContentConnector'; +import styles from './FileBrowserModal.css'; + +class FileBrowserModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +FileBrowserModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default FileBrowserModal; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.css b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css new file mode 100644 index 000000000..9ae11f0bd --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css @@ -0,0 +1,33 @@ +.modalBody { + composes: modalBody from 'Components/Modal/ModalBody.css'; + + display: flex; + flex-direction: column; +} + +.mappedDrivesWarning { + composes: alert from 'Components/Alert.css'; + + margin: 0; + margin-bottom: 20px; +} + +.faqLink { + color: $alertWarningColor; + font-weight: bold; +} + +.pathInput { + composes: pathInputWrapper from 'Components/Form/PathInput.css'; + + flex: 0 0 auto; +} + +.scroller { + margin-top: 20px; +} + +.loading { + display: inline-block; + margin-right: auto; +} diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js new file mode 100644 index 000000000..6c178f6c7 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js @@ -0,0 +1,253 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { kinds, scrollDirections } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Scroller from 'Components/Scroller/Scroller'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import PathInput from 'Components/Form/PathInput'; +import FileBrowserRow from './FileBrowserRow'; +import styles from './FileBrowserModalContent.css'; + +const columns = [ + { + name: 'type', + label: 'Type', + isVisible: true + }, + { + name: 'name', + label: 'Name', + isVisible: true + } +]; + +class FileBrowserModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._scrollerNode = null; + + this.state = { + isFileBrowserModalOpen: false, + currentPath: props.value + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + currentPath + } = this.props; + + if ( + currentPath !== this.state.currentPath && + currentPath !== prevState.currentPath + ) { + this.setState({ currentPath }); + this._scrollerNode.scrollTop = 0; + } + } + + // + // Control + + setScrollerRef = (ref) => { + if (ref) { + this._scrollerNode = ReactDOM.findDOMNode(ref); + } else { + this._scrollerNode = null; + } + } + + // + // Listeners + + onPathInputChange = ({ value }) => { + this.setState({ currentPath: value }); + } + + onRowPress = (path) => { + this.props.onFetchPaths(path); + } + + onOkPress = () => { + this.props.onChange({ + name: this.props.name, + value: this.state.currentPath + }); + + this.props.onClearPaths(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + parent, + directories, + files, + isWindowsService, + onModalClose, + ...otherProps + } = this.props; + + const emptyParent = parent === ''; + + return ( + + + File Browser + + + + { + isWindowsService && + + Mapped network drives are not available when running as a Windows Service, see the FAQ for more information. + + } + + + + + { + !!error && +
Error loading contents
+ } + + { + isPopulated && !error && + + + { + emptyParent && + + } + + { + !emptyParent && parent && + + } + + { + directories.map((directory) => { + return ( + + ); + }) + } + + { + files.map((file) => { + return ( + + ); + }) + } + +
+ } +
+
+ + + { + isFetching && + + } + + + + + +
+ ); + } +} + +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..7c3ab83e1 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -0,0 +1,286 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props'; +import SelectInput from 'Components/Form/SelectInput'; +import IconButton from 'Components/Link/IconButton'; +import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; +import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; +import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; +import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; +import LanguageProfileFilterBuilderRowValueConnector from './LanguageProfileFilterBuilderRowValueConnector'; +import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; +import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; +import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector'; +import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue'; +import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector'; +import styles from './FilterBuilderRow.css'; + +function getselectedFilterBuilderProp(filterBuilderProps, name) { + return filterBuilderProps.find((a) => { + return a.name === name; + }); +} + +function getFilterTypeOptions(filterBuilderProps, filterKey) { + const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, filterKey); + + if (!selectedFilterBuilderProp) { + return []; + } + + return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type]; +} + +function getDefaultFilterType(selectedFilterBuilderProp) { + return filterBuilderTypes.possibleFilterTypes[selectedFilterBuilderProp.type][0].key; +} + +function getDefaultFilterValue(selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.type === filterBuilderTypes.DATE) { + return ''; + } + + return []; +} + +function getRowValueConnector(selectedFilterBuilderProp) { + if (!selectedFilterBuilderProp) { + return FilterBuilderRowValueConnector; + } + + const valueType = selectedFilterBuilderProp.valueType; + + switch (valueType) { + case filterBuilderValueTypes.BOOL: + return BoolFilterBuilderRowValue; + + case filterBuilderValueTypes.DATE: + return DateFilterBuilderRowValue; + + case filterBuilderValueTypes.INDEXER: + return IndexerFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.LANGUAGE_PROFILE: + return LanguageProfileFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.PROTOCOL: + return ProtocolFilterBuilderRowValue; + + case filterBuilderValueTypes.QUALITY: + return QualityFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.QUALITY_PROFILE: + return QualityProfileFilterBuilderRowValueConnector; + + case filterBuilderValueTypes.SERIES_STATUS: + return SeriesStatusFilterBuilderRowValue; + + case filterBuilderValueTypes.TAG: + return TagFilterBuilderRowValueConnector; + + default: + return FilterBuilderRowValueConnector; + } +} + +class FilterBuilderRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + filterKey, + filterBuilderProps + } = props; + + if (filterKey) { + const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey); + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + } + } + + componentDidMount() { + const { + index, + filterKey, + filterBuilderProps, + onFilterChange + } = this.props; + + if (filterKey) { + const selectedFilterBuilderProp = filterBuilderProps.find((a) => a.name === filterKey); + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + + return; + } + + const selectedFilterBuilderProp = filterBuilderProps[0]; + + const filter = { + key: selectedFilterBuilderProp.name, + value: getDefaultFilterValue(selectedFilterBuilderProp), + type: getDefaultFilterType(selectedFilterBuilderProp) + }; + + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + onFilterChange(index, filter); + } + + // + // Listeners + + onFilterKeyChange = ({ value: key }) => { + const { + index, + filterBuilderProps, + onFilterChange + } = this.props; + + const selectedFilterBuilderProp = getselectedFilterBuilderProp(filterBuilderProps, key); + const type = getDefaultFilterType(selectedFilterBuilderProp); + + const filter = { + key, + value: getDefaultFilterValue(selectedFilterBuilderProp), + type + }; + + this.selectedFilterBuilderProp = selectedFilterBuilderProp; + onFilterChange(index, filter); + } + + onFilterChange = ({ name, value }) => { + const { + index, + filterKey, + filterValue, + filterType, + onFilterChange + } = this.props; + + const filter = { + key: filterKey, + value: filterValue, + type: filterType + }; + + filter[name] = value; + + onFilterChange(index, filter); + } + + onAddPress = () => { + const { + index, + onAddPress + } = this.props; + + onAddPress(index); + } + + onRemovePress = () => { + const { + index, + onRemovePress + } = this.props; + + onRemovePress(index); + } + + // + // Render + + render() { + const { + filterKey, + filterType, + filterValue, + filterCount, + filterBuilderProps, + sectionItems + } = this.props; + + const selectedFilterBuilderProp = this.selectedFilterBuilderProp; + + const keyOptions = filterBuilderProps.map((availablePropFilter) => { + return { + key: availablePropFilter.name, + value: availablePropFilter.label + }; + }); + + const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); + + return ( +
+
+ { + filterKey && + + } +
+ +
+ { + filterType && + + } +
+ +
+ { + filterValue != null && !!selectedFilterBuilderProp && + + } +
+ +
+ + + +
+
+ ); + } +} + +FilterBuilderRow.propTypes = { + index: PropTypes.number.isRequired, + filterKey: PropTypes.string, + filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array, PropTypes.object]), + filterType: PropTypes.string, + filterCount: PropTypes.number.isRequired, + filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired, + sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired, + onFilterChange: PropTypes.func.isRequired, + onAddPress: PropTypes.func.isRequired, + onRemovePress: PropTypes.func.isRequired +}; + +export default FilterBuilderRow; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js new file mode 100644 index 000000000..70c496620 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js @@ -0,0 +1,159 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import convertToBytes from 'Utilities/Number/convertToBytes'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { kinds, filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props'; +import TagInput, { tagShape } from 'Components/Form/TagInput'; +import FilterBuilderRowValueTag from './FilterBuilderRowValueTag'; + +export const NAME = 'value'; + +function getTagDisplayValue(value, selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) { + return formatBytes(value); + } + + return value; +} + +function getValue(input, selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) { + const match = input.match(/^(\d+)([kmgt](i?b)?)$/i); + + if (match && match.length > 1) { + const [, value, unit] = input.match(/^(\d+)([kmgt](i?b)?)$/i); + + switch (unit.toLowerCase()) { + case 'k': + return convertToBytes(value, 1, true); + case 'm': + return convertToBytes(value, 2, true); + case 'g': + return convertToBytes(value, 3, true); + case 't': + return convertToBytes(value, 4, true); + case 'kb': + return convertToBytes(value, 1, true); + case 'mb': + return convertToBytes(value, 2, true); + case 'gb': + return convertToBytes(value, 3, true); + case 'tb': + return convertToBytes(value, 4, true); + case 'kib': + return convertToBytes(value, 1, true); + case 'mib': + return convertToBytes(value, 2, true); + case 'gib': + return convertToBytes(value, 3, true); + case 'tib': + return convertToBytes(value, 4, true); + default: + return parseInt(value); + } + } + } + + if (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER) { + return parseInt(input); + } + + return input; +} + +class FilterBuilderRowValue extends Component { + + // + // Listeners + + onTagAdd = (tag) => { + const { + filterValue, + selectedFilterBuilderProp, + onChange + } = this.props; + + let value = tag.id; + + if (value == null) { + value = getValue(tag.name, selectedFilterBuilderProp); + } + + onChange({ + name: NAME, + value: [...filterValue, value] + }); + } + + onTagDelete = ({ index }) => { + const { + filterValue, + onChange + } = this.props; + + const value = filterValue.filter((v, i) => i !== index); + + onChange({ + name: NAME, + value + }); + } + + // + // Render + + render() { + const { + filterValue, + selectedFilterBuilderProp, + tagList + } = this.props; + + const hasItems = !!tagList.length; + + const tags = filterValue.map((id) => { + if (hasItems) { + const tag = tagList.find((t) => t.id === id); + + return { + id, + name: tag && tag.name + }; + } + + return { + id, + name: getTagDisplayValue(id, selectedFilterBuilderProp) + }; + }); + + return ( + + ); + } +} + +FilterBuilderRowValue.propTypes = { + filterValue: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.bool, PropTypes.string, PropTypes.number])).isRequired, + selectedFilterBuilderProp: PropTypes.object.isRequired, + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + onChange: PropTypes.func.isRequired +}; + +FilterBuilderRowValue.defaultProps = { + filterValue: [] +}; + +export default FilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js new file mode 100644 index 000000000..ac74240e4 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js @@ -0,0 +1,55 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import sortByName from 'Utilities/Array/sortByName'; +import { filterBuilderTypes } from 'Helpers/Props'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createTagListSelector() { + return createSelector( + (state, { sectionItems }) => sectionItems, + (state, { selectedFilterBuilderProp }) => selectedFilterBuilderProp, + (sectionItems, selectedFilterBuilderProp) => { + if ( + selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER || + selectedFilterBuilderProp.type === filterBuilderTypes.STRING + ) { + return []; + } + + let items = []; + + if (selectedFilterBuilderProp.optionsSelector) { + items = selectedFilterBuilderProp.optionsSelector(sectionItems); + } else { + items = sectionItems.reduce((acc, item) => { + const name = item[selectedFilterBuilderProp.name]; + + if (name) { + acc.push({ + id: name, + name + }); + } + + return acc; + }, []).sort(sortByName); + } + + return _.uniqBy(items, 'id'); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createTagListSelector(), + (tagList) => { + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css new file mode 100644 index 000000000..1c4c5acf1 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css @@ -0,0 +1,19 @@ +.tag { + &.isLastTag { + .or { + display: none; + } + } +} + +.label { + composes: label from 'Components/Label.css'; + + border-style: none; + font-size: 13px; +} + +.or { + margin: 0 3px; + color: $themeDarkColor; +} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js new file mode 100644 index 000000000..573e05759 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import TagInputTag from 'Components/Form/TagInputTag'; +import styles from './FilterBuilderRowValueTag.css'; + +function FilterBuilderRowValueTag(props) { + return ( + + + + { + !props.isLastTag && + + or + + } + + ); +} + +FilterBuilderRowValueTag.propTypes = { + isLastTag: PropTypes.bool.isRequired +}; + +export default FilterBuilderRowValueTag; diff --git a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..968b26d2c --- /dev/null +++ b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchIndexers } from 'Store/Actions/settingsActions'; +import { tagShape } from 'Components/Form/TagInput'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (qualityProfiles) => { + const { + isFetching, + isPopulated, + error, + items + } = qualityProfiles; + + const tagList = items.map((item) => { + return { + id: item.id, + name: item.name + }; + }); + + return { + isFetching, + isPopulated, + error, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchIndexers: fetchIndexers +}; + +class IndexerFilterBuilderRowValueConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.dispatchFetchIndexers(); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +IndexerFilterBuilderRowValueConnector.propTypes = { + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + dispatchFetchIndexers: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexerFilterBuilderRowValueConnector); diff --git a/frontend/src/Components/Filter/Builder/LanguageProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/LanguageProfileFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..31b1e952a --- /dev/null +++ b/frontend/src/Components/Filter/Builder/LanguageProfileFilterBuilderRowValueConnector.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.languageProfiles, + (languageProfiles) => { + const tagList = languageProfiles.items.map((languageProfile) => { + const { + id, + name + } = languageProfile; + + return { + id, + name + }; + }); + + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js new file mode 100644 index 000000000..ae63ae0eb --- /dev/null +++ b/frontend/src/Components/Filter/Builder/ProtocolFilterBuilderRowValue.js @@ -0,0 +1,18 @@ +import React from 'react'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const protocols = [ + { id: 'torrent', name: 'Torrent' }, + { id: 'usenet', name: 'Usenet' } +]; + +function ProtocolFilterBuilderRowValue(props) { + return ( + + ); +} + +export default ProtocolFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..0290bcdcb --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityFilterBuilderRowValueConnector.js @@ -0,0 +1,75 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getQualities from 'Utilities/Quality/getQualities'; +import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; +import { tagShape } from 'Components/Form/TagInput'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + schema + } = qualityProfiles; + + const tagList = getQualities(schema.items); + + return { + isFetching, + isPopulated, + error, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchQualityProfileSchema: fetchQualityProfileSchema +}; + +class QualityFilterBuilderRowValueConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.dispatchFetchQualityProfileSchema(); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +QualityFilterBuilderRowValueConnector.propTypes = { + tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + dispatchFetchQualityProfileSchema: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QualityFilterBuilderRowValueConnector); diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..4a8b82283 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityProfiles, + (qualityProfiles) => { + const tagList = qualityProfiles.items.map((qualityProfile) => { + const { + id, + name + } = qualityProfile; + + return { + id, + name + }; + }); + + return { + tagList + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js new file mode 100644 index 000000000..50841a013 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js @@ -0,0 +1,18 @@ +import React from 'react'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const protocols = [ + { id: 'continuing', name: 'Continuing' }, + { id: 'ended', name: 'Ended' } +]; + +function SeriesStatusFilterBuilderRowValue(props) { + return ( + + ); +} + +export default SeriesStatusFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js new file mode 100644 index 000000000..60e04c446 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/TagFilterBuilderRowValueConnector.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMapStateToProps() { + return createSelector( + createTagsSelector(), + (tagList) => { + return { + tagList: tagList.map((tag) => { + const { + id, + label: name + } = tag; + + return { + id, + name + }; + }) + }; + } + ); +} + +export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.css b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css new file mode 100644 index 000000000..7acb69dc7 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css @@ -0,0 +1,17 @@ +.customFilter { + display: flex; + margin-bottom: 5px; + padding: 5px; + + &:hover { + background-color: $tableRowHoverBackgroundColor; + } +} + +.label { + flex: 0 1 300px; +} + +.actions { + flex: 0 0 60px; +} diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js new file mode 100644 index 000000000..c9c326d78 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import styles from './CustomFilter.css'; + +class CustomFilter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDeleting: false + }; + } + + componentDidUpdate(prevProps) { + const { + isDeleting, + deleteError + } = this.props; + + if (prevProps.isDeleting && !isDeleting && this.state.isDeleting && deleteError) { + this.setState({ isDeleting: false }); + } + } + + componentWillUnmount() { + const { + id, + selectedFilterKey, + dispatchSetFilter + } = this.props; + + // Assume that delete and then unmounting means the delete was successful. + // Moving this check to a ancestor would be more accurate, but would have + // more boilerplate. + if (this.state.isDeleting && id === selectedFilterKey) { + dispatchSetFilter({ selectedFilterKey: 'all' }); + } + } + + // + // Listeners + + onEditPress = () => { + const { + id, + onEditPress + } = this.props; + + onEditPress(id); + } + + onRemovePress = () => { + const { + id, + dispatchDeleteCustomFilter + } = this.props; + + this.setState({ isDeleting: true }, () => { + dispatchDeleteCustomFilter({ id }); + }); + + } + + // + // Render + + render() { + const { + label + } = this.props; + + return ( +
+
+ {label} +
+ +
+ + + +
+
+ ); + } +} + +CustomFilter.propTypes = { + id: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + dispatchSetFilter: PropTypes.func.isRequired, + onEditPress: PropTypes.func.isRequired, + dispatchDeleteCustomFilter: PropTypes.func.isRequired +}; + +export default CustomFilter; diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css new file mode 100644 index 000000000..c391764dc --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.css @@ -0,0 +1,3 @@ +.addButtonContainer { + margin-top: 15px; +} diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js new file mode 100644 index 000000000..1a7168fca --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import CustomFilter from './CustomFilter'; +import styles from './CustomFiltersModalContent.css'; + +function CustomFiltersModalContent(props) { + const { + selectedFilterKey, + customFilters, + isDeleting, + deleteError, + dispatchDeleteCustomFilter, + dispatchSetFilter, + onAddCustomFilter, + onEditCustomFilter, + onModalClose + } = props; + + return ( + + + Custom Filters + + + + { + customFilters.map((customFilter, index) => { + return ( + + ); + }) + } + +
+ +
+
+ + + + +
+ ); +} + +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.js b/frontend/src/Components/Form/Form.js new file mode 100644 index 000000000..9a605297a --- /dev/null +++ b/frontend/src/Components/Form/Form.js @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; + +function Form({ children, validationErrors, validationWarnings, ...otherProps }) { + return ( +
+
+ { + validationErrors.map((error, index) => { + return ( + + {error.errorMessage} + + ); + }) + } + + { + validationWarnings.map((warning, index) => { + return ( + + {warning.errorMessage} + + ); + }) + } +
+ + {children} +
+ ); +} + +Form.propTypes = { + children: PropTypes.node.isRequired, + validationErrors: PropTypes.arrayOf(PropTypes.object).isRequired, + validationWarnings: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +Form.defaultProps = { + validationErrors: [], + validationWarnings: [] +}; + +export default Form; diff --git a/frontend/src/Components/Form/FormGroup.css b/frontend/src/Components/Form/FormGroup.css new file mode 100644 index 000000000..ddce8863b --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.css @@ -0,0 +1,28 @@ +.group { + display: flex; + margin-bottom: 20px; +} + +/* Sizes */ + +.extraSmall { + max-width: $formGroupExtraSmallWidth; +} + +.small { + max-width: $formGroupSmallWidth; +} + +.medium { + max-width: $formGroupMediumWidth; +} + +.large { + max-width: $formGroupLargeWidth; +} + +@media only screen and (max-width: $breakpointLarge) { + .group { + display: block; + } +} diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js new file mode 100644 index 000000000..d2e04c350 --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.js @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { map } from 'Helpers/elementChildren'; +import { sizes } from 'Helpers/Props'; +import styles from './FormGroup.css'; + +function FormGroup(props) { + const { + className, + children, + size, + advancedSettings, + isAdvanced, + ...otherProps + } = props; + + if (!advancedSettings && isAdvanced) { + return null; + } + + const childProps = isAdvanced ? { isAdvanced } : {}; + + return ( +
+ { + map(children, (child) => { + return React.cloneElement(child, childProps); + }) + } +
+ ); +} + +FormGroup.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + size: PropTypes.oneOf(sizes.all).isRequired, + advancedSettings: PropTypes.bool.isRequired, + isAdvanced: PropTypes.bool.isRequired +}; + +FormGroup.defaultProps = { + className: styles.group, + size: sizes.SMALL, + advancedSettings: false, + isAdvanced: false +}; + +export default FormGroup; diff --git a/frontend/src/Components/Form/FormInputButton.css b/frontend/src/Components/Form/FormInputButton.css new file mode 100644 index 000000000..27a1923be --- /dev/null +++ b/frontend/src/Components/Form/FormInputButton.css @@ -0,0 +1,12 @@ +.button { + composes: button from 'Components/Link/Button.css'; + + border-left: none; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.middleButton { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js new file mode 100644 index 000000000..4b6491663 --- /dev/null +++ b/frontend/src/Components/Form/FormInputButton.js @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import { kinds } from 'Helpers/Props'; +import styles from './FormInputButton.css'; + +function FormInputButton(props) { + const { + className, + canSpin, + isLastButton, + ...otherProps + } = props; + + if (canSpin) { + return ( + + ); + } + + return ( + + ); +} + +SpinnerButton.propTypes = { + className: PropTypes.string.isRequired, + isSpinning: PropTypes.bool.isRequired, + isDisabled: PropTypes.bool, + spinnerIcon: PropTypes.object.isRequired, + children: PropTypes.node +}; + +SpinnerButton.defaultProps = { + className: styles.button, + spinnerIcon: icons.SPINNER +}; + +export default SpinnerButton; diff --git a/frontend/src/Components/Link/SpinnerErrorButton.css b/frontend/src/Components/Link/SpinnerErrorButton.css new file mode 100644 index 000000000..5f4e68545 --- /dev/null +++ b/frontend/src/Components/Link/SpinnerErrorButton.css @@ -0,0 +1,23 @@ +.iconContainer { + composes: spinnerContainer from 'Components/Link/SpinnerButton.css'; +} + +.icon { + z-index: 1; +} + +.label { + composes: label from 'Components/Link/SpinnerButton.css'; +} + +.showIcon { + .iconContainer { + left: 50%; + visibility: visible; + } + + .label { + left: 100%; + opacity: 0; + } +} diff --git a/frontend/src/Components/Link/SpinnerErrorButton.js b/frontend/src/Components/Link/SpinnerErrorButton.js new file mode 100644 index 000000000..0575db094 --- /dev/null +++ b/frontend/src/Components/Link/SpinnerErrorButton.js @@ -0,0 +1,162 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import styles from './SpinnerErrorButton.css'; + +function getTestResult(error) { + if (!error) { + return { + wasSuccessful: true, + hasWarning: false, + hasError: false + }; + } + + if (error.status !== 400) { + return { + wasSuccessful: false, + hasWarning: false, + hasError: true + }; + } + + const failures = error.responseJSON; + + const hasWarning = _.some(failures, { isWarning: true }); + const hasError = _.some(failures, (failure) => !failure.isWarning); + + return { + wasSuccessful: false, + hasWarning, + hasError + }; +} + +class SpinnerErrorButton extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._testResultTimeout = null; + + this.state = { + wasSuccessful: false, + hasWarning: false, + hasError: false + }; + } + + componentDidUpdate(prevProps) { + const { + isSpinning, + error + } = this.props; + + if (prevProps.isSpinning && !isSpinning) { + const testResult = getTestResult(error); + + this.setState(testResult, () => { + const { + wasSuccessful, + hasWarning, + hasError + } = testResult; + + if (wasSuccessful || hasWarning || hasError) { + this._testResultTimeout = setTimeout(this.resetState, 3000); + } + }); + } + } + + componentWillUnmount() { + if (this._testResultTimeout) { + clearTimeout(this._testResultTimeout); + } + } + + // + // Control + + resetState = () => { + this.setState({ + wasSuccessful: false, + hasWarning: false, + hasError: false + }); + } + + // + // Render + + render() { + const { + isSpinning, + error, + children, + ...otherProps + } = this.props; + + const { + wasSuccessful, + hasWarning, + hasError + } = this.state; + + const showIcon = wasSuccessful || hasWarning || hasError; + + let iconName = icons.CHECK; + let iconKind = kinds.SUCCESS; + + if (hasWarning) { + iconName = icons.WARNING; + iconKind = kinds.WARNING; + } + + if (hasError) { + iconName = icons.DANGER; + iconKind = kinds.DANGER; + } + + return ( + + + { + showIcon && + + + + } + + { + + { + children + } + + } + + + ); + } +} + +SpinnerErrorButton.propTypes = { + isSpinning: PropTypes.bool.isRequired, + error: PropTypes.object, + children: PropTypes.node.isRequired +}; + +export default SpinnerErrorButton; diff --git a/frontend/src/Components/Link/SpinnerIconButton.js b/frontend/src/Components/Link/SpinnerIconButton.js new file mode 100644 index 000000000..a804fafc5 --- /dev/null +++ b/frontend/src/Components/Link/SpinnerIconButton.js @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from './IconButton'; + +function SpinnerIconButton(props) { + const { + name, + spinningName, + isDisabled, + isSpinning, + ...otherProps + } = props; + + return ( + + ); +} + +SpinnerIconButton.propTypes = { + name: PropTypes.object.isRequired, + spinningName: PropTypes.object.isRequired, + isDisabled: PropTypes.bool.isRequired, + isSpinning: PropTypes.bool.isRequired +}; + +SpinnerIconButton.defaultProps = { + spinningName: icons.SPINNER, + isDisabled: false, + isSpinning: false +}; + +export default SpinnerIconButton; diff --git a/frontend/src/Components/Loading/LoadingIndicator.css b/frontend/src/Components/Loading/LoadingIndicator.css new file mode 100644 index 000000000..fd224b1d6 --- /dev/null +++ b/frontend/src/Components/Loading/LoadingIndicator.css @@ -0,0 +1,49 @@ +.loading { + margin-top: 20px; + text-align: center; +} + +.rippleContainer { + position: relative; + display: inline-block; +} + +.ripple:nth-child(0) { + animation-delay: -0.8s; +} + +.ripple:nth-child(1) { + animation-delay: -0.6s; +} + +.ripple:nth-child(2) { + animation-delay: -0.4s; +} + +.ripple:nth-child(3) { + animation-delay: -0.2s; +} + +.ripple { + position: absolute; + border: 2px solid #3a3f51; + border-radius: 100%; + animation: rippleContainer 1.25s 0s infinite cubic-bezier(0.21, 0.53, 0.56, 0.8); + animation-fill-mode: both; +} + +@keyframes rippleContainer { + 0% { + opacity: 1; + transform: scale(0.1); + } + + 70% { + opacity: 0.7; + transform: scale(1); + } + + 100% { + opacity: 0; + } +} diff --git a/frontend/src/Components/Loading/LoadingIndicator.js b/frontend/src/Components/Loading/LoadingIndicator.js new file mode 100644 index 000000000..5f9a15b1a --- /dev/null +++ b/frontend/src/Components/Loading/LoadingIndicator.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './LoadingIndicator.css'; + +function LoadingIndicator({ className, size }) { + const sizeInPx = `${size}px`; + const width = sizeInPx; + const height = sizeInPx; + + return ( +
+
+
+ +
+ +
+
+
+ ); +} + +LoadingIndicator.propTypes = { + className: PropTypes.string, + size: PropTypes.number +}; + +LoadingIndicator.defaultProps = { + className: styles.loading, + size: 50 +}; + +export default LoadingIndicator; diff --git a/frontend/src/Components/Loading/LoadingMessage.css b/frontend/src/Components/Loading/LoadingMessage.css new file mode 100644 index 000000000..a7b39e76f --- /dev/null +++ b/frontend/src/Components/Loading/LoadingMessage.css @@ -0,0 +1,6 @@ +.loadingMessage { + margin: 50px 10px 0; + text-align: center; + font-weight: 300; + font-size: 36px; +} diff --git a/frontend/src/Components/Loading/LoadingMessage.js b/frontend/src/Components/Loading/LoadingMessage.js new file mode 100644 index 000000000..f080731c3 --- /dev/null +++ b/frontend/src/Components/Loading/LoadingMessage.js @@ -0,0 +1,36 @@ +import React from 'react'; +import styles from './LoadingMessage.css'; + +const messages = [ + 'Downloading more RAM', + 'Now in Technicolor', + 'Previously on Sonarr...', + 'Bleep Bloop.', + 'Locating the required gigapixels to render...', + 'Spinning up the hamster wheel...', + 'At least you\'re not on hold', + 'Hum something loud while others stare', + 'Loading humorous message... Please Wait', + 'I could\'ve been faster in Python', + 'Don\'t forget to rewind your episodes', + 'Congratulations! you are the 1000th visitor.', + 'HELP! I\'m being held hostage and forced to write these stupid lines!', + 'RE-calibrating the internet...', + 'I\'ll be here all week', + 'Don\'t forget to tip your waitress', + 'Apply directly to the forehead', + 'Loading Battlestation' +]; + +function LoadingMessage() { + const index = Math.floor(Math.random() * messages.length); + const message = messages[index]; + + return ( +
+ {message} +
+ ); +} + +export default LoadingMessage; diff --git a/frontend/src/Components/Measure.js b/frontend/src/Components/Measure.js new file mode 100644 index 000000000..a2f113de7 --- /dev/null +++ b/frontend/src/Components/Measure.js @@ -0,0 +1,38 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactMeasure from 'react-measure'; + +class Measure extends Component { + + // + // Lifecycle + + componentWillUnmount() { + this.onMeasure.cancel(); + } + + // + // Listeners + + onMeasure = _.debounce((payload) => { + this.props.onMeasure(payload); + }, 250, { leading: true, trailing: false }) + + // + // Render + + render() { + return ( + + ); + } +} + +Measure.propTypes = { + onMeasure: PropTypes.func.isRequired +}; + +export default Measure; diff --git a/frontend/src/Components/Menu/FilterMenu.css b/frontend/src/Components/Menu/FilterMenu.css new file mode 100644 index 000000000..34991aed9 --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenu.css @@ -0,0 +1,9 @@ +.filterMenu { + composes: menu from './Menu.css'; +} + +@media only screen and (max-width: $breakpointSmall) { + .filterMenu { + margin-right: 10px; + } +} diff --git a/frontend/src/Components/Menu/FilterMenu.js b/frontend/src/Components/Menu/FilterMenu.js new file mode 100644 index 000000000..d37876c22 --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenu.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FilterMenuContent from './FilterMenuContent'; +import Menu from './Menu'; +import ToolbarMenuButton from './ToolbarMenuButton'; +import styles from './FilterMenu.css'; + +class FilterMenu extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isFilterModalOpen: false + }; + } + + // + // Listeners + + onCustomFiltersPress = () => { + this.setState({ isFilterModalOpen: true }); + } + + onFiltersModalClose = () => { + this.setState({ isFilterModalOpen: false }); + } + + // + // Render + + render(props) { + const { + className, + isDisabled, + selectedFilterKey, + filters, + customFilters, + buttonComponent: ButtonComponent, + filterModalConnectorComponent: FilterModalConnectorComponent, + filterModalConnectorComponentProps, + onFilterSelect, + ...otherProps + } = this.props; + + const showCustomFilters = !!FilterModalConnectorComponent; + + return ( +
+ + + + + + + + { + showCustomFilters && + + } +
+ ); + } +} + +FilterMenu.propTypes = { + className: PropTypes.string, + isDisabled: PropTypes.bool.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + buttonComponent: PropTypes.func.isRequired, + filterModalConnectorComponent: PropTypes.func, + filterModalConnectorComponentProps: PropTypes.object, + onFilterSelect: PropTypes.func.isRequired +}; + +FilterMenu.defaultProps = { + className: styles.filterMenu, + isDisabled: false, + buttonComponent: ToolbarMenuButton +}; + +export default FilterMenu; diff --git a/frontend/src/Components/Menu/FilterMenuContent.js b/frontend/src/Components/Menu/FilterMenuContent.js new file mode 100644 index 000000000..7463e2c9e --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenuContent.js @@ -0,0 +1,85 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuContent from './MenuContent'; +import FilterMenuItem from './FilterMenuItem'; +import MenuItem from './MenuItem'; +import MenuItemSeparator from './MenuItemSeparator'; + +class FilterMenuContent extends Component { + + // + // Render + + render() { + const { + selectedFilterKey, + filters, + customFilters, + showCustomFilters, + onFilterSelect, + onCustomFiltersPress, + ...otherProps + } = this.props; + + return ( + + { + filters.map((filter) => { + return ( + + {filter.label} + + ); + }) + } + + { + customFilters.map((filter) => { + return ( + + {filter.label} + + ); + }) + } + + { + showCustomFilters && + + } + + { + showCustomFilters && + + Custom Filters + + } + + ); + } +} + +FilterMenuContent.propTypes = { + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + showCustomFilters: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onCustomFiltersPress: PropTypes.func.isRequired +}; + +FilterMenuContent.defaultProps = { + showCustomFilters: false +}; + +export default FilterMenuContent; diff --git a/frontend/src/Components/Menu/FilterMenuItem.js b/frontend/src/Components/Menu/FilterMenuItem.js new file mode 100644 index 000000000..d2c495187 --- /dev/null +++ b/frontend/src/Components/Menu/FilterMenuItem.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import SelectedMenuItem from './SelectedMenuItem'; + +class FilterMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + filterKey, + onPress + } = this.props; + + onPress(filterKey); + } + + // + // Render + + render() { + const { + filterKey, + selectedFilterKey, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +FilterMenuItem.propTypes = { + filterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + onPress: PropTypes.func.isRequired +}; + +export default FilterMenuItem; diff --git a/frontend/src/Components/Menu/Menu.css b/frontend/src/Components/Menu/Menu.css new file mode 100644 index 000000000..9cce48fee --- /dev/null +++ b/frontend/src/Components/Menu/Menu.css @@ -0,0 +1,7 @@ +.tether { + z-index: 2000; +} + +.menu { + position: relative; +} diff --git a/frontend/src/Components/Menu/Menu.js b/frontend/src/Components/Menu/Menu.js new file mode 100644 index 000000000..da778bb7a --- /dev/null +++ b/frontend/src/Components/Menu/Menu.js @@ -0,0 +1,207 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import TetherComponent from 'react-tether'; +import { align } from 'Helpers/Props'; +import styles from './Menu.css'; + +const baseTetherOptions = { + skipMoveElement: true, + constraints: [ + { + to: 'window', + attachment: 'together', + pin: true + } + ] +}; + +const tetherOptions = { + [align.RIGHT]: { + ...baseTetherOptions, + attachment: 'top right', + targetAttachment: 'bottom right' + }, + + [align.LEFT]: { + ...baseTetherOptions, + attachment: 'top left', + targetAttachment: 'bottom left' + } +}; + +class Menu extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMenuOpen: false, + maxHeight: 0 + }; + } + + componentDidMount() { + this.setMaxHeight(); + } + + componentWillUnmount() { + this._removeListener(); + } + + // + // Control + + getMaxHeight() { + if (!this.props.enforceMaxHeight) { + return; + } + + const menu = ReactDOM.findDOMNode(this.refs.menu); + + if (!menu) { + return; + } + + const { bottom } = menu.getBoundingClientRect(); + const maxHeight = window.innerHeight - bottom; + + return maxHeight; + } + + setMaxHeight() { + this.setState({ + maxHeight: this.getMaxHeight() + }); + } + + _addListener() { + // Listen to resize events on the window and scroll events + // on all elements to ensure the menu is the best size possible. + // Listen for click events on the window to support closing the + // menu on clicks outside. + + window.addEventListener('resize', this.onWindowResize); + window.addEventListener('scroll', this.onWindowScroll, { capture: true }); + window.addEventListener('click', this.onWindowClick); + } + + _removeListener() { + window.removeEventListener('resize', this.onWindowResize); + window.removeEventListener('scroll', this.onWindowScroll, { capture: true }); + window.removeEventListener('click', this.onWindowClick); + } + + // + // Listeners + + onWindowClick = (event) => { + const menu = ReactDOM.findDOMNode(this.refs.menu); + const menuContent = ReactDOM.findDOMNode(this.refs.menuContent); + + if (!menu) { + return; + } + + if ((!menu.contains(event.target) || menuContent.contains(event.target)) && this.state.isMenuOpen) { + this.setState({ isMenuOpen: false }); + this._removeListener(); + } + } + + onWindowResize = () => { + this.setMaxHeight(); + } + + onWindowScroll = () => { + this.setMaxHeight(); + } + + onMenuButtonPress = () => { + const state = { + isMenuOpen: !this.state.isMenuOpen + }; + + if (this.state.isMenuOpen) { + this._removeListener(); + } else { + state.maxHeight = this.getMaxHeight(); + this._addListener(); + } + + this.setState(state); + } + + // + // Render + + render() { + const { + className, + children, + alignMenu + } = this.props; + + const { + maxHeight, + isMenuOpen + } = this.state; + + const childrenArray = React.Children.toArray(children); + const button = React.cloneElement( + childrenArray[0], + { + onPress: this.onMenuButtonPress + } + ); + + const content = React.cloneElement( + childrenArray[1], + { + ref: 'menuContent', + alignMenu, + maxHeight, + isOpen: isMenuOpen + } + ); + + return ( + +
+ {button} +
+ + { + isMenuOpen && + content + } +
+ ); + } +} + +Menu.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT]), + enforceMaxHeight: PropTypes.bool.isRequired +}; + +Menu.defaultProps = { + className: styles.menu, + alignMenu: align.LEFT, + enforceMaxHeight: true +}; + +export default Menu; diff --git a/frontend/src/Components/Menu/MenuButton.css b/frontend/src/Components/Menu/MenuButton.css new file mode 100644 index 000000000..ff4f0dadb --- /dev/null +++ b/frontend/src/Components/Menu/MenuButton.css @@ -0,0 +1,30 @@ +.menuButton { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + + &::after { + margin-left: 5px; + content: '\25BE'; + } + + &:hover { + color: $toobarButtonHoverColor; + } +} + +.isDisabled { + color: $disabledColor; + + pointer-events: none; +} + +@media only screen and (max-width: $breakpointSmall) { + .menuButton { + &::after { + margin-left: 0; + content: '\25BE'; + } + } +} diff --git a/frontend/src/Components/Menu/MenuButton.js b/frontend/src/Components/Menu/MenuButton.js new file mode 100644 index 000000000..477334a1d --- /dev/null +++ b/frontend/src/Components/Menu/MenuButton.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import Link from 'Components/Link/Link'; +import styles from './MenuButton.css'; + +class MenuButton extends Component { + + // + // Render + + render() { + const { + className, + children, + isDisabled, + onPress, + ...otherProps + } = this.props; + + return ( + + {children} + + ); + } +} + +MenuButton.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + isDisabled: PropTypes.bool.isRequired, + onPress: PropTypes.func +}; + +MenuButton.defaultProps = { + className: styles.menuButton, + isDisabled: false +}; + +export default MenuButton; diff --git a/frontend/src/Components/Menu/MenuContent.css b/frontend/src/Components/Menu/MenuContent.css new file mode 100644 index 000000000..0acc07390 --- /dev/null +++ b/frontend/src/Components/Menu/MenuContent.css @@ -0,0 +1,11 @@ +.menuContent { + display: flex; + flex-direction: column; + background-color: $toolbarMenuItemBackgroundColor; + line-height: 20px; +} + +.scroller { + display: flex; + flex-direction: column; +} diff --git a/frontend/src/Components/Menu/MenuContent.js b/frontend/src/Components/Menu/MenuContent.js new file mode 100644 index 000000000..1acacf80f --- /dev/null +++ b/frontend/src/Components/Menu/MenuContent.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Scroller from 'Components/Scroller/Scroller'; +import styles from './MenuContent.css'; + +class MenuContent extends Component { + + // + // Render + + render() { + const { + className, + children, + maxHeight + } = this.props; + + return ( +
+ + {children} + +
+ ); + } +} + +MenuContent.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + maxHeight: PropTypes.number +}; + +MenuContent.defaultProps = { + className: styles.menuContent +}; + +export default MenuContent; diff --git a/frontend/src/Components/Menu/MenuItem.css b/frontend/src/Components/Menu/MenuItem.css new file mode 100644 index 000000000..bae1a649c --- /dev/null +++ b/frontend/src/Components/Menu/MenuItem.css @@ -0,0 +1,19 @@ +.menuItem { + @add-mixin truncate; + + display: block; + flex-shrink: 0; + padding: 10px 20px; + min-width: 150px; + max-width: 250px; + background-color: $toolbarMenuItemBackgroundColor; + color: $menuItemColor; + line-height: 20px; + + &:hover, + &:focus { + background-color: $toolbarMenuItemHoverBackgroundColor; + color: $menuItemHoverColor; + text-decoration: none; + } +} diff --git a/frontend/src/Components/Menu/MenuItem.js b/frontend/src/Components/Menu/MenuItem.js new file mode 100644 index 000000000..ff083450b --- /dev/null +++ b/frontend/src/Components/Menu/MenuItem.js @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './MenuItem.css'; + +class MenuItem extends Component { + + // + // Render + + render() { + const { + className, + children, + ...otherProps + } = this.props; + + return ( + + {children} + + ); + } +} + +MenuItem.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired +}; + +MenuItem.defaultProps = { + className: styles.menuItem +}; + +export default MenuItem; diff --git a/frontend/src/Components/Menu/MenuItemSeparator.css b/frontend/src/Components/Menu/MenuItemSeparator.css new file mode 100644 index 000000000..a867e3153 --- /dev/null +++ b/frontend/src/Components/Menu/MenuItemSeparator.css @@ -0,0 +1,5 @@ +.separator { + overflow: hidden; + height: 1px; + background-color: $themeDarkColor; +} diff --git a/frontend/src/Components/Menu/MenuItemSeparator.js b/frontend/src/Components/Menu/MenuItemSeparator.js new file mode 100644 index 000000000..e586670c9 --- /dev/null +++ b/frontend/src/Components/Menu/MenuItemSeparator.js @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './MenuItemSeparator.css'; + +function MenuItemSeparator() { + return ( +
+ ); +} + +export default MenuItemSeparator; diff --git a/frontend/src/Components/Menu/PageMenuButton.css b/frontend/src/Components/Menu/PageMenuButton.css new file mode 100644 index 000000000..e6954f600 --- /dev/null +++ b/frontend/src/Components/Menu/PageMenuButton.css @@ -0,0 +1,11 @@ +.menuButton { + composes: menuButton from './MenuButton.css'; + + &:hover { + color: #666; + } +} + +.label { + margin-left: 5px; +} diff --git a/frontend/src/Components/Menu/PageMenuButton.js b/frontend/src/Components/Menu/PageMenuButton.js new file mode 100644 index 000000000..abbfc98f8 --- /dev/null +++ b/frontend/src/Components/Menu/PageMenuButton.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import MenuButton from 'Components/Menu/MenuButton'; +import styles from './PageMenuButton.css'; + +function PageMenuButton(props) { + const { + iconName, + text, + ...otherProps + } = props; + + return ( + + + +
+ {text} +
+
+ ); +} + +PageMenuButton.propTypes = { + iconName: PropTypes.object.isRequired, + text: PropTypes.string +}; + +export default PageMenuButton; diff --git a/frontend/src/Components/Menu/SelectedMenuItem.css b/frontend/src/Components/Menu/SelectedMenuItem.css new file mode 100644 index 000000000..739419d69 --- /dev/null +++ b/frontend/src/Components/Menu/SelectedMenuItem.css @@ -0,0 +1,15 @@ +.item { + display: flex; + justify-content: space-between; + white-space: nowrap; +} + +.isSelected { + visibility: visible; + margin-left: 20px; +} + +.isNotSelected { + visibility: hidden; + margin-left: 20px; +} diff --git a/frontend/src/Components/Menu/SelectedMenuItem.js b/frontend/src/Components/Menu/SelectedMenuItem.js new file mode 100644 index 000000000..8b0805c57 --- /dev/null +++ b/frontend/src/Components/Menu/SelectedMenuItem.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import MenuItem from './MenuItem'; +import styles from './SelectedMenuItem.css'; + +class SelectedMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + onPress + } = this.props; + + onPress(name); + } + + // + // Render + + render() { + const { + children, + selectedIconName, + isSelected, + ...otherProps + } = this.props; + + return ( + +
+ {children} + + +
+
+ ); + } +} + +SelectedMenuItem.propTypes = { + name: PropTypes.string, + children: PropTypes.node.isRequired, + selectedIconName: PropTypes.object.isRequired, + isSelected: PropTypes.bool.isRequired, + onPress: PropTypes.func.isRequired +}; + +SelectedMenuItem.defaultProps = { + selectedIconName: icons.CHECK +}; + +export default SelectedMenuItem; diff --git a/frontend/src/Components/Menu/SortMenu.js b/frontend/src/Components/Menu/SortMenu.js new file mode 100644 index 000000000..a9a6a184e --- /dev/null +++ b/frontend/src/Components/Menu/SortMenu.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Menu from 'Components/Menu/Menu'; +import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; + +function SortMenu(props) { + const { + className, + children, + isDisabled, + ...otherProps + } = props; + + return ( + + + {children} + + ); +} + +SortMenu.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + isDisabled: PropTypes.bool.isRequired +}; + +SortMenu.defaultProps = { + isDisabled: false +}; + +export default SortMenu; diff --git a/frontend/src/Components/Menu/SortMenuItem.js b/frontend/src/Components/Menu/SortMenuItem.js new file mode 100644 index 000000000..e35864ae6 --- /dev/null +++ b/frontend/src/Components/Menu/SortMenuItem.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, sortDirections } from 'Helpers/Props'; +import SelectedMenuItem from './SelectedMenuItem'; + +function SortMenuItem(props) { + const { + name, + sortKey, + sortDirection, + ...otherProps + } = props; + + const isSelected = name === sortKey; + + return ( + + ); +} + +SortMenuItem.propTypes = { + name: PropTypes.string, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + onPress: PropTypes.func.isRequired +}; + +SortMenuItem.defaultProps = { + name: null +}; + +export default SortMenuItem; diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.css b/frontend/src/Components/Menu/ToolbarMenuButton.css new file mode 100644 index 000000000..c8a905e17 --- /dev/null +++ b/frontend/src/Components/Menu/ToolbarMenuButton.css @@ -0,0 +1,11 @@ +.menuButton { + composes: menuButton from './MenuButton.css'; + + width: $toolbarButtonWidth; + height: $toolbarHeight; + text-align: center; +} + +.label { + composes: label from 'Components/Page/Toolbar/PageToolbarButton.css'; +} diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.js b/frontend/src/Components/Menu/ToolbarMenuButton.js new file mode 100644 index 000000000..b80d6eaa3 --- /dev/null +++ b/frontend/src/Components/Menu/ToolbarMenuButton.js @@ -0,0 +1,38 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import MenuButton from 'Components/Menu/MenuButton'; +import styles from './ToolbarMenuButton.css'; + +function ToolbarMenuButton(props) { + const { + iconName, + text, + ...otherProps + } = props; + + return ( + +
+ + +
+ {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..7043da46f --- /dev/null +++ b/frontend/src/Components/NotFound.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import styles from './NotFound.css'; + +function NotFound({ message }) { + return ( + +
+
+ {message} +
+ + +
+
+ ); +} + +NotFound.propTypes = { + message: PropTypes.string.isRequired +}; + +NotFound.defaultProps = { + message: 'You must be lost, nothing to see here.' +}; + +export default NotFound; diff --git a/frontend/src/Components/Page/ErrorPage.css b/frontend/src/Components/Page/ErrorPage.css new file mode 100644 index 000000000..e62a82a6b --- /dev/null +++ b/frontend/src/Components/Page/ErrorPage.css @@ -0,0 +1,12 @@ +.page { + composes: page from './Page.css'; + + margin-top: 20px; + text-align: center; + font-size: 20px; +} + +.version { + margin-top: 20px; + font-size: 16px; +} diff --git a/frontend/src/Components/Page/ErrorPage.js b/frontend/src/Components/Page/ErrorPage.js new file mode 100644 index 000000000..891056c2c --- /dev/null +++ b/frontend/src/Components/Page/ErrorPage.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import styles from './ErrorPage.css'; + +function ErrorPage(props) { + const { + version, + isLocalStorageSupported, + seriesError, + customFiltersError, + tagsError, + qualityProfilesError, + languageProfilesError, + uiSettingsError + } = props; + + let errorMessage = 'Failed to load Sonarr'; + + if (!isLocalStorageSupported) { + errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.'; + } else if (seriesError) { + errorMessage = getErrorMessage(seriesError, 'Failed to load series from API'); + } else if (customFiltersError) { + errorMessage = getErrorMessage(customFiltersError, 'Failed to load custom filters from API'); + } else if (tagsError) { + errorMessage = getErrorMessage(tagsError, 'Failed to load tags from API'); + } else if (qualityProfilesError) { + errorMessage = getErrorMessage(qualityProfilesError, 'Failed to load quality profiles from API'); + } else if (languageProfilesError) { + errorMessage = getErrorMessage(languageProfilesError, 'Failed to load language profiles from API'); + } else if (uiSettingsError) { + errorMessage = getErrorMessage(uiSettingsError, 'Failed to load UI settings from API'); + } + + return ( +
+
+ {errorMessage} +
+ +
+ Version {version} +
+
+ ); +} + +ErrorPage.propTypes = { + version: PropTypes.string.isRequired, + isLocalStorageSupported: PropTypes.bool.isRequired, + seriesError: PropTypes.object, + customFiltersError: PropTypes.object, + tagsError: PropTypes.object, + qualityProfilesError: PropTypes.object, + languageProfilesError: PropTypes.object, + uiSettingsError: PropTypes.object +}; + +export default ErrorPage; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js new file mode 100644 index 000000000..a1d106b58 --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import KeyboardShortcutsModalContentConnector from './KeyboardShortcutsModalContentConnector'; + +function KeyboardShortcutsModal(props) { + const { + isOpen, + onModalClose + } = props; + + return ( + + + + ); +} + +KeyboardShortcutsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default KeyboardShortcutsModal; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css new file mode 100644 index 000000000..4425e0e0d --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css @@ -0,0 +1,15 @@ +.shortcut { + display: flex; + justify-content: space-between; + padding: 5px 20px; + font-size: 18px; +} + +.key { + padding: 2px 4px; + border-radius: 3px; + background-color: $defaultColor; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25); + color: $white; + font-size: 16px; +} diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js new file mode 100644 index 000000000..9c07e047c --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { shortcuts } from 'Components/keyboardShortcuts'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './KeyboardShortcutsModalContent.css'; + +function getShortcuts() { + const allShortcuts = []; + + Object.keys(shortcuts).forEach((key) => { + allShortcuts.push(shortcuts[key]); + }); + + return allShortcuts; +} + +function getShortcutKey(combo, isOsx) { + const comboMatch = combo.match(/(.+?)\+(.)/); + + if (!comboMatch) { + return combo; + } + + const modifier = comboMatch[1]; + const key = comboMatch[2]; + let osModifier = modifier; + + if (modifier === 'mod') { + osModifier = isOsx ? 'cmd' : 'ctrl'; + } + + return `${osModifier} + ${key}`; +} + +function KeyboardShortcutsModalContent(props) { + const { + isOsx, + onModalClose + } = props; + + const allShortcuts = getShortcuts(); + + return ( + + + Keyboard Shortcuts + + + + { + allShortcuts.map((shortcut) => { + return ( +
+
+ {getShortcutKey(shortcut.key, isOsx)} +
+ +
+ {shortcut.name} +
+
+ ); + }) + } +
+ + + + +
+ ); +} + +KeyboardShortcutsModalContent.propTypes = { + isOsx: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default KeyboardShortcutsModalContent; diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js new file mode 100644 index 000000000..d80877153 --- /dev/null +++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent'; + +function createMapStateToProps() { + return createSelector( + createSystemStatusSelector(), + (systemStatus) => { + return { + isOsx: systemStatus.isOsx + }; + } + ); +} + +export default connect(createMapStateToProps)(KeyboardShortcutsModalContent); diff --git a/frontend/src/Components/Page/Header/PageHeader.css b/frontend/src/Components/Page/Header/PageHeader.css new file mode 100644 index 000000000..1974cbcb1 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeader.css @@ -0,0 +1,65 @@ +.header { + z-index: 3; + display: flex; + align-items: center; + flex: 0 0 auto; + height: $headerHeight; + background-color: $themeAlternateBlue; + color: $white; +} + +.logoContainer { + display: flex; + align-items: center; + flex: 0 0 $sidebarWidth; + padding-left: 20px; +} + +.logoLink { + line-height: 0; +} + +.logo { + width: 32px; + height: 32px; +} + +.sidebarToggleContainer { + display: none; + justify-content: center; + flex: 0 0 45px; + margin-right: 14px; +} + +.right { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.donate { + composes: link from 'Components/Link/Link.css'; + + width: 30px; + color: $themeRed; + text-align: center; + line-height: 60px; + + &:hover { + color: #9c1f30; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .logoContainer { + flex: 0 0 60px; + } + + .sidebarToggleContainer { + display: flex; + } + + .donate { + display: none; + } +} diff --git a/frontend/src/Components/Page/Header/PageHeader.js b/frontend/src/Components/Page/Header/PageHeader.js new file mode 100644 index 000000000..9a1117a49 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeader.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SeriesSearchInputConnector from './SeriesSearchInputConnector'; +import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector'; +import KeyboardShortcutsModal from './KeyboardShortcutsModal'; +import styles from './PageHeader.css'; + +class PageHeader extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props); + + this.state = { + isKeyboardShortcutsModalOpen: false + }; + } + + componentDidMount() { + this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.onOpenKeyboardShortcutsModal); + } + + // + // Control + + onOpenKeyboardShortcutsModal = () => { + this.setState({ isKeyboardShortcutsModalOpen: true }); + } + + // + // Listeners + + onKeyboardShortcutsModalClose = () => { + this.setState({ isKeyboardShortcutsModalOpen: false }); + } + + // + // Render + + render() { + const { + onSidebarToggle + } = this.props; + + return ( +
+
+ + + +
+ +
+ +
+ + + +
+ + +
+ + +
+ ); + } +} + +PageHeader.propTypes = { + onSidebarToggle: PropTypes.func.isRequired, + bindShortcut: PropTypes.func.isRequired +}; + +export default keyboardShortcuts(PageHeader); diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css new file mode 100644 index 000000000..e27ad883e --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css @@ -0,0 +1,21 @@ +.menuButton { + margin-right: 15px; + width: 30px; + height: 60px; + text-align: center; + + &:hover { + color: $themeDarkColor; + } +} + +.itemIcon { + margin-right: 8px; +} + +@media only screen and (max-width: $breakpointSmall) { + .menuButton { + margin-right: 5px; + } +} + diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js new file mode 100644 index 000000000..ddeeeef04 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; +import MenuItemSeparator from 'Components/Menu/MenuItemSeparator'; +import styles from './PageHeaderActionsMenu.css'; + +function PageHeaderActionsMenu(props) { + const { + formsAuth, + onKeyboardShortcutsPress, + onRestartPress, + onShutdownPress + } = props; + + return ( +
+ + + + + + + + + Keyboard Shortcuts + + + + + + + Restart + + + + + Shutdown + + + { + formsAuth && +
+ } + + { + formsAuth && + + + Logout + + } + +
+
+ ); +} + +PageHeaderActionsMenu.propTypes = { + formsAuth: PropTypes.bool.isRequired, + onKeyboardShortcutsPress: PropTypes.func.isRequired, + onRestartPress: PropTypes.func.isRequired, + onShutdownPress: PropTypes.func.isRequired +}; + +export default PageHeaderActionsMenu; diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js new file mode 100644 index 000000000..66d131521 --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js @@ -0,0 +1,56 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { restart, shutdown } from 'Store/Actions/systemActions'; +import PageHeaderActionsMenu from './PageHeaderActionsMenu'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.status, + (status) => { + return { + formsAuth: status.item.authentication === 'forms' + }; + } + ); +} + +const mapDispatchToProps = { + restart, + shutdown +}; + +class PageHeaderActionsMenuConnector extends Component { + + // + // Listeners + + onRestartPress = () => { + this.props.restart(); + } + + onShutdownPress = () => { + this.props.shutdown(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +PageHeaderActionsMenuConnector.propTypes = { + restart: PropTypes.func.isRequired, + shutdown: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector); diff --git a/frontend/src/Components/Page/Header/SeriesSearchInput.css b/frontend/src/Components/Page/Header/SeriesSearchInput.css new file mode 100644 index 000000000..ce9a92271 --- /dev/null +++ b/frontend/src/Components/Page/Header/SeriesSearchInput.css @@ -0,0 +1,96 @@ +.wrapper { + display: flex; + align-items: center; +} + +.input { + margin-left: 8px; + width: 200px; + border: none; + border-bottom: solid 1px $white; + border-radius: 0; + background-color: transparent; + box-shadow: none; + color: $white; + transition: border 0.3s ease-out; + + &::placeholder { + color: $white; + transition: color 0.3s ease-out; + } + + &:focus { + outline: 0; + border-bottom-color: transparent; + + &::placeholder { + color: transparent; + } + } +} + +.container { + position: relative; + flex-grow: 1; +} + +.seriesContainer { + @add-mixin scrollbar; + @add-mixin scrollbarTrack; + @add-mixin scrollbarThumb; +} + +.containerOpen { + .seriesContainer { + position: absolute; + top: 42px; + z-index: 1; + overflow-y: auto; + min-width: 100%; + max-height: 230px; + border: 1px solid $themeDarkColor; + border-radius: 4px; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-color: $themeDarkColor; + box-shadow: inset 0 1px 1px $inputBoxShadowColor; + color: $menuItemColor; + } +} + +.list { + margin: 5px 0; + padding-left: 0; + list-style-type: none; +} + +.listItem { + padding: 0 16px; + white-space: nowrap; +} + +.highlighted { + background-color: $themeLightColor; +} + +.sectionTitle { + padding: 5px 8px; + color: $disabledColor; +} + +.addNewSeriesSuggestion { + padding: 0 3px; + cursor: pointer; +} + +@media only screen and (max-width: $breakpointSmall) { + .input { + min-width: 150px; + max-width: 200px; + } + + .container { + min-width: 0; + max-width: 200px; + } +} diff --git a/frontend/src/Components/Page/Header/SeriesSearchInput.js b/frontend/src/Components/Page/Header/SeriesSearchInput.js new file mode 100644 index 000000000..44c75a49a --- /dev/null +++ b/frontend/src/Components/Page/Header/SeriesSearchInput.js @@ -0,0 +1,260 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Autosuggest from 'react-autosuggest'; +import jdu from 'jdu'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts'; +import SeriesSearchResult from './SeriesSearchResult'; +import styles from './SeriesSearchInput.css'; + +const ADD_NEW_TYPE = 'addNew'; + +class SeriesSearchInput extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._autosuggest = null; + + this.state = { + value: '', + suggestions: [] + }; + } + + componentDidMount() { + this.props.bindShortcut(shortcuts.SERIES_SEARCH_INPUT.key, this.focusInput); + } + + // + // Control + + setAutosuggestRef = (ref) => { + this._autosuggest = ref; + } + + focusInput = (event) => { + event.preventDefault(); + this._autosuggest.input.focus(); + } + + getSectionSuggestions(section) { + return section.suggestions; + } + + renderSectionTitle(section) { + return ( +
+ {section.title} +
+ ); + } + + getSuggestionValue({ title }) { + return title; + } + + renderSuggestion(item, { query }) { + if (item.type === ADD_NEW_TYPE) { + return ( +
+ Search for {query} +
+ ); + } + + return ( + + ); + } + + goToSeries(series) { + this.setState({ value: '' }); + this.props.onGoToSeries(series.titleSlug); + } + + reset() { + this.setState({ + value: '', + suggestions: [] + }); + } + + // + // Listeners + + onChange = (event, { newValue, method }) => { + if (method === 'up' || method === 'down') { + return; + } + + this.setState({ value: newValue }); + } + + onKeyDown = (event) => { + if (event.key !== 'Tab' && event.key !== 'Enter') { + return; + } + + const { + suggestions, + value + } = this.state; + + const { + highlightedSectionIndex, + highlightedSuggestionIndex + } = this._autosuggest.state; + + if (!suggestions.length || highlightedSectionIndex) { + this.props.onGoToAddNewSeries(value); + this._autosuggest.input.blur(); + + return; + } + + // If an suggestion is not selected go to the first series, + // otherwise go to the selected series. + + if (highlightedSuggestionIndex == null) { + this.goToSeries(suggestions[0]); + } else { + this.goToSeries(suggestions[highlightedSuggestionIndex]); + } + } + + onBlur = () => { + this.reset(); + } + + onSuggestionsFetchRequested = ({ value }) => { + const lowerCaseValue = jdu.replace(value).toLowerCase(); + + const suggestions = this.props.series.filter((series) => { + // Check the title first and if there isn't a match fallback to + // the alternate titles and finally the tags. + + if (value.length === 1) { + return ( + series.cleanTitle.startsWith(lowerCaseValue) || + series.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.startsWith(lowerCaseValue)) || + series.tags.some((tag) => tag.cleanLabel.startsWith(lowerCaseValue)) + ); + } + + return ( + series.cleanTitle.contains(lowerCaseValue) || + series.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.contains(lowerCaseValue)) || + series.tags.some((tag) => tag.cleanLabel.contains(lowerCaseValue)) + ); + }); + + this.setState({ suggestions }); + } + + onSuggestionsClearRequested = () => { + this.setState({ + suggestions: [] + }); + } + + onSuggestionSelected = (event, { suggestion }) => { + if (suggestion.type === ADD_NEW_TYPE) { + this.props.onGoToAddNewSeries(this.state.value); + } else { + this.goToSeries(suggestion); + } + } + + // + // Render + + render() { + const { + value, + suggestions + } = this.state; + + const suggestionGroups = []; + + if (suggestions.length) { + suggestionGroups.push({ + title: 'Existing Series', + suggestions + }); + } + + suggestionGroups.push({ + title: 'Add New Series', + suggestions: [ + { + type: ADD_NEW_TYPE, + title: value + } + ] + }); + + const inputProps = { + ref: this.setInputRef, + className: styles.input, + name: 'seriesSearch', + value, + placeholder: 'Search', + autoComplete: 'off', + spellCheck: false, + onChange: this.onChange, + onKeyDown: this.onKeyDown, + onBlur: this.onBlur, + onFocus: this.onFocus + }; + + const theme = { + container: styles.container, + containerOpen: styles.containerOpen, + suggestionsContainer: styles.seriesContainer, + suggestionsList: styles.list, + suggestion: styles.listItem, + suggestionHighlighted: styles.highlighted + }; + + return ( +
+ + + +
+ ); + } +} + +SeriesSearchInput.propTypes = { + series: PropTypes.arrayOf(PropTypes.object).isRequired, + onGoToSeries: PropTypes.func.isRequired, + onGoToAddNewSeries: PropTypes.func.isRequired, + bindShortcut: PropTypes.func.isRequired +}; + +export default keyboardShortcuts(SeriesSearchInput); diff --git a/frontend/src/Components/Page/Header/SeriesSearchInputConnector.js b/frontend/src/Components/Page/Header/SeriesSearchInputConnector.js new file mode 100644 index 000000000..c90a49330 --- /dev/null +++ b/frontend/src/Components/Page/Header/SeriesSearchInputConnector.js @@ -0,0 +1,97 @@ +import { connect } from 'react-redux'; +import { push } from 'react-router-redux'; +import { createSelector } from 'reselect'; +import jdu from 'jdu'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import SeriesSearchInput from './SeriesSearchInput'; + +function createCleanTagsSelector() { + return createSelector( + createTagsSelector(), + (tags) => { + return tags.map((tag) => { + const { + id, + label + } = tag; + + return { + id, + label, + cleanLabel: jdu.replace(label).toLowerCase() + }; + }); + } + ); +} + +function createCleanSeriesSelector() { + return createSelector( + createAllSeriesSelector(), + createCleanTagsSelector(), + (allSeries, allTags) => { + return allSeries.map((series) => { + const { + title, + titleSlug, + sortTitle, + images, + alternateTitles = [], + tags = [] + } = series; + + return { + title, + titleSlug, + sortTitle, + images, + cleanTitle: jdu.replace(title).toLowerCase(), + alternateTitles: alternateTitles.map((alternateTitle) => { + return { + title: alternateTitle.title, + cleanTitle: jdu.replace(alternateTitle.title).toLowerCase() + }; + }), + tags: tags.map((id) => { + return allTags.find((tag) => tag.id === id); + }) + }; + }).sort((a, b) => { + if (a.cleanTitle < b.cleanTitle) { + return -1; + } + if (a.cleanTitle > b.cleanTitle) { + return 1; + } + + return 0; + }); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createCleanSeriesSelector(), + (series) => { + return { + series + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onGoToSeries(titleSlug) { + dispatch(push(`${window.Sonarr.urlBase}/series/${titleSlug}`)); + }, + + onGoToAddNewSeries(query) { + dispatch(push(`${window.Sonarr.urlBase}/add/new?term=${encodeURIComponent(query)}`)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesSearchInput); diff --git a/frontend/src/Components/Page/Header/SeriesSearchResult.css b/frontend/src/Components/Page/Header/SeriesSearchResult.css new file mode 100644 index 000000000..29edc382b --- /dev/null +++ b/frontend/src/Components/Page/Header/SeriesSearchResult.css @@ -0,0 +1,38 @@ +.result { + display: flex; + padding: 3px; + cursor: pointer; +} + +.poster { + width: 35px; + height: 50px; +} + +.titles { + flex: 1 1 1px; +} + +.title { + flex: 1 1 1px; + margin-left: 5px; +} + +.alternateTitle { + composes: title; + + color: $disabledColor; + font-size: $smallFontSize; +} + +.tagContainer { + composes: title; +} + +@media only screen and (max-width: $breakpointSmall) { + .titles, + .title, + .alternateTitle { + @add-mixin truncate; + } +} diff --git a/frontend/src/Components/Page/Header/SeriesSearchResult.js b/frontend/src/Components/Page/Header/SeriesSearchResult.js new file mode 100644 index 000000000..9e2adb82c --- /dev/null +++ b/frontend/src/Components/Page/Header/SeriesSearchResult.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; +import SeriesPoster from 'Series/SeriesPoster'; +import styles from './SeriesSearchResult.css'; + +function findMatchingAlternateTitle(alternateTitles, cleanQuery) { + return alternateTitles.find((alternateTitle) => { + return alternateTitle.cleanTitle.contains(cleanQuery); + }); +} + +function getMatchingTag(tags, cleanQuery) { + return tags.find((tag) => { + return tag.cleanLabel.contains(cleanQuery); + }); +} + +function SeriesSearchResult(props) { + const { + cleanQuery, + title, + cleanTitle, + images, + alternateTitles, + tags + } = props; + + const titleContains = cleanTitle.contains(cleanQuery); + let alternateTitle = null; + let tag = null; + + if (!titleContains) { + alternateTitle = findMatchingAlternateTitle(alternateTitles, cleanQuery); + } + + if (!titleContains && !alternateTitle) { + tag = getMatchingTag(tags, cleanQuery); + } + + return ( +
+ + +
+
+ {title} +
+ + { + !!alternateTitle && +
+ {alternateTitle.title} +
+ } + + { + !!tag && +
+ +
+ } +
+
+ ); +} + +SeriesSearchResult.propTypes = { + cleanQuery: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + cleanTitle: PropTypes.string.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, + tags: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default SeriesSearchResult; diff --git a/frontend/src/Components/Page/LoadingPage.css b/frontend/src/Components/Page/LoadingPage.css new file mode 100644 index 000000000..dd5852e61 --- /dev/null +++ b/frontend/src/Components/Page/LoadingPage.css @@ -0,0 +1,3 @@ +.page { + composes: page from './Page.css'; +} diff --git a/frontend/src/Components/Page/LoadingPage.js b/frontend/src/Components/Page/LoadingPage.js new file mode 100644 index 000000000..398b70c4b --- /dev/null +++ b/frontend/src/Components/Page/LoadingPage.js @@ -0,0 +1,15 @@ +import React from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import LoadingMessage from 'Components/Loading/LoadingMessage'; +import styles from './LoadingPage.css'; + +function LoadingPage() { + return ( +
+ + +
+ ); +} + +export default LoadingPage; diff --git a/frontend/src/Components/Page/Page.css b/frontend/src/Components/Page/Page.css new file mode 100644 index 000000000..9facbfc22 --- /dev/null +++ b/frontend/src/Components/Page/Page.css @@ -0,0 +1,18 @@ +.page { + display: flex; + flex-direction: column; + height: 100%; +} + +.main { + position: relative; /* need this to position inner content - is this really needed? */ + display: flex; + flex: 1 1 auto; +} + +@media only screen and (max-width: $breakpointSmall) { + .page { + flex-grow: 1; + height: initial; + } +} diff --git a/frontend/src/Components/Page/Page.js b/frontend/src/Components/Page/Page.js new file mode 100644 index 000000000..fc7502fd4 --- /dev/null +++ b/frontend/src/Components/Page/Page.js @@ -0,0 +1,130 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import locationShape from 'Helpers/Props/Shapes/locationShape'; +import SignalRConnector from 'Components/SignalRConnector'; +import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector'; +import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector'; +import PageHeader from './Header/PageHeader'; +import PageSidebar from './Sidebar/PageSidebar'; +import styles from './Page.css'; + +class Page extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isUpdatedModalOpen: false, + isConnectionLostModalOpen: false + }; + } + + componentDidMount() { + window.addEventListener('resize', this.onResize); + } + + componentDidUpdate(prevProps) { + const { + isDisconnected, + isUpdated + } = this.props; + + if (!prevProps.isUpdated && isUpdated) { + this.setState({ isUpdatedModalOpen: true }); + } + + if (prevProps.isDisconnected !== isDisconnected) { + this.setState({ isConnectionLostModalOpen: isDisconnected }); + } + } + + componentWillUnmount() { + window.removeEventListener('resize', this.onResize); + } + + // + // Listeners + + onResize = () => { + this.props.onResize({ + width: window.innerWidth, + height: window.innerHeight + }); + } + + onUpdatedModalClose = () => { + this.setState({ isUpdatedModalOpen: false }); + } + + onConnectionLostModalClose = () => { + this.setState({ isConnectionLostModalOpen: false }); + } + + // + // Render + + render() { + const { + className, + location, + children, + isSmallScreen, + isSidebarVisible, + onSidebarToggle, + onSidebarVisibleChange + } = this.props; + + return ( +
+ + + + +
+ + + {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, + onResize: PropTypes.func.isRequired, + onSidebarToggle: PropTypes.func.isRequired, + onSidebarVisibleChange: PropTypes.func.isRequired +}; + +Page.defaultProps = { + className: styles.page +}; + +export default Page; diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js new file mode 100644 index 000000000..5085d139b --- /dev/null +++ b/frontend/src/Components/Page/PageConnector.js @@ -0,0 +1,194 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; +import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; +import { fetchSeries } from 'Store/Actions/seriesActions'; +import { fetchTags } from 'Store/Actions/tagActions'; +import { fetchQualityProfiles, fetchLanguageProfiles, fetchUISettings } from 'Store/Actions/settingsActions'; +import { fetchStatus } from 'Store/Actions/systemActions'; +import ErrorPage from './ErrorPage'; +import LoadingPage from './LoadingPage'; +import Page from './Page'; + +function testLocalStorage() { + const key = 'sonarrTest'; + + try { + localStorage.setItem(key, key); + localStorage.removeItem(key); + + return true; + } catch (e) { + return false; + } +} + +function createMapStateToProps() { + return createSelector( + (state) => state.series, + (state) => state.customFilters, + (state) => state.tags, + (state) => state.settings, + (state) => state.app, + createDimensionsSelector(), + (series, customFilters, tags, settings, app, dimensions) => { + const isPopulated = ( + series.isPopulated && + customFilters.isPopulated && + tags.isPopulated && + settings.qualityProfiles.isPopulated && + settings.ui.isPopulated + ); + + const hasError = !!( + series.error || + customFilters.error || + tags.error || + settings.qualityProfiles.error || + settings.languageProfiles.error || + settings.ui.error + ); + + return { + isPopulated, + hasError, + seriesError: series.error, + customFiltersError: tags.error, + tagsError: tags.error, + qualityProfilesError: settings.qualityProfiles.error, + uiSettingsError: settings.ui.error, + isSmallScreen: dimensions.isSmallScreen, + isSidebarVisible: app.isSidebarVisible, + version: app.version, + isUpdated: app.isUpdated, + isDisconnected: app.isDisconnected + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchSeries() { + dispatch(fetchSeries()); + }, + dispatchFetchCustomFilters() { + dispatch(fetchCustomFilters()); + }, + dispatchFetchTags() { + dispatch(fetchTags()); + }, + dispatchFetchQualityProfiles() { + dispatch(fetchQualityProfiles()); + }, + dispatchFetchLanguageProfiles() { + dispatch(fetchLanguageProfiles()); + }, + dispatchFetchUISettings() { + dispatch(fetchUISettings()); + }, + dispatchFetchStatus() { + dispatch(fetchStatus()); + }, + onResize(dimensions) { + dispatch(saveDimensions(dimensions)); + }, + onSidebarVisibleChange(isSidebarVisible) { + dispatch(setIsSidebarVisible({ isSidebarVisible })); + } + }; +} + +class PageConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isLocalStorageSupported: testLocalStorage() + }; + } + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.dispatchFetchSeries(); + this.props.dispatchFetchCustomFilters(); + this.props.dispatchFetchTags(); + this.props.dispatchFetchQualityProfiles(); + this.props.dispatchFetchLanguageProfiles(); + this.props.dispatchFetchUISettings(); + this.props.dispatchFetchStatus(); + } + } + + // + // Listeners + + onSidebarToggle = () => { + this.props.onSidebarVisibleChange(!this.props.isSidebarVisible); + } + + // + // Render + + render() { + const { + isPopulated, + hasError, + dispatchFetchSeries, + dispatchFetchTags, + dispatchFetchQualityProfiles, + dispatchFetchLanguageProfiles, + dispatchFetchUISettings, + dispatchFetchStatus, + ...otherProps + } = this.props; + + if (hasError || !this.state.isLocalStorageSupported) { + return ( + + ); + } + + if (isPopulated) { + return ( + + ); + } + + return ( + + ); + } +} + +PageConnector.propTypes = { + isPopulated: PropTypes.bool.isRequired, + hasError: PropTypes.bool.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + dispatchFetchSeries: PropTypes.func.isRequired, + dispatchFetchCustomFilters: PropTypes.func.isRequired, + dispatchFetchTags: PropTypes.func.isRequired, + dispatchFetchQualityProfiles: PropTypes.func.isRequired, + dispatchFetchLanguageProfiles: PropTypes.func.isRequired, + dispatchFetchUISettings: PropTypes.func.isRequired, + dispatchFetchStatus: PropTypes.func.isRequired, + onSidebarVisibleChange: PropTypes.func.isRequired +}; + +export default withRouter( + connect(createMapStateToProps, createMapDispatchToProps)(PageConnector) +); diff --git a/frontend/src/Components/Page/PageContent.css b/frontend/src/Components/Page/PageContent.css new file mode 100644 index 000000000..4580077c3 --- /dev/null +++ b/frontend/src/Components/Page/PageContent.css @@ -0,0 +1,8 @@ +.content { + position: relative; + display: flex; + flex-direction: column; + flex-grow: 1; + overflow-x: hidden; + width: 100%; +} diff --git a/frontend/src/Components/Page/PageContent.js b/frontend/src/Components/Page/PageContent.js new file mode 100644 index 000000000..62003c2b0 --- /dev/null +++ b/frontend/src/Components/Page/PageContent.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DocumentTitle from 'react-document-title'; +import ErrorBoundary from 'Components/Error/ErrorBoundary'; +import PageContentError from './PageContentError'; +import styles from './PageContent.css'; + +function PageContent(props) { + const { + className, + title, + children + } = props; + + return ( + + +
+ {children} +
+
+
+ ); +} + +PageContent.propTypes = { + className: PropTypes.string, + title: PropTypes.string, + children: PropTypes.node.isRequired +}; + +PageContent.defaultProps = { + className: styles.content +}; + +export default PageContent; diff --git a/frontend/src/Components/Page/PageContentBody.css b/frontend/src/Components/Page/PageContentBody.css new file mode 100644 index 000000000..8b41754dd --- /dev/null +++ b/frontend/src/Components/Page/PageContentBody.css @@ -0,0 +1,19 @@ +.contentBody { + /* 1px for flex-basis so the div grows correctly in Edge/Firefox */ + flex: 1 0 1px; +} + +.innerContentBody { + padding: $pageContentBodyPadding; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentBody { + flex-basis: auto; + overflow-y: hidden !important; + } + + .innerContentBody { + padding: $pageContentBodyPaddingSmallScreen; + } +} diff --git a/frontend/src/Components/Page/PageContentBody.js b/frontend/src/Components/Page/PageContentBody.js new file mode 100644 index 000000000..81bd9b29b --- /dev/null +++ b/frontend/src/Components/Page/PageContentBody.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import OverlayScroller from 'Components/Scroller/OverlayScroller'; +import Scroller from 'Components/Scroller/Scroller'; +import styles from './PageContentBody.css'; + +class PageContentBody extends Component { + + // + // Render + + render() { + const { + className, + innerClassName, + isSmallScreen, + children, + dispatch, + ...otherProps + } = this.props; + + const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller; + + return ( + +
+ {children} +
+
+ ); + } +} + +PageContentBody.propTypes = { + className: PropTypes.string, + innerClassName: PropTypes.string, + isSmallScreen: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + dispatch: PropTypes.func +}; + +PageContentBody.defaultProps = { + className: styles.contentBody, + innerClassName: styles.innerContentBody +}; + +export default PageContentBody; diff --git a/frontend/src/Components/Page/PageContentBodyConnector.js b/frontend/src/Components/Page/PageContentBodyConnector.js new file mode 100644 index 000000000..b5cdfbb21 --- /dev/null +++ b/frontend/src/Components/Page/PageContentBodyConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import PageContentBody from './PageContentBody'; + +function createMapStateToProps() { + return createSelector( + createDimensionsSelector(), + (dimensions) => { + return { + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +export default connect(createMapStateToProps)(PageContentBody); diff --git a/frontend/src/Components/Page/PageContentError.css b/frontend/src/Components/Page/PageContentError.css new file mode 100644 index 000000000..7b1f7a6db --- /dev/null +++ b/frontend/src/Components/Page/PageContentError.css @@ -0,0 +1,3 @@ +.content { + composes: content from './PageContent.css'; +} diff --git a/frontend/src/Components/Page/PageContentError.js b/frontend/src/Components/Page/PageContentError.js new file mode 100644 index 000000000..5ae41a936 --- /dev/null +++ b/frontend/src/Components/Page/PageContentError.js @@ -0,0 +1,19 @@ +import React from 'react'; +import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError'; +import PageContentBodyConnector from './PageContentBodyConnector'; +import styles from './PageContentError.css'; + +function PageContentError(props) { + return ( +
+ + + +
+ ); +} + +export default PageContentError; diff --git a/frontend/src/Components/Page/PageContentFooter.css b/frontend/src/Components/Page/PageContentFooter.css new file mode 100644 index 000000000..74bdb3811 --- /dev/null +++ b/frontend/src/Components/Page/PageContentFooter.css @@ -0,0 +1,26 @@ +.contentFooter { + display: flex; + flex: 0 0 auto; + padding: 20px; + background-color: #f1f1f1; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentFooter { + display: block; + + div { + margin-top: 10px; + + &:first-child { + margin-top: 0; + } + } + } +} + +@media only screen and (max-width: $breakpointLarge) { + .contentFooter { + flex-wrap: wrap; + } +} diff --git a/frontend/src/Components/Page/PageContentFooter.js b/frontend/src/Components/Page/PageContentFooter.js new file mode 100644 index 000000000..1f6e2d21a --- /dev/null +++ b/frontend/src/Components/Page/PageContentFooter.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './PageContentFooter.css'; + +class PageContentFooter extends Component { + + // + // Render + + render() { + const { + className, + children + } = this.props; + + return ( +
+ {children} +
+ ); + } +} + +PageContentFooter.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired +}; + +PageContentFooter.defaultProps = { + className: styles.contentFooter +}; + +export default PageContentFooter; diff --git a/frontend/src/Components/Page/PageJumpBar.css b/frontend/src/Components/Page/PageJumpBar.css new file mode 100644 index 000000000..9a116fb54 --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBar.css @@ -0,0 +1,22 @@ +.jumpBar { + display: flex; + align-content: stretch; + align-items: stretch; + align-self: stretch; + justify-content: center; + flex: 0 0 30px; +} + +.jumpBarItems { + display: flex; + justify-content: space-around; + flex: 0 0 100%; + flex-direction: column; + overflow: hidden; +} + +@media only screen and (max-width: $breakpointSmall) { + .jumpBar { + display: none; + } +} diff --git a/frontend/src/Components/Page/PageJumpBar.js b/frontend/src/Components/Page/PageJumpBar.js new file mode 100644 index 000000000..f7d44ae9a --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBar.js @@ -0,0 +1,133 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import dimensions from 'Styles/Variables/dimensions'; +import Measure from 'Components/Measure'; +import PageJumpBarItem from './PageJumpBarItem'; +import styles from './PageJumpBar.css'; + +const ITEM_HEIGHT = parseInt(dimensions.jumpBarItemHeight); + +class PageJumpBar extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + height: 0, + visibleItems: props.items + }; + } + + componentDidMount() { + this.computeVisibleItems(); + } + + componentDidUpdate(prevProps, prevState) { + if ( + prevProps.items !== this.props.items || + prevState.height !== this.state.height + ) { + this.computeVisibleItems(); + } + } + + // + // Control + + computeVisibleItems() { + const { + items, + minimumItems + } = this.props; + + const height = this.state.height; + const maximumItems = Math.floor(height / ITEM_HEIGHT); + const diff = items.length - maximumItems; + + if (diff < 0) { + this.setState({ visibleItems: items }); + return; + } + + if (items.length < minimumItems) { + this.setState({ visibleItems: items }); + return; + } + + const removeDiff = Math.ceil(items.length / maximumItems); + + const visibleItems = _.reduce(items, (acc, item, index) => { + if (index % removeDiff === 0) { + acc.push(item); + } + + return acc; + }, []); + + this.setState({ visibleItems }); + } + + // + // Listeners + + onMeasure = ({ height }) => { + this.setState({ height }); + } + + // + // Render + + render() { + const { + minimumItems, + onItemPress + } = this.props; + + const { + visibleItems + } = this.state; + + if (!visibleItems.length || visibleItems.length < minimumItems) { + return null; + } + + return ( +
+ +
+ { + visibleItems.map((item) => { + return ( + + ); + }) + } +
+
+
+ ); + } +} + +PageJumpBar.propTypes = { + items: PropTypes.arrayOf(PropTypes.string).isRequired, + minimumItems: PropTypes.number.isRequired, + onItemPress: PropTypes.func.isRequired +}; + +PageJumpBar.defaultProps = { + minimumItems: 5 +}; + +export default PageJumpBar; diff --git a/frontend/src/Components/Page/PageJumpBarItem.css b/frontend/src/Components/Page/PageJumpBarItem.css new file mode 100644 index 000000000..e829dd31a --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBarItem.css @@ -0,0 +1,14 @@ +.jumpBarItem { + flex: 1 0 $jumpBarItemHeight; + border-bottom: 1px solid $borderColor; + text-align: center; + font-weight: bold; + + &:hover { + color: #777; + } + + &:last-child { + border: none; + } +} diff --git a/frontend/src/Components/Page/PageJumpBarItem.js b/frontend/src/Components/Page/PageJumpBarItem.js new file mode 100644 index 000000000..aeffe4ddd --- /dev/null +++ b/frontend/src/Components/Page/PageJumpBarItem.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './PageJumpBarItem.css'; + +class PageJumpBarItem extends Component { + + // + // Listeners + + onPress = () => { + const { + label, + onItemPress + } = this.props; + + onItemPress(label); + } + + // + // Render + + render() { + return ( + + {this.props.label.toUpperCase()} + + ); + } +} + +PageJumpBarItem.propTypes = { + label: PropTypes.string.isRequired, + onItemPress: PropTypes.func.isRequired +}; + +export default PageJumpBarItem; diff --git a/frontend/src/Components/Page/PageSectionContent.js b/frontend/src/Components/Page/PageSectionContent.js new file mode 100644 index 000000000..774b88669 --- /dev/null +++ b/frontend/src/Components/Page/PageSectionContent.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; + +function PageSectionContent(props) { + const { + isFetching, + isPopulated, + error, + errorMessage, + children + } = props; + + if (isFetching) { + return ( + + ); + } else if (!isFetching && !!error) { + return ( +
{errorMessage}
+ ); + } else if (isPopulated && !error) { + return ( +
{children}
+ ); + } + + return null; +} + +PageSectionContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + errorMessage: PropTypes.string.isRequired, + children: PropTypes.node.isRequired +}; + +export default PageSectionContent; diff --git a/frontend/src/Components/Page/Sidebar/Messages/Message.css b/frontend/src/Components/Page/Sidebar/Messages/Message.css new file mode 100644 index 000000000..7d53adb69 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Message.css @@ -0,0 +1,42 @@ +.message { + display: flex; + border-left: 3px solid $infoColor; +} + +.iconContainer, +.text { + display: flex; + justify-content: center; + flex-direction: column; + padding: 2px 0; + color: $sidebarColor; +} + +.iconContainer { + flex: 0 0 25px; + margin-left: 24px; + padding: 10px 0; +} + +.text { + margin-right: 24px; + font-size: 13px; +} + +/* Types */ + +.error { + border-left-color: $dangerColor; +} + +.info { + border-left-color: $infoColor; +} + +.success { + border-left-color: $successColor; +} + +.warning { + border-left-color: $warningColor; +} diff --git a/frontend/src/Components/Page/Sidebar/Messages/Message.js b/frontend/src/Components/Page/Sidebar/Messages/Message.js new file mode 100644 index 000000000..7f2343d7c --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Message.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import styles from './Message.css'; + +function getIconName(name) { + switch (name) { + case 'ApplicationUpdate': + return icons.RESTART; + case 'Backup': + return icons.BACKUP; + case 'CheckHealth': + return icons.HEALTH; + case 'EpisodeSearch': + return icons.SEARCH; + case 'Housekeeping': + return icons.HOUSEKEEPING; + case 'RefreshSeries': + return icons.REFRESH; + case 'RssSync': + return icons.RSS; + case 'SeasonSearch': + return icons.SEARCH; + case 'SeriesSearch': + return icons.SEARCH; + case 'UpdateSceneMapping': + return icons.REFRESH; + default: + return icons.SPINNER; + } +} + +function Message(props) { + const { + name, + message, + type + } = props; + + return ( +
+
+ +
+ +
+ {message} +
+
+ ); +} + +Message.propTypes = { + name: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + type: PropTypes.string.isRequired +}; + +export default Message; diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js new file mode 100644 index 000000000..06c545c27 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { hideMessage } from 'Store/Actions/appActions'; +import Message from './Message'; + +const mapDispatchToProps = { + hideMessage +}; + +class MessageConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._hideTimeoutId = null; + this.scheduleHideMessage(props.hideAfter); + } + + componentDidUpdate() { + this.scheduleHideMessage(this.props.hideAfter); + } + + // + // Control + + scheduleHideMessage = (hideAfter) => { + if (this._hideTimeoutId) { + clearTimeout(this._hideTimeoutId); + } + + if (hideAfter) { + this._hideTimeoutId = setTimeout(this.hideMessage, hideAfter * 1000); + } + } + + hideMessage = () => { + this.props.hideMessage({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MessageConnector.propTypes = { + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + hideAfter: PropTypes.number.isRequired, + hideMessage: PropTypes.func.isRequired +}; + +MessageConnector.defaultProps = { + // Hide messages after 60 seconds if there is no activity + // hideAfter: 60 +}; + +export default connect(undefined, mapDispatchToProps)(MessageConnector); diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.css b/frontend/src/Components/Page/Sidebar/Messages/Messages.css new file mode 100644 index 000000000..ef01ad02c --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.css @@ -0,0 +1,11 @@ +.messages { + margin-top: auto; + margin-bottom: 20px; + padding-top: 20px; +} + +@media only screen and (max-width: $breakpointSmall) { + .messages { + margin-bottom: 0; + } +} diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.js b/frontend/src/Components/Page/Sidebar/Messages/Messages.js new file mode 100644 index 000000000..ec8876f6e --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import MessageConnector from './MessageConnector'; +import styles from './Messages.css'; + +function Messages({ messages }) { + return ( +
+ { + messages.map((message) => { + return ( + + ); + }) + } +
+ ); +} + +Messages.propTypes = { + messages: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Messages; diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js new file mode 100644 index 000000000..5d20d9194 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import Messages from './Messages'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.messages.items, + (messages) => { + return { + messages: messages.slice().reverse() + }; + } + ); +} + +export default connect(createMapStateToProps)(Messages); diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.css b/frontend/src/Components/Page/Sidebar/PageSidebar.css new file mode 100644 index 000000000..fdbd80320 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.css @@ -0,0 +1,34 @@ +.sidebarContainer { + flex: 0 0 $sidebarWidth; + overflow: hidden; + width: $sidebarWidth; + background-color: $sidebarBackgroundColor; + transition: transform 300ms ease-in-out; + transform: translateX(0); +} + +.sidebar { + display: flex; + flex-direction: column; + overflow: hidden; + background-color: $sidebarBackgroundColor; + color: $white; +} + +@media only screen and (max-width: $breakpointSmall) { + .sidebarContainer { + position: fixed; + top: 0; + z-index: 2; + height: 100vh; + } + + .sidebar { + position: fixed; + z-index: 2; + overflow-y: auto; + width: 100%; + height: 100%; + } +} + diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js new file mode 100644 index 000000000..6ffdf53cc --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -0,0 +1,525 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import locationShape from 'Helpers/Props/Shapes/locationShape'; +import dimensions from 'Styles/Variables/dimensions'; +import OverlayScroller from 'Components/Scroller/OverlayScroller'; +import Scroller from 'Components/Scroller/Scroller'; +import QueueStatusConnector from 'Activity/Queue/Status/QueueStatusConnector'; +import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector'; +import MessagesConnector from './Messages/MessagesConnector'; +import PageSidebarItem from './PageSidebarItem'; +import styles from './PageSidebar.css'; + +const HEADER_HEIGHT = parseInt(dimensions.headerHeight); +const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth); + +const links = [ + { + iconName: icons.SERIES_CONTINUING, + title: 'Series', + to: '/', + alias: '/series', + children: [ + { + title: 'Add New', + to: '/add/new' + }, + { + title: 'Import', + to: '/add/import' + }, + { + title: 'Mass Editor', + to: '/serieseditor' + }, + { + title: 'Season Pass', + to: '/seasonpass' + } + ] + }, + + { + iconName: icons.CALENDAR, + title: 'Calendar', + to: '/calendar' + }, + + { + iconName: icons.ACTIVITY, + title: 'Activity', + to: '/activity/queue', + children: [ + { + title: 'Queue', + to: '/activity/queue', + statusComponent: QueueStatusConnector + }, + { + title: 'History', + to: '/activity/history' + }, + { + title: 'Blacklist', + to: '/activity/blacklist' + } + ] + }, + + { + iconName: icons.WARNING, + title: 'Wanted', + to: '/wanted/missing', + children: [ + { + title: 'Missing', + to: '/wanted/missing' + }, + { + title: 'Cutoff Unmet', + to: '/wanted/cutoffunmet' + } + ] + }, + + { + iconName: icons.SETTINGS, + title: 'Settings', + to: '/settings', + children: [ + { + title: 'Media Management', + to: '/settings/mediamanagement' + }, + { + title: 'Profiles', + to: '/settings/profiles' + }, + { + title: 'Quality', + to: '/settings/quality' + }, + { + title: 'Indexers', + to: '/settings/indexers' + }, + { + title: 'Download Clients', + to: '/settings/downloadclients' + }, + { + title: 'Connect', + to: '/settings/connect' + }, + { + title: 'Metadata', + to: '/settings/metadata' + }, + { + title: 'Tags', + to: '/settings/tags' + }, + { + title: 'General', + to: '/settings/general' + }, + { + title: 'UI', + to: '/settings/ui' + } + ] + }, + + { + iconName: icons.SYSTEM, + title: 'System', + to: '/system/status', + children: [ + { + title: 'Status', + to: '/system/status', + statusComponent: HealthStatusConnector + }, + { + title: 'Tasks', + to: '/system/tasks' + }, + { + title: 'Backup', + to: '/system/backup' + }, + { + title: 'Updates', + to: '/system/updates' + }, + { + title: 'Events', + to: '/system/events' + }, + { + title: 'Log Files', + to: '/system/logs/files' + } + ] + } +]; + +function getActiveParent(pathname) { + let activeParent = links[0].to; + + links.forEach((link) => { + if (link.to && link.to === pathname) { + activeParent = link.to; + + return false; + } + + const children = link.children; + + if (children) { + children.forEach((childLink) => { + if (pathname.startsWith(childLink.to)) { + activeParent = link.to; + + return false; + } + }); + } + + if ( + (link.to !== '/' && pathname.startsWith(link.to)) || + (link.alias && pathname.startsWith(link.alias)) + ) { + activeParent = link.to; + + return false; + } + }); + + return activeParent; +} + +function hasActiveChildLink(link, pathname) { + const children = link.children; + + if (!children || !children.length) { + return false; + } + + return _.some(children, (child) => { + return child.to === pathname; + }); +} + +function getPositioning() { + const windowScroll = window.scrollY == null ? document.documentElement.scrollTop : window.scrollY; + const top = Math.max(HEADER_HEIGHT - windowScroll, 0); + const height = window.innerHeight - top; + + return { + top: `${top}px`, + height: `${height}px` + }; +} + +class PageSidebar extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._touchStartX = null; + this._touchStartY = null; + this._sidebarRef = null; + + this.state = { + top: dimensions.headerHeight, + height: `${window.innerHeight - HEADER_HEIGHT}px`, + transition: null, + transform: props.isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1 + }; + } + + componentDidMount() { + if (this.props.isSmallScreen) { + window.addEventListener('click', this.onWindowClick, { capture: true }); + window.addEventListener('scroll', this.onWindowScroll); + window.addEventListener('touchstart', this.onTouchStart); + window.addEventListener('touchmove', this.onTouchMove); + window.addEventListener('touchend', this.onTouchEnd); + window.addEventListener('touchcancel', this.onTouchCancel); + } + } + + componentDidUpdate(prevProps) { + const { + isSidebarVisible + } = this.props; + + const transform = this.state.transform; + + if (prevProps.isSidebarVisible !== isSidebarVisible) { + this._setSidebarTransform(isSidebarVisible); + } else if (transform === 0 && !isSidebarVisible) { + this.props.onSidebarVisibleChange(true); + } else if (transform === -SIDEBAR_WIDTH && isSidebarVisible) { + this.props.onSidebarVisibleChange(false); + } + } + + componentWillUnmount() { + if (this.props.isSmallScreen) { + window.removeEventListener('click', this.onWindowClick, { capture: true }); + window.removeEventListener('scroll', this.onWindowScroll); + window.removeEventListener('touchstart', this.onTouchStart); + window.removeEventListener('touchmove', this.onTouchMove); + window.removeEventListener('touchend', this.onTouchEnd); + window.removeEventListener('touchcancel', this.onTouchCancel); + } + } + + // + // Control + + _setSidebarRef = (ref) => { + this._sidebarRef = ref; + } + + _setSidebarTransform(isSidebarVisible, transition, callback) { + this.setState({ + transition, + transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1 + }, callback); + } + + // + // Listeners + + onWindowClick = (event) => { + const sidebar = ReactDOM.findDOMNode(this._sidebarRef); + const toggleButton = document.getElementById('sidebar-toggle-button'); + + if (!sidebar) { + return; + } + + if ( + !sidebar.contains(event.target) && + !toggleButton.contains(event.target) && + this.props.isSidebarVisible + ) { + event.preventDefault(); + event.stopPropagation(); + this.props.onSidebarVisibleChange(false); + } + } + + onWindowScroll = () => { + this.setState(getPositioning()); + } + + onTouchStart = (event) => { + const touches = event.touches; + const touchStartX = touches[0].pageX; + const touchStartY = touches[0].pageY; + const isSidebarVisible = this.props.isSidebarVisible; + + if (touches.length !== 1) { + return; + } + + if (isSidebarVisible && (touchStartX > 210 || touchStartX < 180)) { + return; + } else if (!isSidebarVisible && touchStartX > 40) { + return; + } + + this._touchStartX = touchStartX; + this._touchStartY = touchStartY; + } + + onTouchMove = (event) => { + const touches = event.touches; + const currentTouchX = touches[0].pageX; + // const currentTouchY = touches[0].pageY; + // const isSidebarVisible = this.props.isSidebarVisible; + + if (!this._touchStartX) { + return; + } + + // This is a bit funky when trying to close and you scroll + // vertical too much by mistake, commenting out for now. + // TODO: Evaluate if this should be nuked + + // if (Math.abs(this._touchStartY - currentTouchY) > 40) { + // const transform = isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1; + + // this.setState({ + // transition: 'none', + // transform + // }); + + // return; + // } + + if (Math.abs(this._touchStartX - currentTouchX) < 40) { + return; + } + + const transform = Math.min(currentTouchX - SIDEBAR_WIDTH, 0); + + this.setState({ + transition: 'none', + transform + }); + } + + onTouchEnd = (event) => { + const touches = event.changedTouches; + const currentTouch = touches[0].pageX; + + if (!this._touchStartX) { + return; + } + + if (currentTouch > this._touchStartX && currentTouch > 50) { + this._setSidebarTransform(true, 'none'); + } else if (currentTouch < this._touchStartX && currentTouch < 80) { + this._setSidebarTransform(false, 'transform 50ms ease-in-out'); + } else { + this._setSidebarTransform(this.props.isSidebarVisible); + } + + this._touchStartX = null; + this._touchStartY = null; + } + + onTouchCancel = (event) => { + this._touchStartX = null; + this._touchStartY = null; + } + + onItemPress = () => { + this.props.onSidebarVisibleChange(false); + } + + // + // Render + + render() { + const { + location, + isSmallScreen + } = this.props; + + const { + top, + height, + transition, + transform + } = this.state; + + const urlBase = window.Sonarr.urlBase; + const pathname = urlBase ? location.pathname.substr(urlBase.length) || '/' : location.pathname; + const activeParent = getActiveParent(pathname); + + let containerStyle = {}; + let sidebarStyle = {}; + + if (isSmallScreen) { + containerStyle = { + transition, + transform: `translateX(${transform}px)` + }; + + sidebarStyle = { + top, + height + }; + } + + const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller; + + return ( +
+ +
+ { + links.map((link) => { + const childWithStatusComponent = _.find(link.children, (child) => { + return !!child.statusComponent; + }); + + const childStatusComponent = childWithStatusComponent ? + childWithStatusComponent.statusComponent : + null; + + const isActiveParent = activeParent === link.to; + const hasActiveChild = hasActiveChildLink(link, pathname); + + return ( + + { + link.children && link.to === activeParent && + link.children.map((child) => { + return ( + + ); + }) + } + + ); + }) + } +
+ + +
+
+ ); + } +} + +PageSidebar.propTypes = { + location: locationShape.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + isSidebarVisible: PropTypes.bool.isRequired, + onSidebarVisibleChange: PropTypes.func.isRequired +}; + +export default PageSidebar; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.css b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css new file mode 100644 index 000000000..dbf0bd2ba --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css @@ -0,0 +1,50 @@ +.item { + border-left: 3px solid transparent; + color: $sidebarColor; + transition: border-left 0.3s ease-in-out; +} + +.isActiveItem { + border-left: 3px solid $themeAlternateBlue; +} + +.link { + display: block; + padding: 12px 24px; + color: $sidebarColor; + + &:hover, + &:focus { + color: $themeBlue; + text-decoration: none; + } +} + +.childLink { + composes: link; + + padding: 10px 24px; +} + +.isActiveLink { + color: $themeAlternateBlue; +} + +.isActiveParentLink { + background-color: $sidebarActiveBackgroundColor; +} + +.iconContainer { + display: inline-block; + margin-right: 7px; + width: 18px; + text-align: center; +} + +.noIcon { + margin-left: 25px; +} + +.status { + float: right; +} diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js new file mode 100644 index 000000000..d494150c0 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { map } from 'Helpers/elementChildren'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './PageSidebarItem.css'; + +class PageSidebarItem extends Component { + + // + // Listeners + + onPress = () => { + const { + isChildItem, + isParentItem, + onPress + } = this.props; + + if (isChildItem || !isParentItem) { + onPress(); + } + } + + // + // Render + + render() { + const { + iconName, + title, + to, + isActive, + isActiveParent, + isChildItem, + statusComponent: StatusComponent, + children + } = this.props; + + return ( +
+ + { + !!iconName && + + + + } + + + {title} + + + { + !!StatusComponent && + + + + } + + + { + children && + map(children, (child) => { + return React.cloneElement(child, { isChildItem: true }); + }) + } +
+ ); + } +} + +PageSidebarItem.propTypes = { + iconName: PropTypes.object, + title: PropTypes.string.isRequired, + to: PropTypes.string.isRequired, + isActive: PropTypes.bool, + isActiveParent: PropTypes.bool, + isParentItem: PropTypes.bool.isRequired, + isChildItem: PropTypes.bool.isRequired, + statusComponent: PropTypes.func, + children: PropTypes.node, + onPress: PropTypes.func +}; + +PageSidebarItem.defaultProps = { + isChildItem: false +}; + +export default PageSidebarItem; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css new file mode 100644 index 000000000..4dd0cc678 --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css @@ -0,0 +1,3 @@ +.status { + composes: label from 'Components/Label.css'; +} diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js new file mode 100644 index 000000000..c1ea615ed --- /dev/null +++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function PageSidebarStatus({ count, errors, warnings }) { + if (!count) { + return null; + } + + let kind = kinds.INFO; + + if (errors) { + kind = kinds.DANGER; + } else if (warnings) { + kind = kinds.WARNING; + } + + return ( + + ); +} + +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..5fb56b77c --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css @@ -0,0 +1,27 @@ +.sectionContainer { + display: flex; + flex: 1 1 10%; + overflow: hidden; +} + +.section { + display: flex; + align-items: center; + flex-grow: 1; +} + +.left { + justify-content: flex-start; +} + +.center { + justify-content: center; +} + +.right { + justify-content: flex-end; +} + +.overflowMenuItemIcon { + margin-right: 8px; +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js new file mode 100644 index 000000000..57b53ff4e --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js @@ -0,0 +1,221 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { forEach } from 'Helpers/elementChildren'; +import { align, icons } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import Measure from 'Components/Measure'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; +import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; +import styles from './PageToolbarSection.css'; + +const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth); +const SEPARATOR_MARGIN = parseInt(dimensions.toolbarSeparatorMargin); +const SEPARATOR_WIDTH = 2 * SEPARATOR_MARGIN + 1; +const SEPARATOR_NAME = 'PageToolbarSeparator'; + +function calculateOverflowItems(children, isMeasured, width, collapseButtons) { + let buttonCount = 0; + let separatorCount = 0; + const validChildren = []; + + forEach(children, (child) => { + const name = child.type.name; + + if (name === SEPARATOR_NAME) { + separatorCount++; + } else { + buttonCount++; + } + + validChildren.push(child); + }); + + const buttonsWidth = buttonCount * BUTTON_WIDTH; + const separatorsWidth = separatorCount + SEPARATOR_WIDTH; + const totalWidth = buttonsWidth + separatorsWidth; + + // If the width of buttons and separators is less than + // the available width return all valid children. + + if ( + !isMeasured || + !collapseButtons || + totalWidth < width + ) { + return { + buttons: validChildren, + buttonCount, + overflowItems: [] + }; + } + + const maxButtons = Math.max(Math.floor((width - separatorsWidth) / BUTTON_WIDTH), 1); + const buttons = []; + const overflowItems = []; + let actualButtons = 0; + + // Return all buttons if only one is being pushed to the overflow menu. + if (buttonCount - 1 === maxButtons) { + return { + buttons: validChildren, + buttonCount, + overflowItems: [] + }; + } + + validChildren.forEach((child, index) => { + if (actualButtons < maxButtons) { + if (child.type.name !== SEPARATOR_NAME) { + buttons.push(child); + actualButtons++; + } + } else if (child.type.name !== SEPARATOR_NAME) { + overflowItems.push(child.props); + } + }); + + return { + buttons, + buttonCount, + overflowItems + }; +} + +class PageToolbarSection extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMeasured: false, + width: 0, + buttons: [], + overflowItems: [] + }; + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.setState({ + isMeasured: true, + width + }); + } + + // + // Render + + render() { + const { + children, + alignContent, + collapseButtons + } = this.props; + + const { + isMeasured, + width + } = this.state; + + const { + buttons, + buttonCount, + overflowItems + } = calculateOverflowItems(children, isMeasured, width, collapseButtons); + + return ( + +
+ { + isMeasured ? +
+ { + buttons.map((button) => { + return button; + }) + } + + { + !!overflowItems.length && + + + + + { + overflowItems.map((item) => { + const { + iconName, + spinningName, + label, + isDisabled, + isSpinning, + ...otherProps + } = item; + + return ( + + + {label} + + ); + }) + } + + + } +
: + null + } +
+
+ ); + } + +} + +PageToolbarSection.propTypes = { + children: PropTypes.node, + alignContent: PropTypes.oneOf([align.LEFT, align.CENTER, align.RIGHT]), + collapseButtons: PropTypes.bool.isRequired +}; + +PageToolbarSection.defaultProps = { + alignContent: align.LEFT, + collapseButtons: true +}; + +export default PageToolbarSection; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css new file mode 100644 index 000000000..968673593 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css @@ -0,0 +1,12 @@ +.separator { + margin: 10px $toolbarSeparatorMargin; + height: 40px; + border-right: 1px solid #e5e5e5; + opacity: 0.35; +} + +@media only screen and (max-width: $breakpointSmall) { + .separator { + margin: 10px 5px; + } +} diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js new file mode 100644 index 000000000..754248f99 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js @@ -0,0 +1,17 @@ +import React, { Component } from 'react'; +import styles from './PageToolbarSeparator.css'; + +class PageToolbarSeparator extends Component { + + // + // Render + + render() { + return ( +
+ ); + } + +} + +export default PageToolbarSeparator; diff --git a/frontend/src/Components/ProgressBar.css b/frontend/src/Components/ProgressBar.css new file mode 100644 index 000000000..2f0019043 --- /dev/null +++ b/frontend/src/Components/ProgressBar.css @@ -0,0 +1,93 @@ +.container { + position: relative; + overflow: hidden; + width: 100%; + border-radius: 4px; + background-color: #f5f5f5; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.progressBar { + position: relative; + z-index: 1; + float: left; + width: 0; + height: 100%; + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); + color: $white; + transition: width 0.6s ease; +} + +.frontTextContainer { + z-index: 1; + color: $white; +} + +.backTextContainer, +.frontTextContainer { + position: absolute; + overflow: hidden; + width: 0; + height: 100%; +} + +.backText, +.frontText { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 12px; + cursor: default; +} + +.primary { + background-color: $primaryColor; +} + +.danger { + background-color: $dangerColor; +} + +.success { + background-color: $successColor; +} + +.purple { + background-color: $purple; +} + +.warning { + background-color: $warningColor; +} + +.info { + background-color: $infoColor; +} + +.small { + height: $progressBarSmallHeight; + + .backText, + .frontText { + height: $progressBarSmallHeight; + } +} + +.medium { + height: $progressBarMediumHeight; + + .backText, + .frontText { + height: $progressBarMediumHeight; + } +} + +.large { + height: $progressBarLargeHeight; + + .backText, + .frontText { + height: $progressBarLargeHeight; + } +} diff --git a/frontend/src/Components/ProgressBar.js b/frontend/src/Components/ProgressBar.js new file mode 100644 index 000000000..4f457d558 --- /dev/null +++ b/frontend/src/Components/ProgressBar.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { kinds, sizes } from 'Helpers/Props'; +import styles from './ProgressBar.css'; + +function ProgressBar(props) { + const { + className, + containerClassName, + title, + progress, + precision, + showText, + text, + kind, + size, + width + } = props; + + const progressPercent = `${progress.toFixed(precision)}%`; + const progressText = text || progressPercent; + const actualWidth = width ? `${width}px` : '100%'; + + return ( +
+ { + showText && !!width && +
+
+
+ {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..8b7b91fe2 --- /dev/null +++ b/frontend/src/Components/SignalRConnector.js @@ -0,0 +1,374 @@ +import $ from 'jquery'; +import 'signalr'; +import PropTypes from 'prop-types'; +import { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { repopulatePage } from 'Utilities/pagePopulator'; +import titleCase from 'Utilities/String/titleCase'; +import { fetchCommands, updateCommand, finishCommand } from 'Store/Actions/commandActions'; +import { setAppValue, setVersion } from 'Store/Actions/appActions'; +import { update, updateItem, removeItem } from 'Store/Actions/baseActions'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions'; +import { fetchTags, fetchTagDetails } from 'Store/Actions/tagActions'; + +function getState(status) { + switch (status) { + case 0: + return 'connecting'; + case 1: + return 'connected'; + case 2: + return 'reconnecting'; + case 4: + return 'disconnected'; + default: + throw new Error(`invalid status ${status}`); + } +} + +function isAppDisconnected(disconnectedTime) { + if (!disconnectedTime) { + return false; + } + + return Math.floor(new Date().getTime() / 1000) - disconnectedTime > 180; +} + +function getHandlerName(name) { + name = titleCase(name); + name = name.replace('/', ''); + + return `handle${name}`; +} + +function createMapStateToProps() { + return createSelector( + (state) => state.app.isReconnecting, + (state) => state.app.isDisconnected, + (state) => state.queue.paged.isPopulated, + (isReconnecting, isDisconnected, isQueuePopulated) => { + return { + isReconnecting, + isDisconnected, + isQueuePopulated + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchCommands: fetchCommands, + dispatchUpdateCommand: updateCommand, + dispatchFinishCommand: finishCommand, + dispatchSetAppValue: setAppValue, + dispatchSetVersion: setVersion, + dispatchUpdate: update, + dispatchUpdateItem: updateItem, + dispatchRemoveItem: removeItem, + dispatchFetchHealth: fetchHealth, + dispatchFetchQueue: fetchQueue, + dispatchFetchQueueDetails: fetchQueueDetails, + dispatchFetchTags: fetchTags, + dispatchFetchTagDetails: fetchTagDetails +}; + +class SignalRConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.signalRconnectionOptions = { transport: ['webSockets', 'longPolling'] }; + this.signalRconnection = null; + this.retryInterval = 1; + this.retryTimeoutId = null; + this.disconnectedTime = null; + } + + componentDidMount() { + console.log('Starting signalR'); + + this.signalRconnection = $.connection('/signalr', { apiKey: window.Sonarr.apiKey }); + + this.signalRconnection.stateChanged(this.onStateChanged); + this.signalRconnection.received(this.onReceived); + this.signalRconnection.reconnecting(this.onReconnecting); + this.signalRconnection.disconnected(this.onDisconnected); + + this.signalRconnection.start(this.signalRconnectionOptions); + } + + componentWillUnmount() { + if (this.retryTimeoutId) { + this.retryTimeoutId = clearTimeout(this.retryTimeoutId); + } + + this.signalRconnection.stop(); + this.signalRconnection = null; + } + + // + // Control + + retryConnection = () => { + if (isAppDisconnected(this.disconnectedTime)) { + this.setState({ + isDisconnected: true + }); + } + + this.retryTimeoutId = setTimeout(() => { + if (!this.signalRconnection) { + console.error('signalR: Connection was disposed'); + return; + } + + this.signalRconnection.start(this.signalRconnectionOptions); + this.retryInterval = Math.min(this.retryInterval + 1, 10); + }, this.retryInterval * 1000); + } + + handleMessage = (message) => { + const { + name, + body + } = message; + + const handler = this[getHandlerName(name)]; + + if (handler) { + handler(body); + return; + } + + console.error(`signalR: Unable to find handler for ${name}`); + } + + handleCalendar = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'calendar', + updateOnly: true, + ...body.resource + }); + } + } + + handleCommand = (body) => { + if (body.action === 'sync') { + this.props.dispatchFetchCommands(); + return; + } + + const resource = body.resource; + const status = resource.status; + + // Both sucessful and failed commands need to be + // completed, otherwise they spin until they timeout. + + if (status === 'completed' || status === 'failed') { + this.props.dispatchFinishCommand(resource); + } else { + this.props.dispatchUpdateCommand(resource); + } + } + + handleEpisode = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'episodes', + updateOnly: true, + ...body.resource + }); + } + } + + handleEpisodefile = (body) => { + const section = 'episodeFiles'; + + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ section, ...body.resource }); + + // Repopulate the page to handle recently imported file + repopulatePage('episodeFileUpdated'); + } else if (body.action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: body.resource.id }); + } + } + + handleHealth = () => { + this.props.dispatchFetchHealth(); + } + + handleSeries = (body) => { + const action = body.action; + const section = 'series'; + + if (action === 'updated') { + this.props.dispatchUpdateItem({ section, ...body.resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: body.resource.id }); + } + } + + handleQueue = () => { + if (this.props.isQueuePopulated) { + this.props.dispatchFetchQueue(); + } + } + + handleQueueDetails = () => { + this.props.dispatchFetchQueueDetails(); + } + + handleQueueStatus = (body) => { + this.props.dispatchUpdate({ section: 'queue.status', data: body.resource }); + } + + handleVersion = (body) => { + const version = body.Version; + + this.props.dispatchSetVersion({ version }); + } + + handleWantedCutoff = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'cutoffUnmet', + updateOnly: true, + ...body.resource + }); + } + } + + handleWantedMissing = (body) => { + if (body.action === 'updated') { + this.props.dispatchUpdateItem({ + section: 'missing', + updateOnly: true, + ...body.resource + }); + } + } + + handleSystemTask = () => { + // No-op for now, we may want this later + } + + handleTag = (body) => { + if (body.action === 'sync') { + this.props.dispatchFetchTags(); + this.props.dispatchFetchTagDetails(); + return; + } + } + + // + // Listeners + + onStateChanged = (change) => { + const state = getState(change.newState); + console.log(`signalR: ${state}`); + + if (state === 'connected') { + // Clear disconnected time + this.disconnectedTime = null; + + const { + dispatchFetchCommands, + dispatchSetAppValue + } = this.props; + + // Repopulate the page (if a repopulator is set) to ensure things + // are in sync after reconnecting. + + if (this.props.isReconnecting || this.props.isDisconnected) { + dispatchFetchCommands(); + repopulatePage(); + } + + dispatchSetAppValue({ + isConnected: true, + isReconnecting: false, + isDisconnected: false, + isRestarting: false + }); + + this.retryInterval = 5; + + if (this.retryTimeoutId) { + clearTimeout(this.retryTimeoutId); + } + } + } + + onReceived = (message) => { + console.debug('signalR: received', message.name, message.body); + + this.handleMessage(message); + } + + onReconnecting = () => { + if (window.Sonarr.unloading) { + return; + } + + if (!this.disconnectedTime) { + this.disconnectedTime = Math.floor(new Date().getTime() / 1000); + } + + this.props.dispatchSetAppValue({ + isReconnecting: true + }); + } + + onDisconnected = () => { + if (window.Sonarr.unloading) { + return; + } + + if (!this.disconnectedTime) { + this.disconnectedTime = Math.floor(new Date().getTime() / 1000); + } + + this.props.dispatchSetAppValue({ + isConnected: false, + isReconnecting: true, + isDisconnected: isAppDisconnected(this.disconnectedTime) + }); + + this.retryConnection(); + } + + // + // Render + + render() { + return null; + } +} + +SignalRConnector.propTypes = { + isReconnecting: PropTypes.bool.isRequired, + isDisconnected: PropTypes.bool.isRequired, + isQueuePopulated: PropTypes.bool.isRequired, + dispatchFetchCommands: PropTypes.func.isRequired, + dispatchUpdateCommand: PropTypes.func.isRequired, + dispatchFinishCommand: PropTypes.func.isRequired, + dispatchSetAppValue: PropTypes.func.isRequired, + dispatchSetVersion: PropTypes.func.isRequired, + dispatchUpdate: PropTypes.func.isRequired, + dispatchUpdateItem: PropTypes.func.isRequired, + dispatchRemoveItem: PropTypes.func.isRequired, + dispatchFetchHealth: PropTypes.func.isRequired, + dispatchFetchQueue: PropTypes.func.isRequired, + dispatchFetchQueueDetails: PropTypes.func.isRequired, + dispatchFetchTags: PropTypes.func.isRequired, + dispatchFetchTagDetails: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SignalRConnector); diff --git a/frontend/src/Components/SpinnerIcon.js b/frontend/src/Components/SpinnerIcon.js new file mode 100644 index 000000000..d21674d9e --- /dev/null +++ b/frontend/src/Components/SpinnerIcon.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from './Icon'; + +function SpinnerIcon(props) { + const { + name, + spinningName, + isSpinning, + ...otherProps + } = props; + + return ( + + ); +} + +SpinnerIcon.propTypes = { + name: PropTypes.object.isRequired, + spinningName: PropTypes.object.isRequired, + isSpinning: PropTypes.bool.isRequired +}; + +SpinnerIcon.defaultProps = { + spinningName: icons.SPINNER +}; + +export default SpinnerIcon; diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.css b/frontend/src/Components/Table/Cells/RelativeDateCell.css new file mode 100644 index 000000000..7be20ce5d --- /dev/null +++ b/frontend/src/Components/Table/Cells/RelativeDateCell.css @@ -0,0 +1,5 @@ +.cell { + composes: cell from './TableRowCell.css'; + + width: 180px; +} diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.js b/frontend/src/Components/Table/Cells/RelativeDateCell.js new file mode 100644 index 000000000..93004b447 --- /dev/null +++ b/frontend/src/Components/Table/Cells/RelativeDateCell.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import TableRowCell from './TableRowCell'; +import styles from './RelativeDateCell.css'; + +function RelativeDateCell(props) { + const { + className, + date, + includeSeconds, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + component: Component, + dispatch, + ...otherProps + } = props; + + if (!date) { + return ( + + ); + } + + return ( + + {getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds, timeForToday: true })} + + ); +} + +RelativeDateCell.propTypes = { + className: PropTypes.string.isRequired, + date: PropTypes.string, + includeSeconds: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + component: PropTypes.func, + dispatch: PropTypes.func +}; + +RelativeDateCell.defaultProps = { + className: styles.cell, + includeSeconds: false, + component: TableRowCell +}; + +export default RelativeDateCell; diff --git a/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js new file mode 100644 index 000000000..ed996abbe --- /dev/null +++ b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import RelativeDateCell from './RelativeDateCell'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return _.pick(uiSettings, [ + 'showRelativeDates', + 'shortDateFormat', + 'longDateFormat', + 'timeFormat' + ]); + } + ); +} + +export default connect(createMapStateToProps, null)(RelativeDateCell); diff --git a/frontend/src/Components/Table/Cells/TableRowCell.css b/frontend/src/Components/Table/Cells/TableRowCell.css new file mode 100644 index 000000000..1c3e6fc5a --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCell.css @@ -0,0 +1,11 @@ +.cell { + padding: 8px; + border-top: 1px solid #eee; + line-height: 1.52857143; +} + +@media only screen and (max-width: $breakpointSmall) { + .cell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/Cells/TableRowCell.js b/frontend/src/Components/Table/Cells/TableRowCell.js new file mode 100644 index 000000000..f66bbf3aa --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCell.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './TableRowCell.css'; + +class TableRowCell extends Component { + + // + // Render + + render() { + const { + className, + children, + ...otherProps + } = this.props; + + return ( + + {children} + + ); + } +} + +TableRowCell.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.node]) +}; + +TableRowCell.defaultProps = { + className: styles.cell +}; + +export default TableRowCell; diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.css b/frontend/src/Components/Table/Cells/TableRowCellButton.css new file mode 100644 index 000000000..f01e7cba6 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCellButton.css @@ -0,0 +1,4 @@ +.cell { + composes: cell from './TableRowCell.css'; + composes: link from 'Components/Link/Link.css'; +} diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.js b/frontend/src/Components/Table/Cells/TableRowCellButton.js new file mode 100644 index 000000000..ff50d3bc9 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableRowCellButton.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Link from 'Components/Link/Link'; +import TableRowCell from './TableRowCell'; +import styles from './TableRowCellButton.css'; + +function TableRowCellButton({ className, ...otherProps }) { + return ( + + ); +} + +TableRowCellButton.propTypes = { + className: PropTypes.string.isRequired +}; + +TableRowCellButton.defaultProps = { + className: styles.cell +}; + +export default TableRowCellButton; diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.css b/frontend/src/Components/Table/Cells/TableSelectCell.css new file mode 100644 index 000000000..21ab944d7 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableSelectCell.css @@ -0,0 +1,11 @@ +.selectCell { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 30px; +} + +.input { + composes: input from 'Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.js b/frontend/src/Components/Table/Cells/TableSelectCell.js new file mode 100644 index 000000000..9c10f4444 --- /dev/null +++ b/frontend/src/Components/Table/Cells/TableSelectCell.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import TableRowCell from './TableRowCell'; +import styles from './TableSelectCell.css'; + +class TableSelectCell extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + id, + isSelected, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: isSelected }); + } + + componentWillUnmount() { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: null }); + } + + // + // Listeners + + onChange = ({ value, shiftKey }, a, b, c, d) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + className, + id, + isSelected, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +TableSelectCell.propTypes = { + className: PropTypes.string.isRequired, + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + isSelected: PropTypes.bool.isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +TableSelectCell.defaultProps = { + className: styles.selectCell, + isSelected: false +}; + +export default TableSelectCell; diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.css b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css new file mode 100644 index 000000000..e4cffe1c4 --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css @@ -0,0 +1,14 @@ +.cell { + @add-mixin truncate; + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + flex-grow: 0; + flex-shrink: 1; + white-space: nowrap; +} + +@media only screen and (max-width: $breakpointSmall) { + .cell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.js b/frontend/src/Components/Table/Cells/VirtualTableRowCell.js new file mode 100644 index 000000000..42999216f --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './VirtualTableRowCell.css'; + +function VirtualTableRowCell(props) { + const { + className, + children + } = props; + + return ( +
+ {children} +
+ ); +} + +VirtualTableRowCell.propTypes = { + className: PropTypes.string.isRequired, + children: PropTypes.oneOfType([PropTypes.string, PropTypes.node]) +}; + +VirtualTableRowCell.defaultProps = { + className: styles.cell +}; + +export default VirtualTableRowCell; diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css new file mode 100644 index 000000000..e1016aa8a --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.css @@ -0,0 +1,11 @@ +.cell { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + flex: 0 0 36px; +} + +.input { + composes: input from 'Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js new file mode 100644 index 000000000..a773aab58 --- /dev/null +++ b/frontend/src/Components/Table/Cells/VirtualTableSelectCell.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableRowCell from './VirtualTableRowCell'; +import styles from './VirtualTableSelectCell.css'; + +export function virtualTableSelectCellRenderer(cellProps) { + const { + cellKey, + rowData, + columnData, + ...otherProps + } = cellProps; + + return ( + + ); +} + +class VirtualTableSelectCell extends Component { + + // + // Listeners + + onChange = ({ value, shiftKey }) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + inputClassName, + id, + isSelected, + isDisabled, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +VirtualTableSelectCell.propTypes = { + inputClassName: PropTypes.string.isRequired, + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + isSelected: PropTypes.bool.isRequired, + isDisabled: PropTypes.bool.isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +VirtualTableSelectCell.defaultProps = { + inputClassName: styles.input, + isSelected: false +}; + +export default VirtualTableSelectCell; diff --git a/frontend/src/Components/Table/Table.css b/frontend/src/Components/Table/Table.css new file mode 100644 index 000000000..46d49826a --- /dev/null +++ b/frontend/src/Components/Table/Table.css @@ -0,0 +1,16 @@ +.tableContainer { + overflow-x: auto; +} + +.table { + max-width: 100%; + width: 100%; + border-collapse: collapse; +} + +@media only screen and (max-width: $breakpointSmall) { + .tableContainer { + overflow-y: hidden; + width: 100%; + } +} diff --git a/frontend/src/Components/Table/Table.js b/frontend/src/Components/Table/Table.js new file mode 100644 index 000000000..5cfbc4c8f --- /dev/null +++ b/frontend/src/Components/Table/Table.js @@ -0,0 +1,160 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, scrollDirections } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import Scroller from 'Components/Scroller/Scroller'; +import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal'; +import TableHeader from './TableHeader'; +import TableHeaderCell from './TableHeaderCell'; +import TableSelectAllHeaderCell from './TableSelectAllHeaderCell'; +import styles from './Table.css'; + +const tableHeaderCellProps = [ + 'sortKey', + 'sortDirection' +]; + +function getTableHeaderCellProps(props) { + return _.reduce(tableHeaderCellProps, (result, key) => { + if (props.hasOwnProperty(key)) { + result[key] = props[key]; + } + + return result; + }, {}); +} + +class Table extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isTableOptionsModalOpen: false + }; + } + + // + // Listeners + + onTableOptionsPress = () => { + this.setState({ isTableOptionsModalOpen: true }); + } + + onTableOptionsModalClose = () => { + this.setState({ isTableOptionsModalOpen: false }); + } + + // + // Render + + render() { + const { + className, + selectAll, + columns, + optionsComponent, + pageSize, + canModifyColumns, + children, + onSortPress, + onTableOptionChange, + ...otherProps + } = this.props; + + return ( + + + + { + selectAll && + + } + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if ((name === 'actions' || name === 'details') && onTableOptionChange) { + return ( + + + + ); + } + + return ( + + {column.label} + + ); + }) + } + + { + !!onTableOptionChange && + + } + + + {children} +
+
+ ); + } +} + +Table.propTypes = { + className: PropTypes.string, + selectAll: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + optionsComponent: PropTypes.func, + pageSize: PropTypes.number, + canModifyColumns: PropTypes.bool, + children: PropTypes.node, + onSortPress: PropTypes.func, + onTableOptionChange: PropTypes.func +}; + +Table.defaultProps = { + className: styles.table, + selectAll: false +}; + +export default Table; diff --git a/frontend/src/Components/Table/TableBody.js b/frontend/src/Components/Table/TableBody.js new file mode 100644 index 000000000..5cc60d6f4 --- /dev/null +++ b/frontend/src/Components/Table/TableBody.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; + +class TableBody extends Component { + + // + // Render + + render() { + const { + children + } = this.props; + + return ( + {children} + ); + } + +} + +TableBody.propTypes = { + children: PropTypes.node +}; + +export default TableBody; diff --git a/frontend/src/Components/Table/TableHeader.js b/frontend/src/Components/Table/TableHeader.js new file mode 100644 index 000000000..81943e919 --- /dev/null +++ b/frontend/src/Components/Table/TableHeader.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; + +class TableHeader extends Component { + + // + // Render + + render() { + const { + children + } = this.props; + + return ( + + + {children} + + + ); + } +} + +TableHeader.propTypes = { + children: PropTypes.node +}; + +export default TableHeader; diff --git a/frontend/src/Components/Table/TableHeaderCell.css b/frontend/src/Components/Table/TableHeaderCell.css new file mode 100644 index 000000000..c2c4f58c8 --- /dev/null +++ b/frontend/src/Components/Table/TableHeaderCell.css @@ -0,0 +1,16 @@ +.headerCell { + padding: 8px; + border: none !important; + text-align: left; + font-weight: bold; +} + +.sortIcon { + margin-left: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .headerCell { + white-space: nowrap; + } +} diff --git a/frontend/src/Components/Table/TableHeaderCell.js b/frontend/src/Components/Table/TableHeaderCell.js new file mode 100644 index 000000000..73c4b7ec2 --- /dev/null +++ b/frontend/src/Components/Table/TableHeaderCell.js @@ -0,0 +1,94 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, sortDirections } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import Icon from 'Components/Icon'; +import styles from './TableHeaderCell.css'; + +class TableHeaderCell extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + fixedSortDirection + } = this.props; + + if (fixedSortDirection) { + this.props.onSortPress(name, fixedSortDirection); + } else { + this.props.onSortPress(name); + } + } + + // + // Render + + render() { + const { + className, + name, + isSortable, + isVisible, + isModifiable, + sortKey, + sortDirection, + fixedSortDirection, + children, + onSortPress, + ...otherProps + } = this.props; + + const isSorting = isSortable && sortKey === name; + const sortIcon = sortDirection === sortDirections.ASCENDING ? + icons.SORT_ASCENDING : + icons.SORT_DESCENDING; + + return ( + isSortable ? + + {children} + + { + isSorting && + + } + : + + + {children} + + ); + } +} + +TableHeaderCell.propTypes = { + className: PropTypes.string, + name: PropTypes.string.isRequired, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + isSortable: PropTypes.bool, + isVisible: PropTypes.bool, + isModifiable: PropTypes.bool, + sortKey: PropTypes.string, + fixedSortDirection: PropTypes.string, + sortDirection: PropTypes.string, + children: PropTypes.node, + onSortPress: PropTypes.func +}; + +TableHeaderCell.defaultProps = { + className: styles.headerCell, + isSortable: false +}; + +export default TableHeaderCell; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css new file mode 100644 index 000000000..204773c3d --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.css @@ -0,0 +1,48 @@ +.column { + display: flex; + align-items: stretch; + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; +} + +.checkContainer { + position: relative; + margin-right: 4px; + margin-bottom: 7px; + margin-left: 8px; +} + +.label { + display: flex; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; + line-height: 36px; + cursor: pointer; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.notDragable { + padding: 4px 0; +} diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js new file mode 100644 index 000000000..a986be615 --- /dev/null +++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './TableOptionsColumn.css'; + +function TableOptionsColumn(props) { + const { + name, + label, + isVisible, + isModifiable, + isDragging, + connectDragSource, + onVisibleChange + } = props; + + return ( +
+
+ + + { + !!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/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..f43139e4a --- /dev/null +++ b/frontend/src/Components/keyboardShortcuts.js @@ -0,0 +1,102 @@ +import React, { Component } from 'react'; +import Mousetrap from 'mousetrap'; +import getDisplayName from 'Helpers/getDisplayName'; + +export const shortcuts = { + OPEN_KEYBOARD_SHORTCUTS_MODAL: { + key: '?', + name: 'Open This Modal' + }, + + SERIES_SEARCH_INPUT: { + key: 's', + name: 'Focus Search Box' + }, + + SAVE_SETTINGS: { + key: 'mod+s', + name: 'Save Settings' + } +}; + +function keyboardShortcuts(WrappedComponent) { + class KeyboardShortcuts extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + this._mousetrapBindings = {}; + this._mousetrap = new Mousetrap(); + this._mousetrap.stopCallback = this.stopCallback; + } + + componentWillUnmount() { + this.unbindAllShortcuts(); + this._mousetrap = null; + } + + // + // Control + + bindShortcut = (key, callback, options = {}) => { + this._mousetrap.bind(key, callback); + this._mousetrapBindings[key] = options; + } + + unbindShortcut = (key) => { + delete this._mousetrapBindings[key]; + this._mousetrap.unbind(key); + } + + unbindAllShortcuts = () => { + const keys = Object.keys(this._mousetrapBindings); + + if (!keys.length) { + return; + } + + keys.forEach((binding) => { + this._mousetrap.unbind(binding); + }); + + this._mousetrapBindings = {}; + } + + stopCallback = (event, element, combo) => { + const binding = this._mousetrapBindings[combo]; + + if (!binding || binding.isGlobal) { + return false; + } + + return ( + element.tagName === 'INPUT' || + element.tagName === 'SELECT' || + element.tagName === 'TEXTAREA' || + (element.contentEditable && element.contentEditable === 'true') + ); + } + + // + // Render + + render() { + return ( + + ); + } + } + + KeyboardShortcuts.displayName = `KeyboardShortcut(${getDisplayName(WrappedComponent)})`; + KeyboardShortcuts.WrappedComponent = WrappedComponent; + + return KeyboardShortcuts; +} + +export default keyboardShortcuts; diff --git a/frontend/src/Components/withCurrentPage.js b/frontend/src/Components/withCurrentPage.js new file mode 100644 index 000000000..5e6d9ccf4 --- /dev/null +++ b/frontend/src/Components/withCurrentPage.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +function withCurrentPage(WrappedComponent) { + function CurrentPage(props) { + const { + history + } = props; + + return ( + + ); + } + + CurrentPage.propTypes = { + history: PropTypes.object.isRequired + }; + + return CurrentPage; +} + +export default withCurrentPage; diff --git a/frontend/src/Components/withScrollPosition.js b/frontend/src/Components/withScrollPosition.js new file mode 100644 index 000000000..110da9ab2 --- /dev/null +++ b/frontend/src/Components/withScrollPosition.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import scrollPositions from 'Store/scrollPositions'; + +function withScrollPosition(WrappedComponent, scrollPositionKey) { + function ScrollPosition(props) { + const { + history + } = props; + + const scrollTop = history.action === 'POP' ? + scrollPositions[scrollPositionKey] : + 0; + + return ( + + ); + } + + ScrollPosition.propTypes = { + history: PropTypes.object.isRequired + }; + + return ScrollPosition; +} + +export default withScrollPosition; diff --git a/frontend/src/Content/Fonts/Roboto-Light.ttf b/frontend/src/Content/Fonts/Roboto-Light.ttf new file mode 100644 index 000000000..94c6bcc67 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.ttf differ diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff b/frontend/src/Content/Fonts/Roboto-Light.woff new file mode 100644 index 000000000..ec6bf5749 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.woff differ diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff2 b/frontend/src/Content/Fonts/Roboto-Light.woff2 new file mode 100644 index 000000000..288201788 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.woff2 differ diff --git a/frontend/src/Content/Fonts/Roboto-Regular.ttf b/frontend/src/Content/Fonts/Roboto-Regular.ttf new file mode 100644 index 000000000..8c082c8de Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.ttf differ diff --git a/frontend/src/Content/Fonts/Roboto-Regular.woff b/frontend/src/Content/Fonts/Roboto-Regular.woff new file mode 100644 index 000000000..464d20623 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.woff differ diff --git a/frontend/src/Content/Fonts/Roboto-Regular.woff2 b/frontend/src/Content/Fonts/Roboto-Regular.woff2 new file mode 100644 index 000000000..f96619675 Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.woff2 differ diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.eot b/frontend/src/Content/Fonts/UbuntuMono-Regular.eot new file mode 100644 index 000000000..7a03fb512 Binary files /dev/null and b/frontend/src/Content/Fonts/UbuntuMono-Regular.eot differ diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf b/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf new file mode 100644 index 000000000..fdd309d71 Binary files /dev/null and b/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf differ diff --git a/frontend/src/Content/Fonts/UbuntuMono-Regular.woff b/frontend/src/Content/Fonts/UbuntuMono-Regular.woff new file mode 100644 index 000000000..0289699c0 Binary files /dev/null and b/frontend/src/Content/Fonts/UbuntuMono-Regular.woff differ diff --git a/frontend/src/Content/Fonts/fonts.css b/frontend/src/Content/Fonts/fonts.css new file mode 100644 index 000000000..3db8ae6b0 --- /dev/null +++ b/frontend/src/Content/Fonts/fonts.css @@ -0,0 +1,38 @@ +@font-face { + font-weight: 300; + font-style: normal; + font-family: 'Roboto'; + src: url('Roboto-Light.woff2?v=1.1.0') format('woff2'), url('Roboto-Light.woff?v=1.2.0') format('woff'), url('Roboto-Light.ttf?v=1.1.0') format('truetype'); +} + +@font-face { + font-weight: 400; + font-style: normal; + font-family: 'Roboto'; + src: url('Roboto-Regular.woff2?v=1.2.0') format('woff2'), url('Roboto-Regular.woff?v=1.2.0') format('woff'), url('Roboto-Regular.ttf?v=1.1.0') format('truetype'); +} + +@font-face { + font-weight: normal; + font-style: normal; + font-family: 'Roboto'; + src: url('Roboto-Regular.woff2?v=1.3.0') format('woff2'), url('Roboto-Regular.woff?v=1.2.0') format('woff'), url('Roboto-Regular.ttf?v=1.1.0') format('truetype'); +} + +@font-face { + font-weight: 400; + font-style: normal; + font-family: 'Ubuntu Mono'; + src: url('UbuntuMono-Regular.eot?#iefix') format('embedded-opentype'), url('UbuntuMono-Regular.woff') format('woff'), url('UbuntuMono-Regular.ttf') format('truetype'); +} + +/* + * text-security-disc + */ + +@font-face { + font-weight: normal; + font-style: normal; + font-family: 'text-security-disc'; + src: url('text-security-disc.woff') format('woff'), url('text-security-disc.ttf') format('truetype'); +} diff --git a/frontend/src/Content/Fonts/text-security-disc.ttf b/frontend/src/Content/Fonts/text-security-disc.ttf new file mode 100644 index 000000000..86038dba8 Binary files /dev/null and b/frontend/src/Content/Fonts/text-security-disc.ttf differ diff --git a/frontend/src/Content/Fonts/text-security-disc.woff b/frontend/src/Content/Fonts/text-security-disc.woff new file mode 100644 index 000000000..bc4cc324b Binary files /dev/null and b/frontend/src/Content/Fonts/text-security-disc.woff differ diff --git a/frontend/src/Content/Images/404.png b/frontend/src/Content/Images/404.png new file mode 100644 index 000000000..deeb83f8f Binary files /dev/null and b/frontend/src/Content/Images/404.png differ diff --git a/frontend/src/Content/Images/Icons/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/android-chrome-192x192.png new file mode 100644 index 000000000..3b133f3a6 Binary files /dev/null and b/frontend/src/Content/Images/Icons/android-chrome-192x192.png differ diff --git a/frontend/src/Content/Images/Icons/android-chrome-512x512.png b/frontend/src/Content/Images/Icons/android-chrome-512x512.png new file mode 100644 index 000000000..9a117be5a Binary files /dev/null and b/frontend/src/Content/Images/Icons/android-chrome-512x512.png differ diff --git a/frontend/src/Content/Images/Icons/apple-touch-icon.png b/frontend/src/Content/Images/Icons/apple-touch-icon.png new file mode 100644 index 000000000..1986ca676 Binary files /dev/null and b/frontend/src/Content/Images/Icons/apple-touch-icon.png differ diff --git a/frontend/src/Content/Images/Icons/browserconfig.xml b/frontend/src/Content/Images/Icons/browserconfig.xml new file mode 100644 index 000000000..993924968 --- /dev/null +++ b/frontend/src/Content/Images/Icons/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #00ccff + + + diff --git a/frontend/src/Content/Images/Icons/favicon-16x16.png b/frontend/src/Content/Images/Icons/favicon-16x16.png new file mode 100644 index 000000000..94e3bc54e Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/favicon-32x32.png b/frontend/src/Content/Images/Icons/favicon-32x32.png new file mode 100644 index 000000000..52da982d8 Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/favicon-debug-16x16.png b/frontend/src/Content/Images/Icons/favicon-debug-16x16.png new file mode 100644 index 000000000..884285bca Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug-16x16.png differ diff --git a/frontend/src/Content/Images/Icons/favicon-debug-32x32.png b/frontend/src/Content/Images/Icons/favicon-debug-32x32.png new file mode 100644 index 000000000..5d3557ea8 Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug-32x32.png differ diff --git a/frontend/src/Content/Images/Icons/favicon-debug.ico b/frontend/src/Content/Images/Icons/favicon-debug.ico new file mode 100644 index 000000000..9f80388f3 Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug.ico differ diff --git a/frontend/src/Content/Images/Icons/favicon.ico b/frontend/src/Content/Images/Icons/favicon.ico new file mode 100644 index 000000000..3f63c9d50 Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon.ico differ diff --git a/frontend/src/Content/Images/Icons/manifest.json b/frontend/src/Content/Images/Icons/manifest.json new file mode 100644 index 000000000..d14732f60 --- /dev/null +++ b/frontend/src/Content/Images/Icons/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "", + "icons": [ + { + "src": "/Content/Images/Icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/Content/Images/Icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#3a3f51", + "background_color": "#3a3f51", + "display": "standalone" +} \ No newline at end of file diff --git a/frontend/src/Content/Images/Icons/mstile-144x144.png b/frontend/src/Content/Images/Icons/mstile-144x144.png new file mode 100644 index 000000000..86be23dc4 Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-144x144.png differ diff --git a/frontend/src/Content/Images/Icons/mstile-150x150.png b/frontend/src/Content/Images/Icons/mstile-150x150.png new file mode 100644 index 000000000..4417102cc Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-150x150.png differ diff --git a/frontend/src/Content/Images/Icons/mstile-310x150.png b/frontend/src/Content/Images/Icons/mstile-310x150.png new file mode 100644 index 000000000..b6308fce5 Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-310x150.png differ diff --git a/frontend/src/Content/Images/Icons/mstile-310x310.png b/frontend/src/Content/Images/Icons/mstile-310x310.png new file mode 100644 index 000000000..dc2c2bf6c Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-310x310.png differ diff --git a/frontend/src/Content/Images/Icons/mstile-70x70.png b/frontend/src/Content/Images/Icons/mstile-70x70.png new file mode 100644 index 000000000..fc33b9fe2 Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-70x70.png differ diff --git a/frontend/src/Content/Images/Icons/safari-pinned-tab.svg b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg new file mode 100644 index 000000000..6fc7fb969 --- /dev/null +++ b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg @@ -0,0 +1,38 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + diff --git a/frontend/src/Content/Images/error.png b/frontend/src/Content/Images/error.png new file mode 100644 index 000000000..9b1ae7746 Binary files /dev/null and b/frontend/src/Content/Images/error.png differ diff --git a/frontend/src/Content/Images/logo.svg b/frontend/src/Content/Images/logo.svg new file mode 100644 index 000000000..b0a721893 --- /dev/null +++ b/frontend/src/Content/Images/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/Content/Images/poster-dark.png b/frontend/src/Content/Images/poster-dark.png new file mode 100644 index 000000000..6a88711c3 Binary files /dev/null and b/frontend/src/Content/Images/poster-dark.png differ diff --git a/frontend/src/Episode/EpisodeDetailsModal.js b/frontend/src/Episode/EpisodeDetailsModal.js new file mode 100644 index 000000000..b2f379808 --- /dev/null +++ b/frontend/src/Episode/EpisodeDetailsModal.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EpisodeDetailsModalContentConnector from './EpisodeDetailsModalContentConnector'; + +class EpisodeDetailsModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +EpisodeDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EpisodeDetailsModal; diff --git a/frontend/src/Episode/EpisodeDetailsModalContent.css b/frontend/src/Episode/EpisodeDetailsModalContent.css new file mode 100644 index 000000000..450cdce9d --- /dev/null +++ b/frontend/src/Episode/EpisodeDetailsModalContent.css @@ -0,0 +1,44 @@ +.seriesTitle { + margin-left: 5px; +} + +.separator { + margin: 0 5px; +} + +.tabs { + margin-top: -32px; +} + +.tabList { + margin: 0; + padding: 0; +} + +.tab { + position: relative; + bottom: -1px; + display: inline-block; + padding: 6px 12px; + border: 1px solid transparent; + border-top: none; + list-style: none; + cursor: pointer; +} + +.selectedTab { + border-color: $borderColor; + border-radius: 0 0 5px 5px; + background-color: rgba(239, 239, 239, 0.4); + color: $black; +} + +.tabContent { + margin-top: 20px; +} + +.openSeriesButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Episode/EpisodeDetailsModalContent.js b/frontend/src/Episode/EpisodeDetailsModalContent.js new file mode 100644 index 000000000..f1b612501 --- /dev/null +++ b/frontend/src/Episode/EpisodeDetailsModalContent.js @@ -0,0 +1,218 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; +import episodeEntities from 'Episode/episodeEntities'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import EpisodeSummaryConnector from './Summary/EpisodeSummaryConnector'; +import EpisodeHistoryConnector from './History/EpisodeHistoryConnector'; +import EpisodeSearchConnector from './Search/EpisodeSearchConnector'; +import SeasonEpisodeNumber from './SeasonEpisodeNumber'; +import styles from './EpisodeDetailsModalContent.css'; + +const tabs = [ + 'details', + 'history', + 'search' +]; + +class EpisodeDetailsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + selectedTab: props.selectedTab + }; + } + + // + // Listeners + + onTabSelect = (index, lastIndex) => { + this.setState({ selectedTab: tabs[index] }); + } + + // + // Render + + render() { + const { + episodeId, + episodeEntity, + episodeFileId, + seriesId, + seriesTitle, + titleSlug, + seriesMonitored, + seriesType, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + episodeTitle, + airDate, + monitored, + isSaving, + showOpenSeriesButton, + startInteractiveSearch, + onMonitorEpisodePress, + onModalClose + } = this.props; + + const seriesLink = `/series/${titleSlug}`; + + return ( + + + + + + {seriesTitle} + + + - + + + + - + + {episodeTitle} + + + + + + + Details + + + + History + + + + Search + + + + +
+ +
+
+ + +
+ +
+
+ + + {/* Don't wrap in tabContent so we not have a top margin */} + + +
+
+ + + { + showOpenSeriesButton && + + } + + + +
+ ); + } +} + +EpisodeDetailsModalContent.propTypes = { + episodeId: PropTypes.number.isRequired, + episodeEntity: PropTypes.string.isRequired, + episodeFileId: PropTypes.number, + seriesId: PropTypes.number.isRequired, + seriesTitle: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + seriesMonitored: PropTypes.bool.isRequired, + seriesType: PropTypes.string.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + airDate: PropTypes.string.isRequired, + episodeTitle: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + isSaving: PropTypes.bool, + showOpenSeriesButton: PropTypes.bool, + selectedTab: PropTypes.string.isRequired, + startInteractiveSearch: PropTypes.bool.isRequired, + onMonitorEpisodePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +EpisodeDetailsModalContent.defaultProps = { + selectedTab: 'details', + episodeEntity: episodeEntities.EPISODES, + startInteractiveSearch: false +}; + +export default EpisodeDetailsModalContent; diff --git a/frontend/src/Episode/EpisodeDetailsModalContentConnector.js b/frontend/src/Episode/EpisodeDetailsModalContentConnector.js new file mode 100644 index 000000000..5b378fbfb --- /dev/null +++ b/frontend/src/Episode/EpisodeDetailsModalContentConnector.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions'; +import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions'; +import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import episodeEntities from 'Episode/episodeEntities'; +import EpisodeDetailsModalContent from './EpisodeDetailsModalContent'; + +function createMapStateToProps() { + return createSelector( + createEpisodeSelector(), + createSeriesSelector(), + (episode, series) => { + const { + title: seriesTitle, + titleSlug, + monitored: seriesMonitored, + seriesType + } = series; + + return { + seriesTitle, + titleSlug, + seriesMonitored, + seriesType, + ...episode + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchCancelFetchReleases() { + dispatch(cancelFetchReleases()); + }, + + dispatchClearReleases() { + dispatch(clearReleases()); + }, + + onMonitorEpisodePress(monitored) { + const { + episodeId, + episodeEntity + } = props; + + dispatch(toggleEpisodeMonitored({ + episodeEntity, + episodeId, + monitored + })); + } + }; +} + +class EpisodeDetailsModalContentConnector extends Component { + + // + // Lifecycle + + componentWillUnmount() { + // Clear pending releases here so we can reshow the search + // results even after switching tabs. + + this.props.dispatchCancelFetchReleases(); + this.props.dispatchClearReleases(); + } + + // + // Render + + render() { + const { + dispatchClearReleases, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EpisodeDetailsModalContentConnector.propTypes = { + episodeId: PropTypes.number.isRequired, + episodeEntity: PropTypes.string.isRequired, + seriesId: PropTypes.number.isRequired, + dispatchCancelFetchReleases: PropTypes.func.isRequired, + dispatchClearReleases: PropTypes.func.isRequired +}; + +EpisodeDetailsModalContentConnector.defaultProps = { + episodeEntity: episodeEntities.EPISODES +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeDetailsModalContentConnector); diff --git a/frontend/src/Episode/EpisodeLanguage.js b/frontend/src/Episode/EpisodeLanguage.js new file mode 100644 index 000000000..52c8b3390 --- /dev/null +++ b/frontend/src/Episode/EpisodeLanguage.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Label from 'Components/Label'; +import { kinds } from 'Helpers/Props'; + +function EpisodeLanguage(props) { + const { + className, + language, + isCutoffNotMet + } = props; + + if (!language) { + return null; + } + + return ( + + ); +} + +EpisodeLanguage.propTypes = { + className: PropTypes.string, + language: PropTypes.object, + isCutoffNotMet: PropTypes.bool +}; + +EpisodeLanguage.defaultProps = { + isCutoffNotMet: true +}; + +export default EpisodeLanguage; diff --git a/frontend/src/Episode/EpisodeNumber.css b/frontend/src/Episode/EpisodeNumber.css new file mode 100644 index 000000000..1c5072d02 --- /dev/null +++ b/frontend/src/Episode/EpisodeNumber.css @@ -0,0 +1,7 @@ +.absoluteEpisodeNumber { + margin-left: 5px; +} + +.warning { + margin-left: 8px; +} diff --git a/frontend/src/Episode/EpisodeNumber.js b/frontend/src/Episode/EpisodeNumber.js new file mode 100644 index 000000000..f8183be8a --- /dev/null +++ b/frontend/src/Episode/EpisodeNumber.js @@ -0,0 +1,138 @@ +import PropTypes from 'prop-types'; +import React, { Fragment } from 'react'; +import padNumber from 'Utilities/Number/padNumber'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Popover from 'Components/Tooltip/Popover'; +import SceneInfo from './SceneInfo'; +import styles from './EpisodeNumber.css'; + +function getAlternateTitles(seasonNumber, sceneSeasonNumber, alternateTitles) { + return alternateTitles.filter((alternateTitle) => { + if (sceneSeasonNumber && sceneSeasonNumber === alternateTitle.sceneSeasonNumber) { + return true; + } + + return seasonNumber === alternateTitle.seasonNumber; + }); +} + +function EpisodeNumber(props) { + const { + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + unverifiedSceneNumbering, + alternateTitles: seriesAlternateTitles, + seriesType, + showSeasonNumber + } = props; + + const alternateTitles = getAlternateTitles(seasonNumber, sceneSeasonNumber, seriesAlternateTitles); + + const hasSceneInformation = sceneSeasonNumber !== undefined || + sceneEpisodeNumber !== undefined || + (seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined) || + !!alternateTitles.length; + + return ( + + { + hasSceneInformation ? + + { + showSeasonNumber && seasonNumber != null && + + {seasonNumber}x + + } + + {showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber} + + { + seriesType === 'anime' && !!absoluteEpisodeNumber && + + ({absoluteEpisodeNumber}) + + } + + } + title="Scene Information" + body={ + + } + position={tooltipPositions.RIGHT} + /> : + + { + showSeasonNumber && seasonNumber != null && + + {seasonNumber}x + + } + + {showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber} + + { + seriesType === 'anime' && !!absoluteEpisodeNumber && + + ({absoluteEpisodeNumber}) + + } + + } + + { + unverifiedSceneNumbering && + + } + + { + seriesType === 'anime' && !absoluteEpisodeNumber && + + } + + ); +} + +EpisodeNumber.propTypes = { + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + sceneSeasonNumber: PropTypes.number, + sceneEpisodeNumber: PropTypes.number, + sceneAbsoluteEpisodeNumber: PropTypes.number, + unverifiedSceneNumbering: PropTypes.bool.isRequired, + alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, + seriesType: PropTypes.string, + showSeasonNumber: PropTypes.bool.isRequired +}; + +EpisodeNumber.defaultProps = { + unverifiedSceneNumbering: false, + alternateTitles: [], + showSeasonNumber: false +}; + +export default EpisodeNumber; diff --git a/frontend/src/Episode/EpisodeQuality.js b/frontend/src/Episode/EpisodeQuality.js new file mode 100644 index 000000000..65a3f3dbc --- /dev/null +++ b/frontend/src/Episode/EpisodeQuality.js @@ -0,0 +1,57 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function getTooltip(title, quality, size) { + const revision = quality.revision; + + if (revision.real && revision.real > 0) { + title += ' [REAL]'; + } + + if (revision.version && revision.version > 1) { + title += ' [PROPER]'; + } + + if (size) { + title += ` - ${formatBytes(size)}`; + } + + return title; +} + +function EpisodeQuality(props) { + const { + className, + title, + quality, + size, + isCutoffNotMet + } = props; + + return ( + + ); +} + +EpisodeQuality.propTypes = { + className: PropTypes.string, + title: PropTypes.string, + quality: PropTypes.object.isRequired, + size: PropTypes.number, + isCutoffNotMet: PropTypes.bool +}; + +EpisodeQuality.defaultProps = { + title: '' +}; + +export default EpisodeQuality; diff --git a/frontend/src/Episode/EpisodeSearchCell.css b/frontend/src/Episode/EpisodeSearchCell.css new file mode 100644 index 000000000..5e99b51eb --- /dev/null +++ b/frontend/src/Episode/EpisodeSearchCell.css @@ -0,0 +1,6 @@ +.episodeSearchCell { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 70px; + white-space: nowrap; +} diff --git a/frontend/src/Episode/EpisodeSearchCell.js b/frontend/src/Episode/EpisodeSearchCell.js new file mode 100644 index 000000000..d0f8c4114 --- /dev/null +++ b/frontend/src/Episode/EpisodeSearchCell.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import EpisodeDetailsModal from './EpisodeDetailsModal'; +import styles from './EpisodeSearchCell.css'; + +class EpisodeSearchCell extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onManualSearchPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + episodeId, + seriesId, + episodeTitle, + isSearching, + onSearchPress, + ...otherProps + } = this.props; + + return ( + + + + + + + + ); + } +} + +EpisodeSearchCell.propTypes = { + episodeId: PropTypes.number.isRequired, + seriesId: PropTypes.number.isRequired, + episodeTitle: PropTypes.string.isRequired, + isSearching: PropTypes.bool.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +export default EpisodeSearchCell; diff --git a/frontend/src/Episode/EpisodeSearchCellConnector.js b/frontend/src/Episode/EpisodeSearchCellConnector.js new file mode 100644 index 000000000..e4df0a324 --- /dev/null +++ b/frontend/src/Episode/EpisodeSearchCellConnector.js @@ -0,0 +1,50 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { isCommandExecuting } from 'Utilities/Command'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import EpisodeSearchCell from './EpisodeSearchCell'; + +function createMapStateToProps() { + return createSelector( + (state, { episodeId }) => episodeId, + (state, { sceneSeasonNumber }) => sceneSeasonNumber, + createSeriesSelector(), + createCommandsSelector(), + (episodeId, sceneSeasonNumber, series, commands) => { + const isSearching = commands.some((command) => { + const episodeSearch = command.name === commandNames.EPISODE_SEARCH; + + if (!episodeSearch) { + return false; + } + + return ( + isCommandExecuting(command) && + command.body.episodeIds.indexOf(episodeId) > -1 + ); + }); + + return { + seriesMonitored: series.monitored, + seriesType: series.seriesType, + isSearching + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSearchPress(name, path) { + dispatch(executeCommand({ + name: commandNames.EPISODE_SEARCH, + episodeIds: [props.episodeId] + })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSearchCell); diff --git a/frontend/src/Episode/EpisodeStatus.css b/frontend/src/Episode/EpisodeStatus.css new file mode 100644 index 000000000..3833887df --- /dev/null +++ b/frontend/src/Episode/EpisodeStatus.css @@ -0,0 +1,4 @@ +.center { + display: flex; + justify-content: center; +} diff --git a/frontend/src/Episode/EpisodeStatus.js b/frontend/src/Episode/EpisodeStatus.js new file mode 100644 index 000000000..ee8ff9dad --- /dev/null +++ b/frontend/src/Episode/EpisodeStatus.js @@ -0,0 +1,128 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import isBefore from 'Utilities/Date/isBefore'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import ProgressBar from 'Components/ProgressBar'; +import QueueDetails from 'Activity/Queue/QueueDetails'; +import EpisodeQuality from './EpisodeQuality'; +import styles from './EpisodeStatus.css'; + +function EpisodeStatus(props) { + const { + airDateUtc, + monitored, + grabbed, + queueItem, + episodeFile + } = props; + + const hasEpisodeFile = !!episodeFile; + const isQueued = !!queueItem; + const hasAired = isBefore(airDateUtc); + + if (isQueued) { + const { + sizeleft, + size + } = queueItem; + + const progress = (100 - sizeleft / size * 100); + + return ( +
+ + } + /> +
+ ); + } + + if (grabbed) { + return ( +
+ +
+ ); + } + + if (hasEpisodeFile) { + const quality = episodeFile.quality; + const isCutoffNotMet = episodeFile.qualityCutoffNotMet; + + return ( +
+ +
+ ); + } + + if (!airDateUtc) { + return ( +
+ +
+ ); + } + + if (!monitored) { + return ( +
+ +
+ ); + } + + if (hasAired) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +} + +EpisodeStatus.propTypes = { + airDateUtc: PropTypes.string, + monitored: PropTypes.bool.isRequired, + grabbed: PropTypes.bool, + queueItem: PropTypes.object, + episodeFile: PropTypes.object +}; + +export default EpisodeStatus; diff --git a/frontend/src/Episode/EpisodeStatusConnector.js b/frontend/src/Episode/EpisodeStatusConnector.js new file mode 100644 index 000000000..7169dd31b --- /dev/null +++ b/frontend/src/Episode/EpisodeStatusConnector.js @@ -0,0 +1,53 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; +import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import EpisodeStatus from './EpisodeStatus'; + +function createMapStateToProps() { + return createSelector( + createEpisodeSelector(), + createQueueItemSelector(), + createEpisodeFileSelector(), + (episode, queueItem, episodeFile) => { + const result = _.pick(episode, [ + 'airDateUtc', + 'monitored', + 'grabbed' + ]); + + result.queueItem = queueItem; + result.episodeFile = episodeFile; + + return result; + } + ); +} + +const mapDispatchToProps = { +}; + +class EpisodeStatusConnector extends Component { + + // + // Render + + render() { + return ( + + ); + } +} + +EpisodeStatusConnector.propTypes = { + episodeId: PropTypes.number.isRequired, + episodeFileId: PropTypes.number.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeStatusConnector); diff --git a/frontend/src/Episode/EpisodeTitleLink.css b/frontend/src/Episode/EpisodeTitleLink.css new file mode 100644 index 000000000..6022be8a4 --- /dev/null +++ b/frontend/src/Episode/EpisodeTitleLink.css @@ -0,0 +1,8 @@ +.link { + composes: link from 'Components/Link/Link.css'; + + &:hover { + color: $linkHoverColor; + text-decoration: underline; + } +} diff --git a/frontend/src/Episode/EpisodeTitleLink.js b/frontend/src/Episode/EpisodeTitleLink.js new file mode 100644 index 000000000..eac76eaae --- /dev/null +++ b/frontend/src/Episode/EpisodeTitleLink.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; +import styles from './EpisodeTitleLink.css'; + +class EpisodeTitleLink extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onLinkPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + episodeTitle, + ...otherProps + } = this.props; + + return ( +
+ + {episodeTitle} + + + +
+ ); + } +} + +EpisodeTitleLink.propTypes = { + episodeTitle: PropTypes.string.isRequired +}; + +EpisodeTitleLink.defaultProps = { + showSeriesButton: false +}; + +export default EpisodeTitleLink; diff --git a/frontend/src/Episode/History/EpisodeHistory.js b/frontend/src/Episode/History/EpisodeHistory.js new file mode 100644 index 000000000..5a7007ba8 --- /dev/null +++ b/frontend/src/Episode/History/EpisodeHistory.js @@ -0,0 +1,117 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import EpisodeHistoryRow from './EpisodeHistoryRow'; + +const columns = [ + { + name: 'eventType', + isVisible: true + }, + { + name: 'sourceTitle', + label: 'Source Title', + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isVisible: true + }, + { + name: 'details', + label: 'Details', + isVisible: true + }, + { + name: 'actions', + label: 'Actions', + isVisible: true + } +]; + +class EpisodeHistory extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + onMarkAsFailedPress + } = this.props; + + const hasItems = !!items.length; + + if (isFetching) { + return ( + + ); + } + + if (!isFetching && !!error) { + return ( +
Unable to load episode history.
+ ); + } + + if (isPopulated && !hasItems && !error) { + return ( +
No episode history.
+ ); + } + + if (isPopulated && hasItems && !error) { + return ( + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ ); + } + + return null; + } +} + +EpisodeHistory.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired +}; + +EpisodeHistory.defaultProps = { + selectedTab: 'details' +}; + +export default EpisodeHistory; diff --git a/frontend/src/Episode/History/EpisodeHistoryConnector.js b/frontend/src/Episode/History/EpisodeHistoryConnector.js new file mode 100644 index 000000000..cf28b79dc --- /dev/null +++ b/frontend/src/Episode/History/EpisodeHistoryConnector.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchEpisodeHistory, clearEpisodeHistory, episodeHistoryMarkAsFailed } from 'Store/Actions/episodeHistoryActions'; +import EpisodeHistory from './EpisodeHistory'; + +function createMapStateToProps() { + return createSelector( + (state) => state.episodeHistory, + (episodeHistory) => { + return episodeHistory; + } + ); +} + +const mapDispatchToProps = { + fetchEpisodeHistory, + clearEpisodeHistory, + episodeHistoryMarkAsFailed +}; + +class EpisodeHistoryConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchEpisodeHistory({ episodeId: this.props.episodeId }); + } + + componentWillUnmount() { + this.props.clearEpisodeHistory(); + } + + // + // Listeners + + onMarkAsFailedPress = (historyId) => { + this.props.episodeHistoryMarkAsFailed({ historyId, episodeId: this.props.episodeId }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EpisodeHistoryConnector.propTypes = { + episodeId: PropTypes.number.isRequired, + fetchEpisodeHistory: PropTypes.func.isRequired, + clearEpisodeHistory: PropTypes.func.isRequired, + episodeHistoryMarkAsFailed: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeHistoryConnector); diff --git a/frontend/src/Episode/History/EpisodeHistoryRow.css b/frontend/src/Episode/History/EpisodeHistoryRow.css new file mode 100644 index 000000000..8c3fb8272 --- /dev/null +++ b/frontend/src/Episode/History/EpisodeHistoryRow.css @@ -0,0 +1,6 @@ +.details, +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 65px; +} diff --git a/frontend/src/Episode/History/EpisodeHistoryRow.js b/frontend/src/Episode/History/EpisodeHistoryRow.js new file mode 100644 index 000000000..409449f3b --- /dev/null +++ b/frontend/src/Episode/History/EpisodeHistoryRow.js @@ -0,0 +1,163 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; +import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; +import styles from './EpisodeHistoryRow.css'; + +function getTitle(eventType) { + switch (eventType) { + case 'grabbed': return 'Grabbed'; + case 'seriesFolderImported': return 'Series Folder Imported'; + case 'downloadFolderImported': return 'Download Folder Imported'; + case 'downloadFailed': return 'Download Failed'; + case 'episodeFileDeleted': return 'Episode File Deleted'; + case 'episodeFileRenamed': return 'Episode File Renamed'; + default: return 'Unknown'; + } +} + +class EpisodeHistoryRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMarkAsFailedModalOpen: false + }; + } + + // + // Listeners + + onMarkAsFailedPress = () => { + this.setState({ isMarkAsFailedModalOpen: true }); + } + + onConfirmMarkAsFailed = () => { + this.props.onMarkAsFailedPress(this.props.id); + this.setState({ isMarkAsFailedModalOpen: false }); + } + + onMarkAsFailedModalClose = () => { + this.setState({ isMarkAsFailedModalOpen: false }); + } + + // + // Render + + render() { + const { + eventType, + sourceTitle, + language, + languageCutoffNotMet, + quality, + qualityCutoffNotMet, + date, + data + } = this.props; + + const { + isMarkAsFailedModalOpen + } = this.state; + + return ( + + + + + {sourceTitle} + + + + + + + + + + + + + + + } + title={getTitle(eventType)} + body={ + + } + position={tooltipPositions.LEFT} + /> + + + + { + eventType === 'grabbed' && + + } + + + + + ); + } +} + +EpisodeHistoryRow.propTypes = { + id: PropTypes.number.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + language: PropTypes.object.isRequired, + languageCutoffNotMet: PropTypes.bool.isRequired, + quality: PropTypes.object.isRequired, + qualityCutoffNotMet: PropTypes.bool.isRequired, + date: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired +}; + +export default EpisodeHistoryRow; diff --git a/frontend/src/Episode/SceneInfo.css b/frontend/src/Episode/SceneInfo.css new file mode 100644 index 000000000..3efb78509 --- /dev/null +++ b/frontend/src/Episode/SceneInfo.css @@ -0,0 +1,17 @@ +.descriptionList { + composes: descriptionList from 'Components/DescriptionList/DescriptionList.css'; + + margin-right: 10px; +} + +.title { + composes: title from 'Components/DescriptionList/DescriptionListItemTitle.css'; + + width: 80px; +} + +.description { + composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css'; + + margin-left: 100px; +} diff --git a/frontend/src/Episode/SceneInfo.js b/frontend/src/Episode/SceneInfo.js new file mode 100644 index 000000000..3eef9351f --- /dev/null +++ b/frontend/src/Episode/SceneInfo.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import styles from './SceneInfo.css'; + +function SceneInfo(props) { + const { + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + alternateTitles, + seriesType + } = props; + + return ( + + { + sceneSeasonNumber !== undefined && + + } + + { + sceneEpisodeNumber !== undefined && + + } + + { + seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined && + + } + + { + !!alternateTitles.length && + + { + alternateTitles.map((alternateTitle) => { + return ( +
+ {alternateTitle.title} +
+ ); + }) + } +
+ } + /> + } + + ); +} + +SceneInfo.propTypes = { + sceneSeasonNumber: PropTypes.number, + sceneEpisodeNumber: PropTypes.number, + sceneAbsoluteEpisodeNumber: PropTypes.number, + alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, + seriesType: PropTypes.string +}; + +export default SceneInfo; diff --git a/frontend/src/Episode/Search/EpisodeSearch.css b/frontend/src/Episode/Search/EpisodeSearch.css new file mode 100644 index 000000000..2f7ddfd19 --- /dev/null +++ b/frontend/src/Episode/Search/EpisodeSearch.css @@ -0,0 +1,16 @@ +.buttonContainer { + display: flex; + justify-content: center; + + margin-top: 10px; +} + +.button { + composes: button from 'Components/Link/Button.css'; + + width: 300px; +} + +.buttonIcon { + margin-right: 5px; +} diff --git a/frontend/src/Episode/Search/EpisodeSearch.js b/frontend/src/Episode/Search/EpisodeSearch.js new file mode 100644 index 000000000..f3ab8fdec --- /dev/null +++ b/frontend/src/Episode/Search/EpisodeSearch.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import styles from './EpisodeSearch.css'; + +function EpisodeSearch(props) { + const { + onQuickSearchPress, + onInteractiveSearchPress + } = props; + + return ( +
+
+ +
+ +
+ +
+
+ ); +} + +EpisodeSearch.propTypes = { + onQuickSearchPress: PropTypes.func.isRequired, + onInteractiveSearchPress: PropTypes.func.isRequired +}; + +export default EpisodeSearch; diff --git a/frontend/src/Episode/Search/EpisodeSearchConnector.js b/frontend/src/Episode/Search/EpisodeSearchConnector.js new file mode 100644 index 000000000..c25eecc4f --- /dev/null +++ b/frontend/src/Episode/Search/EpisodeSearchConnector.js @@ -0,0 +1,93 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import * as commandNames from 'Commands/commandNames'; +import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import EpisodeSearch from './EpisodeSearch'; + +function createMapStateToProps() { + return createSelector( + (state) => state.releases, + (releases) => { + return { + isPopulated: releases.isPopulated + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand +}; + +class EpisodeSearchConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isInteractiveSearchOpen: props.startInteractiveSearch + }; + } + + componentDidMount() { + if (this.props.isPopulated) { + this.setState({ isInteractiveSearchOpen: true }); + } + } + + // + // Listeners + + onQuickSearchPress = () => { + this.props.executeCommand({ + name: commandNames.EPISODE_SEARCH, + episodeIds: [this.props.episodeId] + }); + + this.props.onModalClose(); + } + + onInteractiveSearchPress = () => { + this.setState({ isInteractiveSearchOpen: true }); + } + + // + // Render + + render() { + const { episodeId } = this.props; + + if (this.state.isInteractiveSearchOpen) { + return ( + + ); + } + + return ( + + ); + } +} + +EpisodeSearchConnector.propTypes = { + episodeId: PropTypes.number.isRequired, + isPopulated: PropTypes.bool.isRequired, + startInteractiveSearch: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeSearchConnector); diff --git a/frontend/src/Episode/SeasonEpisodeNumber.js b/frontend/src/Episode/SeasonEpisodeNumber.js new file mode 100644 index 000000000..e4d391002 --- /dev/null +++ b/frontend/src/Episode/SeasonEpisodeNumber.js @@ -0,0 +1,32 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import EpisodeNumber from './EpisodeNumber'; + +function SeasonEpisodeNumber(props) { + const { + airDate, + seriesType, + ...otherProps + } = props; + + if (seriesType === 'daily' && airDate) { + return ( + {airDate} + ); + } + + return ( + + ); +} + +SeasonEpisodeNumber.propTypes = { + airDate: PropTypes.string, + seriesType: PropTypes.string +}; + +export default SeasonEpisodeNumber; diff --git a/frontend/src/Episode/Summary/EpisodeAiring.js b/frontend/src/Episode/Summary/EpisodeAiring.js new file mode 100644 index 000000000..54ca64325 --- /dev/null +++ b/frontend/src/Episode/Summary/EpisodeAiring.js @@ -0,0 +1,86 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import formatTime from 'Utilities/Date/formatTime'; +import isInNextWeek from 'Utilities/Date/isInNextWeek'; +import isToday from 'Utilities/Date/isToday'; +import isTomorrow from 'Utilities/Date/isTomorrow'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function EpisodeAiring(props) { + const { + airDateUtc, + network, + shortDateFormat, + showRelativeDates, + timeFormat + } = props; + + const networkLabel = ( + + ); + + if (!airDateUtc) { + return ( + + TBA on {networkLabel} + + ); + } + + const time = formatTime(airDateUtc, timeFormat); + + if (!showRelativeDates) { + return ( + + {moment(airDateUtc).format(shortDateFormat)} at {time} on {networkLabel} + + ); + } + + if (isToday(airDateUtc)) { + return ( + + {time} on {networkLabel} + + ); + } + + if (isTomorrow(airDateUtc)) { + return ( + + Tomorrow at {time} on {networkLabel} + + ); + } + + if (isInNextWeek(airDateUtc)) { + return ( + + {moment(airDateUtc).format('dddd')} at {time} on {networkLabel} + + ); + } + + return ( + + {moment(airDateUtc).format(shortDateFormat)} at {time} on {networkLabel} + + ); +} + +EpisodeAiring.propTypes = { + airDateUtc: PropTypes.string.isRequired, + network: PropTypes.string.isRequired, + shortDateFormat: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default EpisodeAiring; diff --git a/frontend/src/Episode/Summary/EpisodeAiringConnector.js b/frontend/src/Episode/Summary/EpisodeAiringConnector.js new file mode 100644 index 000000000..508467efb --- /dev/null +++ b/frontend/src/Episode/Summary/EpisodeAiringConnector.js @@ -0,0 +1,20 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import EpisodeAiring from './EpisodeAiring'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return _.pick(uiSettings, [ + 'shortDateFormat', + 'showRelativeDates', + 'timeFormat' + ]); + } + ); +} + +export default connect(createMapStateToProps)(EpisodeAiring); diff --git a/frontend/src/Episode/Summary/EpisodeSummary.css b/frontend/src/Episode/Summary/EpisodeSummary.css new file mode 100644 index 000000000..f8238ed8a --- /dev/null +++ b/frontend/src/Episode/Summary/EpisodeSummary.css @@ -0,0 +1,48 @@ +.infoTitle { + display: inline-block; + width: 100px; + font-weight: bold; +} + +.overview, +.files { + margin-top: 20px; +} + +.filesHeader { + display: flex; + font-weight: bold; +} + +.filesHeader { + display: flex; + margin-bottom: 10px; + border-bottom: 1px solid $borderColor; +} + +.fileRow { + display: flex; +} + +.path { + @add-mixin truncate; + + flex: 1 0 1px; +} + +.size, +.quality { + flex: 0 0 125px; +} + +.actions { + flex: 0 0 40px; + text-align: center; +} + +@media only screen and (max-width: $breakpointMedium) { + .size, + .quality { + flex: 0 0 80px; + } +} diff --git a/frontend/src/Episode/Summary/EpisodeSummary.js b/frontend/src/Episode/Summary/EpisodeSummary.js new file mode 100644 index 000000000..ef1b7cc67 --- /dev/null +++ b/frontend/src/Episode/Summary/EpisodeSummary.js @@ -0,0 +1,183 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import Popover from 'Components/Tooltip/Popover'; +import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import EpisodeAiringConnector from './EpisodeAiringConnector'; +import MediaInfo from './MediaInfo'; +import styles from './EpisodeSummary.css'; + +class EpisodeSummary extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRemoveEpisodeFileModalOpen: false + }; + } + + // + // Listeners + + onRemoveEpisodeFilePress = () => { + this.setState({ isRemoveEpisodeFileModalOpen: true }); + } + + onConfirmRemoveEpisodeFile = () => { + this.props.onDeleteEpisodeFile(); + this.setState({ isRemoveEpisodeFileModalOpen: false }); + } + + onRemoveEpisodeFileModalClose = () => { + this.setState({ isRemoveEpisodeFileModalOpen: false }); + } + + // + // Render + + render() { + const { + qualityProfileId, + network, + overview, + airDateUtc, + mediaInfo, + path, + size, + quality, + qualityCutoffNotMet + } = this.props; + + const hasOverview = !!overview; + + return ( +
+
+ Airs + + +
+ +
+ Quality Profile + + +
+ +
+ { + hasOverview ? + overview : + 'No episode overview.' + } +
+ + { + path && +
+
+
+ Path +
+ +
+ Size +
+ +
+ Quality +
+ +
+
+ +
+
+ {path} +
+ +
+ {formatBytes(size)} +
+ +
+ +
+ +
+ + } + title="Media Info" + body={} + position={tooltipPositions.LEFT} + /> + + +
+
+
+ } + + +
+ ); + } +} + +EpisodeSummary.propTypes = { + episodeFileId: PropTypes.number.isRequired, + qualityProfileId: PropTypes.number.isRequired, + network: PropTypes.string.isRequired, + overview: PropTypes.string, + airDateUtc: PropTypes.string.isRequired, + mediaInfo: PropTypes.object, + path: PropTypes.string, + size: PropTypes.number, + quality: PropTypes.object, + qualityCutoffNotMet: PropTypes.bool, + onDeleteEpisodeFile: PropTypes.func.isRequired +}; + +export default EpisodeSummary; diff --git a/frontend/src/Episode/Summary/EpisodeSummaryConnector.js b/frontend/src/Episode/Summary/EpisodeSummaryConnector.js new file mode 100644 index 000000000..e17f7b750 --- /dev/null +++ b/frontend/src/Episode/Summary/EpisodeSummaryConnector.js @@ -0,0 +1,59 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { deleteEpisodeFile } from 'Store/Actions/episodeFileActions'; +import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import EpisodeSummary from './EpisodeSummary'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createEpisodeSelector(), + createEpisodeFileSelector(), + (series, episode, episodeFile = {}) => { + const { + qualityProfileId, + network + } = series; + + const { + airDateUtc, + overview + } = episode; + + const { + mediaInfo, + path, + size, + quality, + qualityCutoffNotMet + } = episodeFile; + + return { + network, + qualityProfileId, + airDateUtc, + overview, + mediaInfo, + path, + size, + quality, + qualityCutoffNotMet + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onDeleteEpisodeFile() { + dispatch(deleteEpisodeFile({ + id: props.episodeFileId, + episodeEntity: props.episodeEntity + })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSummary); diff --git a/frontend/src/Episode/Summary/MediaInfo.js b/frontend/src/Episode/Summary/MediaInfo.js new file mode 100644 index 000000000..af023266b --- /dev/null +++ b/frontend/src/Episode/Summary/MediaInfo.js @@ -0,0 +1,33 @@ +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; + +function MediaInfo(props) { + return ( + + { + Object.keys(props).map((key) => { + const title = key + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (str) => str.toUpperCase()); + + const value = props[key]; + + if (!value) { + return null; + } + + return ( + + ); + }) + } + + ); +} + +export default MediaInfo; diff --git a/frontend/src/Episode/episodeEntities.js b/frontend/src/Episode/episodeEntities.js new file mode 100644 index 000000000..fe21d4ed0 --- /dev/null +++ b/frontend/src/Episode/episodeEntities.js @@ -0,0 +1,13 @@ +export const CALENDAR = 'calendar'; +export const EPISODES = 'episodes'; +export const INTERACTIVE_IMPORT = 'interactiveImport.episodes'; +export const WANTED_CUTOFF_UNMET = 'wanted.cutoffUnmet'; +export const WANTED_MISSING = 'wanted.missing'; + +export default { + CALENDAR, + EPISODES, + INTERACTIVE_IMPORT, + WANTED_CUTOFF_UNMET, + WANTED_MISSING +}; diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js new file mode 100644 index 000000000..35d00caf2 --- /dev/null +++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModal.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EpisodeFileEditorModalContentConnector from './EpisodeFileEditorModalContentConnector'; + +function EpisodeFileEditorModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + { + isOpen && + + } + + ); +} + +EpisodeFileEditorModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EpisodeFileEditorModal; diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css new file mode 100644 index 000000000..49e946826 --- /dev/null +++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.css @@ -0,0 +1,8 @@ +.actions { + display: flex; + margin-right: auto; +} + +.selectInput { + margin-left: 10px; +} diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js new file mode 100644 index 000000000..1d65457d7 --- /dev/null +++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContent.js @@ -0,0 +1,285 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { kinds } from 'Helpers/Props'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import SelectInput from 'Components/Form/SelectInput'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import SeasonNumber from 'Season/SeasonNumber'; +import EpisodeFileEditorRow from './EpisodeFileEditorRow'; +import styles from './EpisodeFileEditorModalContent.css'; + +const columns = [ + { + name: 'episodeNumber', + label: 'Episode', + isVisible: true + }, + { + name: 'relativePath', + label: 'Relative Path', + isVisible: true + }, + { + name: 'airDateUtc', + label: 'Air Date', + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + } +]; + +class EpisodeFileEditorModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isConfirmDeleteModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.items !== this.props.items) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Control + + getSelectedIds = () => { + const selectedIds = getSelectedIds(this.state.selectedState); + + return selectedIds.reduce((acc, id) => { + const matchingItem = this.props.items.find((item) => item.id === id); + + if (matchingItem && !acc.includes(matchingItem.episodeFileId)) { + acc.push(matchingItem.episodeFileId); + } + + return acc; + }, []); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onDeletePress = () => { + this.setState({ isConfirmDeleteModalOpen: true }); + } + + onConfirmDelete = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + this.props.onDeletePress(this.getSelectedIds()); + } + + onConfirmDeleteModalClose = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + } + + onLanguageChange = ({ value }) => { + const selectedIds = this.getSelectedIds(); + + if (!selectedIds.length) { + return; + } + + this.props.onLanguageChange(selectedIds, parseInt(value)); + } + + onQualityChange = ({ value }) => { + const selectedIds = this.getSelectedIds(); + + if (!selectedIds.length) { + return; + } + + this.props.onQualityChange(selectedIds, parseInt(value)); + } + + // + // Render + + render() { + const { + seasonNumber, + isDeleting, + items, + languages, + qualities, + seriesType, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmDeleteModalOpen + } = this.state; + + const languageOptions = _.reduceRight(languages, (acc, language) => { + acc.push({ + key: language.id, + value: language.name + }); + + return acc; + }, [{ key: 'selectLanguage', value: 'Select Language', disabled: true }]); + + const qualityOptions = _.reduceRight(qualities, (acc, quality) => { + acc.push({ + key: quality.id, + value: quality.name + }); + + return acc; + }, [{ key: 'selectQuality', value: 'Select Quality', disabled: true }]); + + const hasSelectedFiles = this.getSelectedIds().length > 0; + + return ( + + + Manage Episodes {seasonNumber != null && } + + + + { + !items.length && +
+ No episode files to manage. +
+ } + + { + !!items.length && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ + +
+ + Delete + + +
+ +
+ +
+ +
+
+ + +
+ + +
+ ); + } +} + +EpisodeFileEditorModalContent.propTypes = { + seasonNumber: PropTypes.number, + isDeleting: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + languages: PropTypes.arrayOf(PropTypes.object).isRequired, + qualities: PropTypes.arrayOf(PropTypes.object).isRequired, + seriesType: PropTypes.string.isRequired, + onDeletePress: PropTypes.func.isRequired, + onLanguageChange: PropTypes.func.isRequired, + onQualityChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EpisodeFileEditorModalContent; diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js new file mode 100644 index 000000000..c20e752e3 --- /dev/null +++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorModalContentConnector.js @@ -0,0 +1,151 @@ +/* eslint max-params: 0 */ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import getQualities from 'Utilities/Quality/getQualities'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import { deleteEpisodeFiles, updateEpisodeFiles } from 'Store/Actions/episodeFileActions'; +import { fetchLanguageProfileSchema, fetchQualityProfileSchema } from 'Store/Actions/settingsActions'; +import EpisodeFileEditorModalContent from './EpisodeFileEditorModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { seasonNumber }) => seasonNumber, + (state) => state.episodes, + (state) => state.episodeFiles, + (state) => state.settings.languageProfiles.schema, + (state) => state.settings.qualityProfiles.schema, + createSeriesSelector(), + ( + seasonNumber, + episodes, + episodeFiles, + languageProfilesSchema, + qualityProfileSchema, + series + ) => { + const filtered = _.filter(episodes.items, (episode) => { + if (seasonNumber >= 0 && episode.seasonNumber !== seasonNumber) { + return false; + } + + if (!episode.episodeFileId) { + return false; + } + + return _.some(episodeFiles.items, { id: episode.episodeFileId }); + }); + + const sorted = _.orderBy(filtered, ['seasonNumber', 'episodeNumber'], ['desc', 'desc']); + + const items = _.map(sorted, (episode) => { + const episodeFile = _.find(episodeFiles.items, { id: episode.episodeFileId }); + + return { + relativePath: episodeFile.relativePath, + language: episodeFile.language, + quality: episodeFile.quality, + ...episode + }; + }); + + const languages = _.map(languageProfilesSchema.languages, 'language'); + const qualities = getQualities(qualityProfileSchema.items); + + return { + items, + seriesType: series.seriesType, + isDeleting: episodeFiles.isDeleting, + isSaving: episodeFiles.isSaving, + languages, + qualities + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchLanguageProfileSchema(name, path) { + dispatch(fetchLanguageProfileSchema()); + }, + + dispatchFetchQualityProfileSchema(name, path) { + dispatch(fetchQualityProfileSchema()); + }, + + dispatchUpdateEpisodeFiles(updateProps) { + dispatch(updateEpisodeFiles(updateProps)); + }, + + onDeletePress(episodeFileIds) { + dispatch(deleteEpisodeFiles({ episodeFileIds })); + } + }; +} + +class EpisodeFileEditorModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchLanguageProfileSchema(); + this.props.dispatchFetchQualityProfileSchema(); + } + + // + // Render + + // + // Listeners + + onLanguageChange = (episodeFileIds, languageId) => { + const language = _.find(this.props.languages, { id: languageId }); + + this.props.dispatchUpdateEpisodeFiles({ episodeFileIds, language }); + } + + onQualityChange = (episodeFileIds, qualityId) => { + const quality = { + quality: _.find(this.props.qualities, { id: qualityId }), + revision: { + version: 1, + real: 0 + } + }; + + this.props.dispatchUpdateEpisodeFiles({ episodeFileIds, quality }); + } + + render() { + const { + dispatchFetchLanguageProfileSchema, + dispatchFetchQualityProfileSchema, + dispatchUpdateEpisodeFiles, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EpisodeFileEditorModalContentConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number, + languages: PropTypes.arrayOf(PropTypes.object).isRequired, + qualities: PropTypes.arrayOf(PropTypes.object).isRequired, + dispatchFetchLanguageProfileSchema: PropTypes.func.isRequired, + dispatchFetchQualityProfileSchema: PropTypes.func.isRequired, + dispatchUpdateEpisodeFiles: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeFileEditorModalContentConnector); diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css new file mode 100644 index 000000000..f86e1de6b --- /dev/null +++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.css @@ -0,0 +1,3 @@ +.absoluteEpisodeNumber { + margin-left: 5px; +} diff --git a/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js new file mode 100644 index 000000000..62cedb8af --- /dev/null +++ b/frontend/src/EpisodeFile/Editor/EpisodeFileEditorRow.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import padNumber from 'Utilities/Number/padNumber'; +import Label from 'Components/Label'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import styles from './EpisodeFileEditorRow'; + +function EpisodeFileEditorRow(props) { + const { + id, + seriesType, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + relativePath, + airDateUtc, + language, + quality, + isSelected, + onSelectedChange + } = props; + + return ( + + + + + {seasonNumber}x{padNumber(episodeNumber, 2)} + + { + seriesType === 'anime' && !!absoluteEpisodeNumber && + + ({absoluteEpisodeNumber}) + + } + + + + {relativePath} + + + + + + + + + + + + + ); +} + +EpisodeFileEditorRow.propTypes = { + id: PropTypes.number.isRequired, + seriesType: PropTypes.string.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + relativePath: PropTypes.string.isRequired, + airDateUtc: PropTypes.string.isRequired, + language: PropTypes.object.isRequired, + quality: PropTypes.object.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +export default EpisodeFileEditorRow; diff --git a/frontend/src/EpisodeFile/EpisodeFileLanguageConnector.js b/frontend/src/EpisodeFile/EpisodeFileLanguageConnector.js new file mode 100644 index 000000000..38713c011 --- /dev/null +++ b/frontend/src/EpisodeFile/EpisodeFileLanguageConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; + +function createMapStateToProps() { + return createSelector( + createEpisodeFileSelector(), + (episodeFile) => { + return { + language: episodeFile ? episodeFile.language : undefined + }; + } + ); +} + +export default connect(createMapStateToProps)(EpisodeLanguage); diff --git a/frontend/src/EpisodeFile/MediaInfo.js b/frontend/src/EpisodeFile/MediaInfo.js new file mode 100644 index 000000000..75b264d58 --- /dev/null +++ b/frontend/src/EpisodeFile/MediaInfo.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import * as mediaInfoTypes from './mediaInfoTypes'; + +function MediaInfo(props) { + const { + type, + audioChannels, + audioCodec, + videoCodec + } = props; + + if (type === mediaInfoTypes.AUDIO) { + return ( + + { + !!audioCodec && + audioCodec + } + + { + !!audioCodec && !!audioChannels && + ' - ' + } + + { + !!audioChannels && + audioChannels.toFixed(1) + } + + ); + } + + if (type === mediaInfoTypes.VIDEO) { + return ( + + {videoCodec} + + ); + } + + return null; +} + +MediaInfo.propTypes = { + type: PropTypes.string.isRequired, + audioChannels: PropTypes.number, + audioCodec: PropTypes.string, + videoCodec: PropTypes.string +}; + +export default MediaInfo; diff --git a/frontend/src/EpisodeFile/MediaInfoConnector.js b/frontend/src/EpisodeFile/MediaInfoConnector.js new file mode 100644 index 000000000..bbb963cf4 --- /dev/null +++ b/frontend/src/EpisodeFile/MediaInfoConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import MediaInfo from './MediaInfo'; + +function createMapStateToProps() { + return createSelector( + createEpisodeFileSelector(), + (episodeFile) => { + if (episodeFile) { + return { + ...episodeFile.mediaInfo + }; + } + + return {}; + } + ); +} + +export default connect(createMapStateToProps)(MediaInfo); diff --git a/frontend/src/EpisodeFile/mediaInfoTypes.js b/frontend/src/EpisodeFile/mediaInfoTypes.js new file mode 100644 index 000000000..5e5a78e64 --- /dev/null +++ b/frontend/src/EpisodeFile/mediaInfoTypes.js @@ -0,0 +1,2 @@ +export const AUDIO = 'audio'; +export const VIDEO = 'video'; diff --git a/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js new file mode 100644 index 000000000..11cca7d1b --- /dev/null +++ b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js @@ -0,0 +1,11 @@ +import PropTypes from 'prop-types'; + +function createRouteMatchShape(props) { + return PropTypes.shape({ + params: PropTypes.shape({ + ...props + }).isRequired + }); +} + +export default createRouteMatchShape; diff --git a/frontend/src/Helpers/Props/Shapes/locationShape.js b/frontend/src/Helpers/Props/Shapes/locationShape.js new file mode 100644 index 000000000..80b53eb44 --- /dev/null +++ b/frontend/src/Helpers/Props/Shapes/locationShape.js @@ -0,0 +1,11 @@ +import PropTypes from 'prop-types'; + +const locationShape = PropTypes.shape({ + pathname: PropTypes.string.isRequired, + search: PropTypes.string.isRequired, + state: PropTypes.object, + action: PropTypes.string, + key: PropTypes.string +}); + +export default locationShape; diff --git a/frontend/src/Helpers/Props/Shapes/settingShape.js b/frontend/src/Helpers/Props/Shapes/settingShape.js new file mode 100644 index 000000000..cd672de27 --- /dev/null +++ b/frontend/src/Helpers/Props/Shapes/settingShape.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; + +const settingShape = { + value: PropTypes.oneOf([PropTypes.bool, PropTypes.number, PropTypes.string]), + warnings: PropTypes.arrayOf(PropTypes.string).isRequired, + errors: PropTypes.arrayOf(PropTypes.string).isRequired +}; + +export const arraySettingShape = { + ...settingShape, + value: PropTypes.array.isRequired +}; + +export const boolSettingShape = { + ...settingShape, + value: PropTypes.bool.isRequired +}; + +export const numberSettingShape = { + ...settingShape, + value: PropTypes.number.isRequired +}; + +export const stringSettingShape = { + ...settingShape, + value: PropTypes.string +}; + +export const tagSettingShape = { + ...settingShape, + value: PropTypes.arrayOf(PropTypes.number).isRequired +}; + +export default settingShape; diff --git a/frontend/src/Helpers/Props/align.js b/frontend/src/Helpers/Props/align.js new file mode 100644 index 000000000..f381959c6 --- /dev/null +++ b/frontend/src/Helpers/Props/align.js @@ -0,0 +1,5 @@ +export const LEFT = 'left'; +export const CENTER = 'center'; +export const RIGHT = 'right'; + +export const all = [LEFT, CENTER, RIGHT]; diff --git a/frontend/src/Helpers/Props/filterBuilderTypes.js b/frontend/src/Helpers/Props/filterBuilderTypes.js new file mode 100644 index 000000000..72722ab63 --- /dev/null +++ b/frontend/src/Helpers/Props/filterBuilderTypes.js @@ -0,0 +1,50 @@ +import * as filterTypes from './filterTypes'; + +export const ARRAY = 'array'; +export const DATE = 'date'; +export const EXACT = 'exact'; +export const NUMBER = 'number'; +export const STRING = 'string'; + +export const all = [ + ARRAY, + DATE, + EXACT, + NUMBER, + STRING +]; + +export const possibleFilterTypes = { + [ARRAY]: [ + { key: filterTypes.CONTAINS, value: 'contains' }, + { key: filterTypes.NOT_CONTAINS, value: 'does not contain' } + ], + + [DATE]: [ + { key: filterTypes.LESS_THAN, value: 'is before' }, + { key: filterTypes.GREATER_THAN, value: 'is after' }, + { key: filterTypes.IN_LAST, value: 'in the last' }, + { key: filterTypes.IN_NEXT, value: 'in the next' } + ], + + [EXACT]: [ + { key: filterTypes.EQUAL, value: 'is' }, + { key: filterTypes.NOT_EQUAL, value: 'is not' } + ], + + [NUMBER]: [ + { key: filterTypes.EQUAL, value: 'equal' }, + { key: filterTypes.GREATER_THAN, value: 'greater than' }, + { key: filterTypes.GREATER_THAN_OR_EQUAL, value: 'greater than or equal' }, + { key: filterTypes.LESS_THAN, value: 'less than' }, + { key: filterTypes.LESS_THAN_OR_EQUAL, value: 'less than or equal' }, + { key: filterTypes.NOT_EQUAL, value: 'not equal' } + ], + + [STRING]: [ + { key: filterTypes.CONTAINS, value: 'contains' }, + { key: filterTypes.NOT_CONTAINS, value: 'does not contain' }, + { key: filterTypes.EQUAL, value: 'equal' }, + { key: filterTypes.NOT_EQUAL, value: 'not equal' } + ] +}; diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js new file mode 100644 index 000000000..cacc24fda --- /dev/null +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js @@ -0,0 +1,11 @@ +export const BOOL = 'bool'; +export const BYTES = 'bytes'; +export const DATE = 'date'; +export const DEFAULT = 'default'; +export const INDEXER = 'indexer'; +export const LANGUAGE_PROFILE = 'languageProfile'; +export const PROTOCOL = 'protocol'; +export const QUALITY = 'quality'; +export const QUALITY_PROFILE = 'qualityProfile'; +export const SERIES_STATUS = 'seriesStatus'; +export const TAG = 'tag'; diff --git a/frontend/src/Helpers/Props/filterTypePredicates.js b/frontend/src/Helpers/Props/filterTypePredicates.js new file mode 100644 index 000000000..a3ea11956 --- /dev/null +++ b/frontend/src/Helpers/Props/filterTypePredicates.js @@ -0,0 +1,45 @@ +import * as filterTypes from './filterTypes'; + +const filterTypePredicates = { + [filterTypes.CONTAINS]: function(itemValue, filterValue) { + if (Array.isArray(itemValue)) { + return itemValue.some((v) => v === filterValue); + } + + return itemValue.toLowerCase().contains(filterValue.toLowerCase()); + }, + + [filterTypes.EQUAL]: function(itemValue, filterValue) { + return itemValue === filterValue; + }, + + [filterTypes.GREATER_THAN]: function(itemValue, filterValue) { + return itemValue > filterValue; + }, + + [filterTypes.GREATER_THAN_OR_EQUAL]: function(itemValue, filterValue) { + return itemValue >= filterValue; + }, + + [filterTypes.LESS_THAN]: function(itemValue, filterValue) { + return itemValue < filterValue; + }, + + [filterTypes.LESS_THAN_OR_EQUAL]: function(itemValue, filterValue) { + return itemValue <= filterValue; + }, + + [filterTypes.NOT_CONTAINS]: function(itemValue, filterValue) { + if (Array.isArray(itemValue)) { + return !itemValue.some((v) => v === filterValue); + } + + return !itemValue.toLowerCase().contains(filterValue.toLowerCase()); + }, + + [filterTypes.NOT_EQUAL]: function(itemValue, filterValue) { + return itemValue !== filterValue; + } +}; + +export default filterTypePredicates; diff --git a/frontend/src/Helpers/Props/filterTypes.js b/frontend/src/Helpers/Props/filterTypes.js new file mode 100644 index 000000000..77809b8ce --- /dev/null +++ b/frontend/src/Helpers/Props/filterTypes.js @@ -0,0 +1,21 @@ +export const CONTAINS = 'contains'; +export const EQUAL = 'equal'; +export const GREATER_THAN = 'greaterThan'; +export const GREATER_THAN_OR_EQUAL = 'greaterThanOrEqual'; +export const IN_LAST = 'inLast'; +export const IN_NEXT = 'inNext'; +export const LESS_THAN = 'lessThan'; +export const LESS_THAN_OR_EQUAL = 'lessThanOrEqual'; +export const NOT_CONTAINS = 'notContains'; +export const NOT_EQUAL = 'notEqual'; + +export const all = [ + CONTAINS, + EQUAL, + GREATER_THAN, + GREATER_THAN_OR_EQUAL, + LESS_THAN, + LESS_THAN_OR_EQUAL, + NOT_CONTAINS, + NOT_EQUAL +]; diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js new file mode 100644 index 000000000..7865dcd6e --- /dev/null +++ b/frontend/src/Helpers/Props/icons.js @@ -0,0 +1,201 @@ +// +// Regular + +import { + faBookmark as farBookmark, + faCalendar as farCalendar, + faCircle as farCircle, + faClock as farClock, + faClone as farClone, + faDotCircle as farDotCircle, + faFile as farFile, + faFileArchive as farFileArchive, + faFileVideo as farFileVideo, + faFolder as farFolder, + faObjectGroup as farObjectGroup, + faHdd as farHdd, + faKeyboard as farKeyboard, + faObjectUngroup as farObjectUngroup +} from '@fortawesome/free-regular-svg-icons'; + +// +// Solid + +import { + faArrowCircleLeft as fasArrowCircleLeft, + faArrowCircleRight as fasArrowCircleRight, + faBackward as fasBackward, + faBars as fasBars, + faBolt as fasBolt, + faBookmark as fasBookmark, + faBookReader as fasBookReader, + faBug as fasBug, + faBroadcastTower as fasBroadcastTower, + faCalendarAlt as fasCalendarAlt, + faCaretDown as fasCaretDown, + faCheck as fasCheck, + faChevronCircleDown as fasChevronCircleDown, + faChevronCircleRight as fasChevronCircleRight, + faChevronCircleUp as fasChevronCircleUp, + faCheckCircle as fasCheckCircle, + faCircle as fasCircle, + faCloudDownloadAlt as fasCloudDownloadAlt, + faCloud as fasCloud, + faCog as fasCog, + faCogs as fasCogs, + faCopy as fasCopy, + faDesktop as fasDesktop, + faDownload as fasDownload, + faEllipsisH as fasEllipsisH, + faExclamationCircle as fasExclamationCircle, + faExclamationTriangle as fasExclamationTriangle, + faExternalLinkAlt as fasExternalLinkAlt, + faEye as fasEye, + faFastBackward as fasFastBackward, + faFastForward as fasFastForward, + faFileInvoice as farFileInvoice, + faFilter as fasFilter, + faFolderOpen as fasFolderOpen, + faForward as fasForward, + faHeart as fasHeart, + faHistory as fasHistory, + faHome as fasHome, + faInfoCircle as fasInfoCircle, + faLaptop as fasLaptop, + faLevelUpAlt as fasLevelUpAlt, + faMedkit as fasMedkit, + faMinus as fasMinus, + faPause as fasPause, + faPlay as fasPlay, + faPlus as fasPlus, + faPowerOff as fasPowerOff, + faQuestion as fasQuestion, + faQuestionCircle as fasQuestionCircle, + faRedoAlt as fasRedoAlt, + faRetweet as fasRetweet, + faRss as fasRss, + faRocket as fasRocket, + faSave as fasSave, + faSearch as fasSearch, + faSignOutAlt as fasSignOutAlt, + faSitemap as fasSitemap, + faSpinner as fasSpinner, + faSort as fasSort, + faSortDown as fasSortDown, + faSortUp as fasSortUp, + faStop as fasStop, + faSync as fasSync, + faTags as fasTags, + faTh as fasTh, + faThList as fasThList, + faTrashAlt as fasTrashAlt, + faTimes as fasTimes, + faTimesCircle as fasTimesCircle, + faUser as fasUser, + faUserPlus as fasUserPlus, + faVial as fasVial, + faWrench as fasWrench +} from '@fortawesome/free-solid-svg-icons'; + +// +// Icons + +export const ACTIONS = fasBolt; +export const ACTIVITY = farClock; +export const ADD = fasPlus; +export const ALTERNATE_TITLES = farClone; +export const ADVANCED_SETTINGS = fasCog; +export const ARROW_LEFT = fasArrowCircleLeft; +export const ARROW_RIGHT = fasArrowCircleRight; +export const BACKUP = farFileArchive; +export const BUG = fasBug; +export const CALENDAR = fasCalendarAlt; +export const CALENDAR_O = farCalendar; +export const CARET_DOWN = fasCaretDown; +export const CHECK = fasCheck; +export const CHECK_INDETERMINATE = fasMinus; +export const CHECK_CIRCLE = fasCheckCircle; +export const CIRCLE = fasCircle; +export const CIRCLE_OUTLINE = farCircle; +export const CLEAR = fasTrashAlt; +export const CLIPBOARD = fasCopy; +export const CLOSE = fasTimes; +export const CLONE = farClone; +export const COLLAPSE = fasChevronCircleUp; +export const COMPUTER = fasDesktop; +export const DANGER = fasExclamationCircle; +export const DELETE = fasTrashAlt; +export const DOWNLOAD = fasDownload; +export const DOWNLOADED = fasDownload; +export const DOWNLOADING = fasCloudDownloadAlt; +export const DRIVE = farHdd; +export const EDIT = fasWrench; +export const EPISODE_FILE = farFileVideo; +export const EXPAND = fasChevronCircleDown; +export const EXPAND_INDETERMINATE = fasChevronCircleRight; +export const EXTERNAL_LINK = fasExternalLinkAlt; +export const FATAL = fasTimesCircle; +export const FILE = farFile; +export const FILTER = fasFilter; +export const FOLDER = farFolder; +export const FOLDER_OPEN = fasFolderOpen; +export const GROUP = farObjectGroup; +export const HEALTH = fasMedkit; +export const HEART = fasHeart; +export const HISTORY = fasHistory; +export const HOUSEKEEPING = fasHome; +export const INFO = fasInfoCircle; +export const INTERACTIVE = fasUser; +export const KEYBOARD = farKeyboard; +export const LOGOUT = fasSignOutAlt; +export const MEDIA_INFO = farFileInvoice; +export const MISSING = fasExclamationTriangle; +export const MONITORED = fasBookmark; +export const NETWORK = fasBroadcastTower; +export const NAVBAR_COLLAPSE = fasBars; +export const NOT_AIRED = farClock; +export const ORGANIZE = fasSitemap; +export const OVERFLOW = fasEllipsisH; +export const OVERVIEW = fasThList; +export const PAGE_FIRST = fasFastBackward; +export const PAGE_PREVIOUS = fasBackward; +export const PAGE_NEXT = fasForward; +export const PAGE_LAST = fasFastForward; +export const PARENT = fasLevelUpAlt; +export const PAUSED = fasPause; +export const PENDING = farClock; +export const PROFILE = fasUser; +export const POSTER = fasTh; +export const QUEUED = fasCloud; +export const QUICK = fasRocket; +export const REFRESH = fasSync; +export const REMOVE = fasTimes; +export const RESTART = fasRedoAlt; +export const RESTORE = fasHistory; +export const REORDER = fasBars; +export const RSS = fasRss; +export const SAVE = fasSave; +export const SCHEDULED = farClock; +export const SCORE = fasUserPlus; +export const SEARCH = fasSearch; +export const SERIES_CONTINUING = fasPlay; +export const SERIES_ENDED = fasStop; +export const SETTINGS = fasCogs; +export const SHUTDOWN = fasPowerOff; +export const SORT = fasSort; +export const SORT_ASCENDING = fasSortUp; +export const SORT_DESCENDING = fasSortDown; +export const SPINNER = fasSpinner; +export const SUBTRACT = fasMinus; +export const SYSTEM = fasLaptop; +export const TAGS = fasTags; +export const TBA = fasQuestionCircle; +export const TEST = fasVial; +export const UNGROUP = farObjectUngroup; +export const UNKNOWN = fasQuestion; +export const UNMONITORED = farBookmark; +export const UPDATE = fasRetweet; +export const UNSAVED_SETTING = farDotCircle; +export const VIEW = fasEye; +export const WARNING = fasExclamationTriangle; +export const WIKI = fasBookReader; diff --git a/frontend/src/Helpers/Props/index.js b/frontend/src/Helpers/Props/index.js new file mode 100644 index 000000000..3f4f94f6f --- /dev/null +++ b/frontend/src/Helpers/Props/index.js @@ -0,0 +1,29 @@ +import * as align from './align'; +import * as inputTypes from './inputTypes'; +import * as filterBuilderTypes from './filterBuilderTypes'; +import * as filterBuilderValueTypes from './filterBuilderValueTypes'; +import filterTypePredicates from './filterTypePredicates'; +import * as filterTypes from './filterTypes'; +import * as icons from './icons'; +import * as kinds from './kinds'; +import * as messageTypes from './messageTypes'; +import * as sizes from './sizes'; +import * as scrollDirections from './scrollDirections'; +import * as sortDirections from './sortDirections'; +import * as tooltipPositions from './tooltipPositions'; + +export { + align, + inputTypes, + filterBuilderTypes, + filterBuilderValueTypes, + filterTypePredicates, + filterTypes, + icons, + kinds, + messageTypes, + sizes, + scrollDirections, + sortDirections, + tooltipPositions +}; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js new file mode 100644 index 000000000..78a8aa1af --- /dev/null +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -0,0 +1,39 @@ +export const AUTO_COMPLETE = 'autoComplete'; +export const CAPTCHA = 'captcha'; +export const CHECK = 'check'; +export const DEVICE = 'device'; +export const KEY_VALUE_LIST = 'keyValueList'; +export const MONITOR_EPISODES_SELECT = 'monitorEpisodesSelect'; +export const NUMBER = 'number'; +export const OAUTH = 'oauth'; +export const PASSWORD = 'password'; +export const PATH = 'path'; +export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; +export const LANGUAGE_PROFILE_SELECT = 'languageProfileSelect'; +export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; +export const SELECT = 'select'; +export const SERIES_TYPE_SELECT = 'seriesTypeSelect'; +export const TAG = 'tag'; +export const TEXT = 'text'; +export const TEXT_TAG = 'textTag'; + +export const all = [ + AUTO_COMPLETE, + CAPTCHA, + CHECK, + DEVICE, + KEY_VALUE_LIST, + MONITOR_EPISODES_SELECT, + NUMBER, + OAUTH, + PASSWORD, + PATH, + QUALITY_PROFILE_SELECT, + LANGUAGE_PROFILE_SELECT, + ROOT_FOLDER_SELECT, + SELECT, + SERIES_TYPE_SELECT, + TAG, + TEXT, + TEXT_TAG +]; diff --git a/frontend/src/Helpers/Props/kinds.js b/frontend/src/Helpers/Props/kinds.js new file mode 100644 index 000000000..fd2c17f7b --- /dev/null +++ b/frontend/src/Helpers/Props/kinds.js @@ -0,0 +1,23 @@ +export const DANGER = 'danger'; +export const DEFAULT = 'default'; +export const DISABLED = 'disabled'; +export const INFO = 'info'; +export const INVERSE = 'inverse'; +export const PINK = 'pink'; +export const PRIMARY = 'primary'; +export const PURPLE = 'purple'; +export const SUCCESS = 'success'; +export const WARNING = 'warning'; + +export const all = [ + DANGER, + DEFAULT, + DISABLED, + INFO, + INVERSE, + PINK, + PRIMARY, + PURPLE, + SUCCESS, + WARNING +]; diff --git a/frontend/src/Helpers/Props/messageTypes.js b/frontend/src/Helpers/Props/messageTypes.js new file mode 100644 index 000000000..997354f9d --- /dev/null +++ b/frontend/src/Helpers/Props/messageTypes.js @@ -0,0 +1,11 @@ +export const ERROR = 'error'; +export const INFO = 'info'; +export const SUCCESS = 'success'; +export const WARNING = 'warning'; + +export const all = [ + ERROR, + INFO, + SUCCESS, + WARNING +]; diff --git a/frontend/src/Helpers/Props/scrollDirections.js b/frontend/src/Helpers/Props/scrollDirections.js new file mode 100644 index 000000000..5e4a4fe08 --- /dev/null +++ b/frontend/src/Helpers/Props/scrollDirections.js @@ -0,0 +1,5 @@ +export const NONE = 'none'; +export const HORIZONTAL = 'horizontal'; +export const VERTICAL = 'vertical'; + +export const all = [NONE, HORIZONTAL, VERTICAL]; diff --git a/frontend/src/Helpers/Props/sizes.js b/frontend/src/Helpers/Props/sizes.js new file mode 100644 index 000000000..d7f85df5e --- /dev/null +++ b/frontend/src/Helpers/Props/sizes.js @@ -0,0 +1,7 @@ +export const EXTRA_SMALL = 'extraSmall'; +export const SMALL = 'small'; +export const MEDIUM = 'medium'; +export const LARGE = 'large'; +export const EXTRA_LARGE = 'extraLarge'; + +export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE]; diff --git a/frontend/src/Helpers/Props/sortDirections.js b/frontend/src/Helpers/Props/sortDirections.js new file mode 100644 index 000000000..ff3b17bb6 --- /dev/null +++ b/frontend/src/Helpers/Props/sortDirections.js @@ -0,0 +1,4 @@ +export const ASCENDING = 'ascending'; +export const DESCENDING = 'descending'; + +export const all = [ASCENDING, DESCENDING]; diff --git a/frontend/src/Helpers/Props/tooltipPositions.js b/frontend/src/Helpers/Props/tooltipPositions.js new file mode 100644 index 000000000..bca3c4ed4 --- /dev/null +++ b/frontend/src/Helpers/Props/tooltipPositions.js @@ -0,0 +1,11 @@ +export const TOP = 'top'; +export const RIGHT = 'right'; +export const BOTTOM = 'bottom'; +export const LEFT = 'left'; + +export const all = [ + TOP, + RIGHT, + BOTTOM, + LEFT +]; diff --git a/frontend/src/Helpers/dragTypes.js b/frontend/src/Helpers/dragTypes.js new file mode 100644 index 000000000..ed6ba080d --- /dev/null +++ b/frontend/src/Helpers/dragTypes.js @@ -0,0 +1,3 @@ +export const QUALITY_PROFILE_ITEM = 'qualityProfileItem'; +export const DELAY_PROFILE = 'delayProfile'; +export const TABLE_COLUMN = 'tableColumn'; diff --git a/frontend/src/Helpers/elementChildren.js b/frontend/src/Helpers/elementChildren.js new file mode 100644 index 000000000..1c10b2f0e --- /dev/null +++ b/frontend/src/Helpers/elementChildren.js @@ -0,0 +1,149 @@ +// https://github.com/react-bootstrap/react-element-children + +import React from 'react'; + +/** + * Iterates through children that are typically specified as `props.children`, + * but only maps over children that are "valid components". + * + * The mapFunction provided index will be normalised to the components mapped, + * so an invalid component would not increase the index. + * + * @param {?*} children Children tree container. + * @param {function(*, int)} func. + * @param {*} context Context for func. + * @return {object} Object containing the ordered map of results. + */ +export function map(children, func, context) { + let index = 0; + + return React.Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child; + } + + return func.call(context, child, index++); + }); +} + +/** + * Iterates through children that are "valid components". + * + * The provided forEachFunc(child, index) will be called for each + * leaf child with the index reflecting the position relative to "valid components". + * + * @param {?*} children Children tree container. + * @param {function(*, int)} func. + * @param {*} context Context for context. + */ +export function forEach(children, func, context) { + let index = 0; + + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child)) { + return; + } + + func.call(context, child, index++); + }); +} + +/** + * Count the number of "valid components" in the Children container. + * + * @param {?*} children Children tree container. + * @returns {number} + */ +export function count(children) { + let result = 0; + + React.Children.forEach(children, (child) => { + if (!React.isValidElement(child)) { + return; + } + + ++result; + }); + + return result; +} + +/** + * Finds children that are typically specified as `props.children`, + * but only iterates over children that are "valid components". + * + * The provided forEachFunc(child, index) will be called for each + * leaf child with the index reflecting the position relative to "valid components". + * + * @param {?*} children Children tree container. + * @param {function(*, int)} func. + * @param {*} context Context for func. + * @returns {array} of children that meet the func return statement + */ +export function filter(children, func, context) { + const result = []; + + forEach(children, (child, index) => { + if (func.call(context, child, index)) { + result.push(child); + } + }); + + return result; +} + +export function find(children, func, context) { + let result = null; + + forEach(children, (child, index) => { + if (result) { + return; + } + if (func.call(context, child, index)) { + result = child; + } + }); + + return result; +} + +export function every(children, func, context) { + let result = true; + + forEach(children, (child, index) => { + if (!result) { + return; + } + if (!func.call(context, child, index)) { + result = false; + } + }); + + return result; +} + +export function some(children, func, context) { + let result = false; + + forEach(children, (child, index) => { + if (result) { + return; + } + + if (func.call(context, child, index)) { + result = true; + } + }); + + return result; +} + +export function toArray(children) { + const result = []; + + forEach(children, (child) => { + result.push(child); + }); + + return result; +} diff --git a/frontend/src/Helpers/getDisplayName.js b/frontend/src/Helpers/getDisplayName.js new file mode 100644 index 000000000..512702c87 --- /dev/null +++ b/frontend/src/Helpers/getDisplayName.js @@ -0,0 +1,3 @@ +export default function getDisplayName(Component) { + return Component.displayName || Component.name || 'Component'; +} diff --git a/frontend/src/Hotkeys/Hotkeys.js b/frontend/src/Hotkeys/Hotkeys.js new file mode 100644 index 000000000..9aa64914b --- /dev/null +++ b/frontend/src/Hotkeys/Hotkeys.js @@ -0,0 +1,34 @@ +var $ = require('jquery'); +var vent = require('vent'); +var HotkeysView = require('./HotkeysView'); + +$(document).on('keypress', function(e) { + if ($(e.target).is('input') || $(e.target).is('textarea')) { + return; + } + + if (e.charCode === 63) { + vent.trigger(vent.Commands.OpenFullscreenModal, new HotkeysView()); + } +}); + +$(document).on('keydown', function(e) { + if (e.ctrlKey && e.keyCode === 83) { + vent.trigger(vent.Hotkeys.SaveSettings); + e.preventDefault(); + return; + } + + if ($(e.target).is('input') || $(e.target).is('textarea')) { + return; + } + + if (e.ctrlKey || e.metaKey || e.altKey) { + return; + } + + if (e.keyCode === 84) { + vent.trigger(vent.Hotkeys.NavbarSearch); + e.preventDefault(); + } +}); diff --git a/frontend/src/Hotkeys/HotkeysView.js b/frontend/src/Hotkeys/HotkeysView.js new file mode 100644 index 000000000..67575c726 --- /dev/null +++ b/frontend/src/Hotkeys/HotkeysView.js @@ -0,0 +1,6 @@ +var vent = require('vent'); +var Marionette = require('marionette'); + +module.exports = Marionette.ItemView.extend({ + template: 'Hotkeys/HotkeysViewTemplate' +}); diff --git a/frontend/src/Hotkeys/HotkeysViewTemplate.hbs b/frontend/src/Hotkeys/HotkeysViewTemplate.hbs new file mode 100644 index 000000000..b0b181603 --- /dev/null +++ b/frontend/src/Hotkeys/HotkeysViewTemplate.hbs @@ -0,0 +1,45 @@ + diff --git a/frontend/src/Hotkeys/hotkeys.less b/frontend/src/Hotkeys/hotkeys.less new file mode 100644 index 000000000..3e06b5c18 --- /dev/null +++ b/frontend/src/Hotkeys/hotkeys.less @@ -0,0 +1,23 @@ +.hotkeys-modal { + h3 { + margin-top: 0px; + margin-botton: 0px; + } + + .hotkey-group { + &:first-of-type { + margin-top: 0px; + } + + &:last-of-type { + margin-bottom: 0px; + } + + margin-top: 25px; + margin-bottom: 25px; + + .hotkey { + font-size: 22px; + } + } +} diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js new file mode 100644 index 000000000..31ea74d23 --- /dev/null +++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectEpisodeModalContentConnector from './SelectEpisodeModalContentConnector'; + +class SelectEpisodeModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectEpisodeModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectEpisodeModal; diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js new file mode 100644 index 000000000..8f8906839 --- /dev/null +++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContent.js @@ -0,0 +1,184 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import SelectEpisodeRow from './SelectEpisodeRow'; + +const columns = [ + { + name: 'episodeNumber', + label: '#', + isSortable: true, + isVisible: true + }, + { + name: 'title', + label: 'Title', + isVisible: true + }, + { + name: 'airDate', + label: 'Air Date', + isVisible: true + } +]; + +class SelectEpisodeModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {} + }; + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onEpisodesSelect = () => { + this.props.onEpisodesSelect(this.getSelectedIds()); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + sortKey, + sortDirection, + onSortPress, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState + } = this.state; + + const errorMessage = getErrorMessage(error, 'Unable to load episodes'); + + return ( + + + Manual Import - Select Episode(s) + + + + { + isFetching && + + } + + { + error && +
{errorMessage}
+ } + + { + isPopulated && !!items.length && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } + + { + isPopulated && !items.length && + 'No episodes were found for the selected season' + } +
+ + + + + + +
+ ); + } +} + +SelectEpisodeModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.string, + onSortPress: PropTypes.func.isRequired, + onEpisodesSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectEpisodeModalContent; diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js new file mode 100644 index 000000000..016cc7a66 --- /dev/null +++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeModalContentConnector.js @@ -0,0 +1,101 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { + updateInteractiveImportItem, + fetchInteractiveImportEpisodes, + setInteractiveImportEpisodesSort, + clearInteractiveImportEpisodes +} from 'Store/Actions/interactiveImportActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import SelectEpisodeModalContent from './SelectEpisodeModalContent'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('interactiveImport.episodes'), + (episodes) => { + return episodes; + } + ); +} + +const mapDispatchToProps = { + fetchInteractiveImportEpisodes, + setInteractiveImportEpisodesSort, + clearInteractiveImportEpisodes, + updateInteractiveImportItem +}; + +class SelectEpisodeModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.fetchInteractiveImportEpisodes({ seriesId, seasonNumber }); + } + + componentWillUnmount() { + // This clears the episodes for the queue and hides the queue + // We'll need another place to store episodes for manual import + this.props.clearInteractiveImportEpisodes(); + } + + // + // Listeners + + onSortPress = (sortKey, sortDirection) => { + this.props.setInteractiveImportEpisodesSort({ sortKey, sortDirection }); + } + + onEpisodesSelect = (episodeIds) => { + const episodes = _.reduce(this.props.items, (acc, item) => { + if (episodeIds.indexOf(item.id) > -1) { + acc.push(item); + } + + return acc; + }, []); + + this.props.updateInteractiveImportItem({ + id: this.props.id, + episodes: _.sortBy(episodes, 'episodeNumber') + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectEpisodeModalContentConnector.propTypes = { + id: PropTypes.number.isRequired, + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchInteractiveImportEpisodes: PropTypes.func.isRequired, + setInteractiveImportEpisodesSort: PropTypes.func.isRequired, + clearInteractiveImportEpisodes: PropTypes.func.isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectEpisodeModalContentConnector); diff --git a/frontend/src/InteractiveImport/Episode/SelectEpisodeRow.js b/frontend/src/InteractiveImport/Episode/SelectEpisodeRow.js new file mode 100644 index 000000000..ba455121a --- /dev/null +++ b/frontend/src/InteractiveImport/Episode/SelectEpisodeRow.js @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; + +class SelectEpisodeRow extends Component { + + // + // Listeners + + onPress = () => { + const { + id, + isSelected + } = this.props; + + this.props.onSelectedChange({ id, value: !isSelected }); + } + + // + // Render + + render() { + const { + id, + episodeNumber, + title, + airDate, + isSelected, + onSelectedChange + } = this.props; + + return ( + + + + + {episodeNumber} + + + + {title} + + + + {airDate} + + + ); + } +} + +SelectEpisodeRow.propTypes = { + id: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + airDate: PropTypes.string.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +export default SelectEpisodeRow; diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css new file mode 100644 index 000000000..86418a2dd --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css @@ -0,0 +1,24 @@ +.recentFoldersContainer { + margin-top: 15px; +} + +.buttonsContainer { + margin-top: 30px; +} + +.buttonContainer { + display: flex; + justify-content: center; + + margin-top: 10px; +} + +.button { + composes: button from 'Components/Link/Button.css'; + + width: 300px; +} + +.buttonIcon { + margin-right: 5px; +} diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js new file mode 100644 index 000000000..78df1f53e --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js @@ -0,0 +1,168 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import PathInputConnector from 'Components/Form/PathInputConnector'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import RecentFolderRow from './RecentFolderRow'; +import styles from './InteractiveImportSelectFolderModalContent.css'; + +const recentFoldersColumns = [ + { + name: 'folder', + label: 'Folder' + }, + { + name: 'lastUsed', + label: 'Last Used' + }, + { + name: 'actions', + label: '' + } +]; + +class InteractiveImportSelectFolderModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + folder: '' + }; + } + + // + // Listeners + + onPathChange = ({ value }) => { + this.setState({ folder: value }); + } + + onRecentPathPress = (folder) => { + this.setState({ folder }); + } + + onQuickImportPress = () => { + this.props.onQuickImportPress(this.state.folder); + } + + onInteractiveImportPress = () => { + this.props.onInteractiveImportPress(this.state.folder); + } + + // + // Render + + render() { + const { + recentFolders, + onRemoveRecentFolderPress, + onModalClose + } = this.props; + + const folder = this.state.folder; + + return ( + + + Manual Import - Select Folder + + + + + + { + !!recentFolders.length && +
+ + + { + recentFolders.map((recentFolder) => { + return ( + + ); + }) + } + +
+
+ } + +
+
+ +
+ +
+ +
+
+
+ + + + +
+ ); + } +} + +InteractiveImportSelectFolderModalContent.propTypes = { + recentFolders: PropTypes.arrayOf(PropTypes.object).isRequired, + onQuickImportPress: PropTypes.func.isRequired, + onInteractiveImportPress: PropTypes.func.isRequired, + onRemoveRecentFolderPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default InteractiveImportSelectFolderModalContent; diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js new file mode 100644 index 000000000..92d729800 --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { addRecentFolder, removeRecentFolder } from 'Store/Actions/interactiveImportActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import InteractiveImportSelectFolderModalContent from './InteractiveImportSelectFolderModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.interactiveImport.recentFolders, + (recentFolders) => { + return { + recentFolders + }; + } + ); +} + +const mapDispatchToProps = { + addRecentFolder, + removeRecentFolder, + executeCommand +}; + +class InteractiveImportSelectFolderModalContentConnector extends Component { + + // + // Listeners + + onQuickImportPress = (folder) => { + this.props.addRecentFolder({ folder }); + + this.props.executeCommand({ + name: commandNames.DOWNLOADED_EPSIODES_SCAN, + path: folder + }); + + this.props.onModalClose(); + } + + onInteractiveImportPress = (folder) => { + this.props.addRecentFolder({ folder }); + this.props.onFolderSelect(folder); + } + + onRemoveRecentFolderPress = (folder) => { + this.props.removeRecentFolder({ folder }); + } + + // + // Render + + render() { + if (this.path) { + return null; + } + + return ( + + ); + } +} + +InteractiveImportSelectFolderModalContentConnector.propTypes = { + path: PropTypes.string, + onFolderSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + addRecentFolder: PropTypes.func.isRequired, + removeRecentFolder: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportSelectFolderModalContentConnector); diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.css b/frontend/src/InteractiveImport/Folder/RecentFolderRow.css new file mode 100644 index 000000000..edb55b075 --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.css @@ -0,0 +1,5 @@ +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 40px; +} diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.js b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js new file mode 100644 index 000000000..403bce33d --- /dev/null +++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import styles from './RecentFolderRow.css'; + +class RecentFolderRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onPress(this.props.folder); + } + + onRemovePress = (event) => { + event.stopPropagation(); + + const { + folder, + onRemoveRecentFolderPress + } = this.props; + + onRemoveRecentFolderPress(folder); + } + + // + // Render + + render() { + const { + folder, + lastUsed + } = this.props; + + return ( + + {folder} + + + + + + + + ); + } +} + +RecentFolderRow.propTypes = { + folder: PropTypes.string.isRequired, + lastUsed: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired, + onRemoveRecentFolderPress: PropTypes.func.isRequired +}; + +export default RecentFolderRow; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css new file mode 100644 index 000000000..5bad6c050 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css @@ -0,0 +1,73 @@ +.filterContainer { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.filterText { + margin-left: 5px; +} + +.footer { + composes: modalFooter from 'Components/Modal/ModalFooter.css'; + + justify-content: space-between; + padding: 15px; +} + +.leftButtons, +.centerButtons, +.rightButtons { + display: flex; + flex: 1 0 33%; + flex-wrap: wrap; +} + +.centerButtons { + justify-content: center; +} + +.rightButtons { + justify-content: flex-end; +} + +.importMode { + composes: select from 'Components/Form/SelectInput.css'; + + width: auto; +} + +.errorMessage { + color: $dangerColor; +} + +@media only screen and (max-width: $breakpointSmall) { + .footer { + .leftButtons, + .centerButtons, + .rightButtons { + flex-direction: column; + } + + .leftButtons { + align-items: flex-start; + } + + .centerButtons { + align-items: center; + } + + .rightButtons { + align-items: flex-end; + } + + a, + button { + margin-left: 0; + + &:first-child { + margin-bottom: 5px; + } + } + } +} diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js new file mode 100644 index 000000000..f0eaf0965 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -0,0 +1,402 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import SelectInput from 'Components/Form/SelectInput'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import SelectedMenuItem from 'Components/Menu/SelectedMenuItem'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; +import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal'; +import InteractiveImportRow from './InteractiveImportRow'; +import styles from './InteractiveImportModalContent.css'; + +const columns = [ + { + name: 'relativePath', + label: 'Relative Path', + isSortable: true, + isVisible: true + }, + { + name: 'series', + label: 'Series', + isSortable: true, + isVisible: true + }, + { + name: 'season', + label: 'Season', + isVisible: true + }, + { + name: 'episodes', + label: 'Episode(s)', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'language', + label: 'Language', + isSortable: true, + isVisible: true + }, + { + name: 'size', + label: 'Size', + isVisible: true + }, + { + name: 'rejections', + label: React.createElement(Icon, { + name: icons.DANGER, + kind: kinds.DANGER + }), + isVisible: true + } +]; + +const filterExistingFilesOptions = { + ALL: 'all', + NEW: 'new' +}; + +class InteractiveImportModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + invalidRowsSelected: [], + isSelectSeriesModalOpen: false, + isSelectSeasonModalOpen: false + }; + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onValidRowChange = (id, isValid) => { + this.setState((state) => { + if (isValid) { + return { + invalidRowsSelected: _.without(state.invalidRowsSelected, id) + }; + } + + return { + invalidRowsSelected: [...state.invalidRowsSelected, id] + }; + }); + } + + onImportSelectedPress = () => { + const { + downloadId, + showImportMode, + importMode, + onImportSelectedPress + } = this.props; + + const selected = this.getSelectedIds(); + const finalImportMode = downloadId || !showImportMode ? 'auto' : importMode; + + onImportSelectedPress(selected, finalImportMode); + } + + onFilterExistingFilesChange = (value) => { + this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL); + } + + onImportModeChange = ({ value }) => { + this.props.onImportModeChange(value); + } + + onSelectSeriesPress = () => { + this.setState({ isSelectSeriesModalOpen: true }); + } + + onSelectSeasonPress = () => { + this.setState({ isSelectSeasonModalOpen: true }); + } + + onSelectSeriesModalClose = () => { + this.setState({ isSelectSeriesModalOpen: false }); + } + + onSelectSeasonModalClose = () => { + this.setState({ isSelectSeasonModalOpen: false }); + } + + // + // Render + + render() { + const { + downloadId, + allowSeriesChange, + showFilterExistingFiles, + showImportMode, + filterExistingFiles, + title, + folder, + isFetching, + isPopulated, + error, + items, + sortKey, + sortDirection, + importMode, + interactiveImportErrorMessage, + onSortPress, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + invalidRowsSelected, + isSelectSeriesModalOpen, + isSelectSeasonModalOpen + } = this.state; + + const selectedIds = this.getSelectedIds(); + const selectedItem = selectedIds.length ? _.find(items, { id: selectedIds[0] }) : null; + const errorMessage = getErrorMessage(error, 'Unable to load manual import items'); + + const importModeOptions = [ + { key: 'move', value: 'Move Files' }, + { key: 'copy', value: 'Copy Files' } + ]; + + return ( + + + Manual Import - {title || folder} + + + + { + showFilterExistingFiles && +
+ + + + +
+ { + filterExistingFiles ? 'Unmapped Files Only' : 'All Files' + } +
+
+ + + + All Files + + + + Unmapped Files Only + + +
+
+ } + + { + isFetching && + + } + + { + error && +
{errorMessage}
+ } + + { + isPopulated && !!items.length && !isFetching && !isFetching && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } + + { + isPopulated && !items.length && !isFetching && + 'No video files were found in the selected folder' + } +
+ + +
+ { + !downloadId && showImportMode && + + } +
+ +
+ { + allowSeriesChange && + + } + + +
+ +
+ + + { + 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..d95af5d72 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js @@ -0,0 +1,370 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Popover from 'Components/Tooltip/Popover'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; +import SelectSeriesModal from 'InteractiveImport/Series/SelectSeriesModal'; +import SelectSeasonModal from 'InteractiveImport/Season/SelectSeasonModal'; +import SelectEpisodeModal from 'InteractiveImport/Episode/SelectEpisodeModal'; +import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; +import SelectLanguageModal from 'InteractiveImport/Language/SelectLanguageModal'; +import InteractiveImportRowCellPlaceholder from './InteractiveImportRowCellPlaceholder'; +import styles from './InteractiveImportRow.css'; + +class InteractiveImportRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isSelectSeriesModalOpen: false, + isSelectSeasonModalOpen: false, + isSelectEpisodeModalOpen: false, + isSelectQualityModalOpen: false, + isSelectLanguageModalOpen: false + }; + } + + componentDidMount() { + const { + id, + series, + seasonNumber, + episodes, + quality, + language + } = this.props; + + if ( + series && + seasonNumber != null && + episodes.length && + quality && + language + ) { + this.props.onSelectedChange({ id, value: true }); + } + } + + componentDidUpdate(prevProps) { + const { + id, + series, + seasonNumber, + episodes, + quality, + language, + isSelected, + onValidRowChange + } = this.props; + + if ( + prevProps.series === series && + prevProps.seasonNumber === seasonNumber && + !hasDifferentItems(prevProps.episodes, episodes) && + prevProps.quality === quality && + prevProps.language === language && + prevProps.isSelected === isSelected + ) { + return; + } + + const isValid = !!( + series && + seasonNumber != null && + episodes.length && + quality && + language + ); + + if (isSelected && !isValid) { + onValidRowChange(id, false); + } else { + onValidRowChange(id, true); + } + } + + // + // Control + + selectRowAfterChange = (value) => { + const { + id, + isSelected + } = this.props; + + if (!isSelected && value === true) { + this.props.onSelectedChange({ id, value }); + } + } + + // + // Listeners + + onSelectSeriesPress = () => { + this.setState({ isSelectSeriesModalOpen: true }); + } + + onSelectSeasonPress = () => { + this.setState({ isSelectSeasonModalOpen: true }); + } + + onSelectEpisodePress = () => { + this.setState({ isSelectEpisodeModalOpen: true }); + } + + onSelectQualityPress = () => { + this.setState({ isSelectQualityModalOpen: true }); + } + + onSelectLanguagePress = () => { + this.setState({ isSelectLanguageModalOpen: true }); + } + + onSelectSeriesModalClose = (changed) => { + this.setState({ isSelectSeriesModalOpen: false }); + this.selectRowAfterChange(changed); + } + + onSelectSeasonModalClose = (changed) => { + this.setState({ isSelectSeasonModalOpen: false }); + this.selectRowAfterChange(changed); + } + + onSelectEpisodeModalClose = (changed) => { + this.setState({ isSelectEpisodeModalOpen: false }); + this.selectRowAfterChange(changed); + } + + onSelectQualityModalClose = (changed) => { + this.setState({ isSelectQualityModalOpen: false }); + this.selectRowAfterChange(changed); + } + + onSelectLanguageModalClose = (changed) => { + this.setState({ isSelectLanguageModalOpen: false }); + this.selectRowAfterChange(changed); + } + + // + // Render + + render() { + const { + id, + allowSeriesChange, + relativePath, + series, + seasonNumber, + episodes, + quality, + language, + size, + rejections, + isSelected, + onSelectedChange + } = this.props; + + const { + isSelectSeriesModalOpen, + isSelectSeasonModalOpen, + isSelectEpisodeModalOpen, + isSelectQualityModalOpen, + isSelectLanguageModalOpen + } = this.state; + + const seriesTitle = series ? series.title : ''; + const episodeNumbers = episodes.map((episode) => episode.episodeNumber) + .join(', '); + + const showSeriesPlaceholder = isSelected && !series; + const showSeasonNumberPlaceholder = isSelected && !!series && isNaN(seasonNumber); + const showEpisodeNumbersPlaceholder = isSelected && Number.isInteger(seasonNumber) && !episodes.length; + const showQualityPlaceholder = isSelected && !quality; + const showLanguagePlaceholder = isSelected && !language; + + return ( + + + + + {relativePath} + + + + { + showSeriesPlaceholder ? : seriesTitle + } + + + + { + showSeasonNumberPlaceholder ? : seasonNumber + } + + + + { + showEpisodeNumbersPlaceholder ? : episodeNumbers + } + + + + { + showQualityPlaceholder && + + } + + { + !showQualityPlaceholder && !!quality && + + } + + + + { + showLanguagePlaceholder && + + } + + { + !showLanguagePlaceholder && !!language && + + } + + + + {formatBytes(size)} + + + + { + !!rejections.length && + + } + title="Release Rejected" + body={ +
    + { + rejections.map((rejection, index) => { + return ( +
  • + {rejection.reason} +
  • + ); + }) + } +
+ } + position={tooltipPositions.LEFT} + /> + } +
+ + + + + + + + 1 : false} + real={quality ? quality.revision.real > 0 : false} + onModalClose={this.onSelectQualityModalClose} + /> + + +
+ ); + } + +} + +InteractiveImportRow.propTypes = { + id: PropTypes.number.isRequired, + allowSeriesChange: PropTypes.bool.isRequired, + relativePath: PropTypes.string.isRequired, + series: PropTypes.object, + seasonNumber: PropTypes.number, + episodes: PropTypes.arrayOf(PropTypes.object).isRequired, + quality: PropTypes.object, + language: PropTypes.object, + size: PropTypes.number.isRequired, + rejections: PropTypes.arrayOf(PropTypes.object).isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired, + onValidRowChange: PropTypes.func.isRequired +}; + +InteractiveImportRow.defaultProps = { + episodes: [] +}; + +export default InteractiveImportRow; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css new file mode 100644 index 000000000..941988144 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css @@ -0,0 +1,7 @@ +.placeholder { + display: inline-block; + margin: -8px 0; + width: 100%; + height: 25px; + border: 2px dashed $dangerColor; +} diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js new file mode 100644 index 000000000..b6744d156 --- /dev/null +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './InteractiveImportRowCellPlaceholder.css'; + +function InteractiveImportRowCellPlaceholder() { + return ( + + ); +} + +export default InteractiveImportRowCellPlaceholder; diff --git a/frontend/src/InteractiveImport/InteractiveImportModal.js b/frontend/src/InteractiveImport/InteractiveImportModal.js new file mode 100644 index 000000000..0ea6fd9cb --- /dev/null +++ b/frontend/src/InteractiveImport/InteractiveImportModal.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import InteractiveImportSelectFolderModalContentConnector from './Folder/InteractiveImportSelectFolderModalContentConnector'; +import InteractiveImportModalContentConnector from './Interactive/InteractiveImportModalContentConnector'; + +class InteractiveImportModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + folder: null + }; + } + + componentDidUpdate(prevProps) { + if (prevProps.isOpen && !this.props.isOpen) { + this.setState({ folder: null }); + } + } + + // + // Listeners + + onFolderSelect = (folder) => { + this.setState({ folder }); + } + + // + // Render + + render() { + const { + isOpen, + folder, + downloadId, + onModalClose, + ...otherProps + } = this.props; + + const folderPath = folder || this.state.folder; + + return ( + + { + folderPath || downloadId ? + : + + } + + ); + } +} + +InteractiveImportModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + folder: PropTypes.string, + downloadId: PropTypes.string, + onModalClose: PropTypes.func.isRequired +}; + +export default InteractiveImportModal; diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModal.js b/frontend/src/InteractiveImport/Language/SelectLanguageModal.js new file mode 100644 index 000000000..938d26a6d --- /dev/null +++ b/frontend/src/InteractiveImport/Language/SelectLanguageModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectLanguageModalContentConnector from './SelectLanguageModalContentConnector'; + +class SelectLanguageModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectLanguageModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectLanguageModal; diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js b/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js new file mode 100644 index 000000000..ff99ce6bf --- /dev/null +++ b/frontend/src/InteractiveImport/Language/SelectLanguageModalContent.js @@ -0,0 +1,87 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +function SelectLanguageModalContent(props) { + const { + languageId, + isFetching, + isPopulated, + error, + items, + onModalClose, + onLanguageSelect + } = props; + + const languageOptions = items.map(({ language }) => { + return { + key: language.id, + value: language.name + }; + }); + + return ( + + + Manual Import - Select Language + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to load languages
+ } + + { + isPopulated && !error && +
+ + Language + + + +
+ } +
+ + + + +
+ ); +} + +SelectLanguageModalContent.propTypes = { + languageId: PropTypes.number.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onLanguageSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectLanguageModalContent; diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js b/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js new file mode 100644 index 000000000..56e95b861 --- /dev/null +++ b/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js @@ -0,0 +1,87 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchLanguageProfileSchema } from 'Store/Actions/settingsActions'; +import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; +import SelectLanguageModalContent from './SelectLanguageModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.languageProfiles, + (languageProfiles) => { + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + schema + } = languageProfiles; + + return { + isFetching, + isPopulated, + error, + items: schema.languages ? [...schema.languages].reverse() : [] + }; + } + ); +} + +const mapDispatchToProps = { + fetchLanguageProfileSchema, + updateInteractiveImportItem +}; + +class SelectLanguageModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount = () => { + if (!this.props.isPopulated) { + this.props.fetchLanguageProfileSchema(); + } + } + + // + // Listeners + + onLanguageSelect = ({ value }) => { + const languageId = parseInt(value); + const language = _.find(this.props.items, + (item) => item.language.id === languageId).language; + + this.props.updateInteractiveImportItem({ + id: this.props.id, + language + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectLanguageModalContentConnector.propTypes = { + id: PropTypes.number.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchLanguageProfileSchema: PropTypes.func.isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectLanguageModalContentConnector); diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModal.js b/frontend/src/InteractiveImport/Quality/SelectQualityModal.js new file mode 100644 index 000000000..d3e31d2dd --- /dev/null +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectQualityModalContentConnector from './SelectQualityModalContentConnector'; + +class SelectQualityModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectQualityModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectQualityModal; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js new file mode 100644 index 000000000..642e0433e --- /dev/null +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +class SelectQualityModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + qualityId, + proper, + real + } = props; + + this.state = { + qualityId, + proper, + real + }; + } + + // + // Listeners + + onQualityChange = ({ value }) => { + this.setState({ qualityId: parseInt(value) }); + } + + onProperChange = ({ value }) => { + this.setState({ proper: value }); + } + + onRealChange = ({ value }) => { + this.setState({ real: value }); + } + + onQualitySelect = () => { + this.props.onQualitySelect(this.state); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + onModalClose + } = this.props; + + const { + qualityId, + proper, + real + } = this.state; + + const qualityOptions = items.map(({ id, name }) => { + return { + key: id, + value: name + }; + }); + + return ( + + + Manual Import - Select Quality + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to load qualities
+ } + + { + isPopulated && !error && +
+ + 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/Season/SelectSeasonModal.js b/frontend/src/InteractiveImport/Season/SelectSeasonModal.js new file mode 100644 index 000000000..9de9ee493 --- /dev/null +++ b/frontend/src/InteractiveImport/Season/SelectSeasonModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectSeasonModalContentConnector from './SelectSeasonModalContentConnector'; + +class SelectSeasonModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectSeasonModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectSeasonModal; diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js b/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js new file mode 100644 index 000000000..267174491 --- /dev/null +++ b/frontend/src/InteractiveImport/Season/SelectSeasonModalContent.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import SelectSeasonRow from './SelectSeasonRow'; + +class SelectSeasonModalContent extends Component { + + // + // Render + + render() { + const { + items, + onSeasonSelect, + onModalClose + } = this.props; + + return ( + + + Manual Import - Select Season + + + + { + items.map((item) => { + return ( + + ); + }) + } + + + + + + + ); + } +} + +SelectSeasonModalContent.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onSeasonSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectSeasonModalContent; diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js b/frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js new file mode 100644 index 000000000..b84fdf148 --- /dev/null +++ b/frontend/src/InteractiveImport/Season/SelectSeasonModalContentConnector.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import SelectSeasonModalContent from './SelectSeasonModalContent'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + (series) => { + if (!series) { + return { + items: [] + }; + } + + return { + items: series.seasons.slice(0).reverse() + }; + } + ); +} + +const mapDispatchToProps = { + updateInteractiveImportItem +}; + +class SelectSeasonModalContentConnector extends Component { + + // + // Listeners + + onSeasonSelect = (seasonNumber) => { + this.props.ids.forEach((id) => { + this.props.updateInteractiveImportItem({ + id, + seasonNumber, + episodes: [] + }); + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectSeasonModalContentConnector.propTypes = { + ids: PropTypes.arrayOf(PropTypes.number).isRequired, + seriesId: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectSeasonModalContentConnector); diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonRow.css b/frontend/src/InteractiveImport/Season/SelectSeasonRow.css new file mode 100644 index 000000000..c43d879f4 --- /dev/null +++ b/frontend/src/InteractiveImport/Season/SelectSeasonRow.css @@ -0,0 +1,4 @@ +.season { + padding: 8px; + border-bottom: 1px solid $borderColor; +} diff --git a/frontend/src/InteractiveImport/Season/SelectSeasonRow.js b/frontend/src/InteractiveImport/Season/SelectSeasonRow.js new file mode 100644 index 000000000..d6cf5aea1 --- /dev/null +++ b/frontend/src/InteractiveImport/Season/SelectSeasonRow.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './SelectSeasonRow.css'; + +class SelectSeasonRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onSeasonSelect(this.props.seasonNumber); + } + + // + // Render + + render() { + const seasonNumber = this.props.seasonNumber; + + return ( + + { + seasonNumber === 0 ? 'Specials' : `Season ${seasonNumber}` + } + + ); + } +} + +SelectSeasonRow.propTypes = { + seasonNumber: PropTypes.number.isRequired, + onSeasonSelect: PropTypes.func.isRequired +}; + +export default SelectSeasonRow; diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModal.js b/frontend/src/InteractiveImport/Series/SelectSeriesModal.js new file mode 100644 index 000000000..1a1ceffca --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectSeriesModalContentConnector from './SelectSeriesModalContentConnector'; + +class SelectSeriesModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectSeriesModal; diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css new file mode 100644 index 000000000..c22d502f5 --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.css @@ -0,0 +1,18 @@ +.modalBody { + composes: modalBody from 'Components/Modal/ModalBody.css'; + + display: flex; + flex: 1 1 auto; + flex-direction: column; +} + +.filterInput { + composes: input from 'Components/Form/TextInput.css'; + + flex: 0 0 auto; + margin-bottom: 20px; +} + +.scroller { + flex: 1 1 auto; +} diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js new file mode 100644 index 000000000..1b4cb52fe --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContent.js @@ -0,0 +1,99 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Scroller from 'Components/Scroller/Scroller'; +import TextInput from 'Components/Form/TextInput'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import SelectSeriesRow from './SelectSeriesRow'; +import styles from './SelectSeriesModalContent.css'; + +class SelectSeriesModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + filter: '' + }; + } + + // + // Listeners + + onFilterChange = ({ value }) => { + this.setState({ filter: value.toLowerCase() }); + } + + // + // Render + + render() { + const { + items, + onSeriesSelect, + onModalClose + } = this.props; + + const filter = this.state.filter; + + return ( + + + Manual Import - Select Series + + + + + + + { + items.map((item) => { + return item.title.toLowerCase().includes(filter) ? + ( + + ) : + null; + }) + } + + + + + + + + ); + } +} + +SelectSeriesModalContent.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onSeriesSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectSeriesModalContent; diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js b/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js new file mode 100644 index 000000000..07dab9f0b --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesModalContentConnector.js @@ -0,0 +1,75 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import SelectSeriesModalContent from './SelectSeriesModalContent'; + +function createMapStateToProps() { + return createSelector( + createAllSeriesSelector(), + (items) => { + return { + items: items.sort((a, b) => { + if (a.sortTitle < b.sortTitle) { + return -1; + } + + if (a.sortTitle > b.sortTitle) { + return 1; + } + + return 0; + }) + }; + } + ); +} + +const mapDispatchToProps = { + updateInteractiveImportItem +}; + +class SelectSeriesModalContentConnector extends Component { + + // + // Listeners + + onSeriesSelect = (seriesId) => { + const series = _.find(this.props.items, { id: seriesId }); + + this.props.ids.forEach((id) => { + this.props.updateInteractiveImportItem({ + id, + series, + seasonNumber: undefined, + episodes: [] + }); + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SelectSeriesModalContentConnector.propTypes = { + ids: PropTypes.arrayOf(PropTypes.number).isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SelectSeriesModalContentConnector); diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesRow.css b/frontend/src/InteractiveImport/Series/SelectSeriesRow.css new file mode 100644 index 000000000..f2573d585 --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesRow.css @@ -0,0 +1,4 @@ +.series { + padding: 8px; + border-bottom: 1px solid $borderColor; +} diff --git a/frontend/src/InteractiveImport/Series/SelectSeriesRow.js b/frontend/src/InteractiveImport/Series/SelectSeriesRow.js new file mode 100644 index 000000000..49af64ecf --- /dev/null +++ b/frontend/src/InteractiveImport/Series/SelectSeriesRow.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import styles from './SelectSeriesRow.css'; + +class SelectSeriesRow extends Component { + + // + // Listeners + + onPress = () => { + this.props.onSeriesSelect(this.props.id); + } + + // + // Render + + render() { + return ( + + {this.props.title} + + ); + } +} + +SelectSeriesRow.propTypes = { + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + onSeriesSelect: PropTypes.func.isRequired +}; + +export default SelectSeriesRow; diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.css b/frontend/src/InteractiveSearch/InteractiveSearch.css new file mode 100644 index 000000000..5e647332f --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearch.css @@ -0,0 +1,9 @@ +.filterMenuContainer { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.filteredMessage { + margin-top: 10px; +} diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.js b/frontend/src/InteractiveSearch/InteractiveSearch.js new file mode 100644 index 000000000..862bc0c55 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearch.js @@ -0,0 +1,207 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, icons, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Icon from 'Components/Icon'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageMenuButton from 'Components/Menu/PageMenuButton'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector'; +import InteractiveSearchRow from './InteractiveSearchRow'; +import styles from './InteractiveSearch.css'; + +const columns = [ + { + name: 'protocol', + label: 'Source', + isSortable: true, + isVisible: true + }, + { + name: 'age', + label: 'Age', + isSortable: true, + isVisible: true + }, + { + name: 'title', + label: 'Title', + isSortable: true, + isVisible: true + }, + { + name: 'indexer', + label: 'Indexer', + isSortable: true, + isVisible: true + }, + { + name: 'size', + label: 'Size', + isSortable: true, + isVisible: true + }, + { + name: 'peers', + label: 'Peers', + isSortable: true, + isVisible: true + }, + { + name: 'languageWeight', + label: 'Language', + isSortable: true, + isVisible: true + }, + { + name: 'qualityWeight', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'preferredWordScore', + label: React.createElement(Icon, { + name: icons.SCORE, + title: 'Preferred word score' + }), + isSortable: true, + isVisible: true + }, + { + name: 'rejections', + label: React.createElement(Icon, { + name: icons.DANGER, + title: 'Rejections' + }), + isSortable: true, + fixedSortDirection: sortDirections.ASCENDING, + isVisible: true + }, + { + name: 'releaseWeight', + label: React.createElement(Icon, { name: icons.DOWNLOAD }), + isSortable: true, + fixedSortDirection: sortDirections.ASCENDING, + isVisible: true + } +]; + +function InteractiveSearch(props) { + const { + isFetching, + isPopulated, + error, + totalReleasesCount, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + type, + longDateFormat, + timeFormat, + onSortPress, + onFilterSelect, + onGrabPress + } = props; + + return ( +
+
+ +
+ + { + isFetching && + + } + + { + !isFetching && !!error && +
+ Unable to load results for this episode search. Try again later +
+ } + + { + !isFetching && isPopulated && !totalReleasesCount && +
+ No results found +
+ } + + { + !!totalReleasesCount && isPopulated && !items.length && +
+ All results are hidden by the applied filter +
+ } + + { + isPopulated && !!items.length && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } + + { + totalReleasesCount !== items.length && !!items.length && +
+ Some results are hidden by the applied filter +
+ } +
+ ); +} + +InteractiveSearch.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalReleasesCount: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.string, + type: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onSortPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onGrabPress: PropTypes.func.isRequired +}; + +export default InteractiveSearch; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js new file mode 100644 index 000000000..601f337d6 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js @@ -0,0 +1,94 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import * as releaseActions from 'Store/Actions/releaseActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import InteractiveSearch from './InteractiveSearch'; + +function createMapStateToProps(appState, { type }) { + return createSelector( + (state) => state.releases.items.length, + createClientSideCollectionSelector('releases', `releases.${type}`), + createUISettingsSelector(), + (totalReleasesCount, releases, uiSettings) => { + return { + totalReleasesCount, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat, + ...releases + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchReleases(payload) { + dispatch(releaseActions.fetchReleases(payload)); + }, + + onSortPress(sortKey, sortDirection) { + dispatch(releaseActions.setReleasesSort({ sortKey, sortDirection })); + }, + + onFilterSelect(selectedFilterKey) { + const action = props.type === 'episode' ? + releaseActions.setEpisodeReleasesFilter : + releaseActions.setSeasonReleasesFilter; + + dispatch(action({ selectedFilterKey })); + }, + + onGrabPress(guid, indexerId) { + dispatch(releaseActions.grabRelease({ guid, indexerId })); + } + }; +} + +class InteractiveSearchConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + searchPayload, + isPopulated, + dispatchFetchReleases + } = this.props; + + // If search results are not yet isPopulated fetch them, + // otherwise re-show the existing props. + + if (!isPopulated) { + dispatchFetchReleases(searchPayload); + } + } + + // + // Render + + render() { + const { + dispatchFetchReleases, + ...otherProps + } = this.props; + + return ( + + + ); + } +} + +InteractiveSearchConnector.propTypes = { + searchPayload: PropTypes.object.isRequired, + isPopulated: PropTypes.bool.isRequired, + dispatchFetchReleases: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js new file mode 100644 index 000000000..dcbcf340f --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js @@ -0,0 +1,32 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setEpisodeReleasesFilter, setSeasonReleasesFilter } from 'Store/Actions/releaseActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.releases.items, + (state) => state.releases.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'releases' + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchSetFilter(payload) { + const action = props.type === 'episode' ? + setEpisodeReleasesFilter: + setSeasonReleasesFilter; + + dispatch(action(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css new file mode 100644 index 000000000..98503e496 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css @@ -0,0 +1,38 @@ +.title { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} + +.quality, +.language { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + text-align: center; +} + +.language { + width: 100px; +} + +.preferredWordScore { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 55px; + font-weight: bold; + cursor: default; +} + +.rejected, +.download { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.age, +.size { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + white-space: nowrap; +} diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js new file mode 100644 index 000000000..4f8c5089f --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.js @@ -0,0 +1,217 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import Link from 'Components/Link/Link'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import Peers from './Peers'; +import styles from './InteractiveSearchRow.css'; + +function getDownloadIcon(isGrabbing, isGrabbed, grabError) { + if (isGrabbing) { + return icons.SPINNER; + } else if (isGrabbed) { + return icons.DOWNLOADING; + } else if (grabError) { + return icons.DOWNLOADING; + } + + return icons.DOWNLOAD; +} + +function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { + if (isGrabbing) { + return ''; + } else if (isGrabbed) { + return 'Added to downloaded queue'; + } else if (grabError) { + return grabError; + } + + return 'Add to downloaded queue'; +} + +class InteractiveSearchRow extends Component { + + // + // Listeners + + onGrabPress = () => { + const { + guid, + indexerId, + onGrabPress + }= this.props; + + onGrabPress(guid, indexerId); + } + + // + // Render + + render() { + const { + protocol, + age, + ageHours, + ageMinutes, + publishDate, + title, + infoUrl, + indexer, + size, + seeders, + leechers, + quality, + language, + preferredWordScore, + rejections, + downloadAllowed, + isGrabbing, + isGrabbed, + longDateFormat, + timeFormat, + grabError + } = this.props; + + return ( + + + + + + + {formatAge(age, ageHours, ageMinutes)} + + + + + {title} + + + + + {indexer} + + + + {formatBytes(size)} + + + + { + protocol === 'torrent' && + + } + + + + + + + + + + + + {preferredWordScore > 0 && `+${preferredWordScore}`} + {preferredWordScore < 0 && preferredWordScore} + + + + { + !!rejections.length && + + } + title="Release Rejected" + body={ +
    + { + rejections.map((rejection, index) => { + return ( +
  • + {rejection} +
  • + ); + }) + } +
+ } + position={tooltipPositions.LEFT} + /> + } +
+ + + { + downloadAllowed && + + } + +
+ ); + } +} + +InteractiveSearchRow.propTypes = { + guid: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + age: PropTypes.number.isRequired, + ageHours: PropTypes.number.isRequired, + ageMinutes: PropTypes.number.isRequired, + publishDate: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + infoUrl: PropTypes.string.isRequired, + indexerId: PropTypes.number.isRequired, + indexer: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + seeders: PropTypes.number, + leechers: PropTypes.number, + quality: PropTypes.object.isRequired, + language: PropTypes.object.isRequired, + preferredWordScore: PropTypes.number.isRequired, + rejections: PropTypes.arrayOf(PropTypes.string).isRequired, + downloadAllowed: PropTypes.bool.isRequired, + isGrabbing: PropTypes.bool.isRequired, + isGrabbed: PropTypes.bool.isRequired, + grabError: PropTypes.string, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onGrabPress: PropTypes.func.isRequired +}; + +InteractiveSearchRow.defaultProps = { + rejections: [], + isGrabbing: false, + isGrabbed: false +}; + +export default InteractiveSearchRow; diff --git a/frontend/src/InteractiveSearch/Peers.js b/frontend/src/InteractiveSearch/Peers.js new file mode 100644 index 000000000..66f7cc9f5 --- /dev/null +++ b/frontend/src/InteractiveSearch/Peers.js @@ -0,0 +1,57 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function getKind(seeders) { + if (seeders > 50) { + return kinds.PRIMARY; + } + + if (seeders > 10) { + return kinds.INFO; + } + + if (seeders > 0) { + return kinds.WARNING; + } + + return kinds.DANGER; +} + +function getPeersTooltipPart(peers, peersUnit) { + if (peers == null) { + return `unknown ${peersUnit}s`; + } + + if (peers === 1) { + return `1 ${peersUnit}`; + } + + return `${peers} ${peersUnit}s`; +} + +function Peers(props) { + const { + seeders, + leechers + } = props; + + const kind = getKind(seeders); + + return ( + + ); +} + +Peers.propTypes = { + seeders: PropTypes.number, + leechers: PropTypes.number +}; + +export default Peers; diff --git a/frontend/src/Organize/OrganizePreviewModal.js b/frontend/src/Organize/OrganizePreviewModal.js new file mode 100644 index 000000000..647f4ddf8 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModal.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import OrganizePreviewModalContentConnector from './OrganizePreviewModalContentConnector'; + +function OrganizePreviewModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + { + isOpen && + + } + + ); +} + +OrganizePreviewModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default OrganizePreviewModal; diff --git a/frontend/src/Organize/OrganizePreviewModalConnector.js b/frontend/src/Organize/OrganizePreviewModalConnector.js new file mode 100644 index 000000000..ace733c86 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearOrganizePreview } from 'Store/Actions/organizePreviewActions'; +import OrganizePreviewModal from './OrganizePreviewModal'; + +const mapDispatchToProps = { + clearOrganizePreview +}; + +class OrganizePreviewModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearOrganizePreview(); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +OrganizePreviewModalConnector.propTypes = { + clearOrganizePreview: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(OrganizePreviewModalConnector); diff --git a/frontend/src/Organize/OrganizePreviewModalContent.css b/frontend/src/Organize/OrganizePreviewModalContent.css new file mode 100644 index 000000000..7de056fcc --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalContent.css @@ -0,0 +1,24 @@ +.path { + margin-left: 5px; + font-weight: bold; +} + +.episodeFormat { + margin-left: 5px; + font-family: $monoSpaceFontFamily; +} + +.previews { + margin-top: 10px; +} + +.selectAllInputContainer { + margin-right: auto; + line-height: 30px; +} + +.selectAllInput { + composes: input from 'Components/Form/CheckInput.css'; + + margin: 0; +} diff --git a/frontend/src/Organize/OrganizePreviewModalContent.js b/frontend/src/Organize/OrganizePreviewModalContent.js new file mode 100644 index 000000000..6962486df --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalContent.js @@ -0,0 +1,203 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import CheckInput from 'Components/Form/CheckInput'; +import SeasonNumber from 'Season/SeasonNumber'; +import OrganizePreviewRow from './OrganizePreviewRow'; +import styles from './OrganizePreviewModalContent.css'; + +function getValue(allSelected, allUnselected) { + if (allSelected) { + return true; + } else if (allUnselected) { + return false; + } + + return null; +} + +class OrganizePreviewModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {} + }; + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onOrganizePress = () => { + this.props.onOrganizePress(this.getSelectedIds()); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + seasonNumber, + renameEpisodes, + episodeFormat, + path, + onModalClose + } = this.props; + + const { + allSelected, + allUnselected, + selectedState + } = this.state; + + const selectAllValue = getValue(allSelected, allUnselected); + + return ( + + + Organize & Rename {seasonNumber != null && } + + + + { + isFetching && + + } + + { + !isFetching && error && +
Error loading previews
+ } + + { + !isFetching && isPopulated && !items.length && +
+ { + renameEpisodes ? +
Success! My work is done, no files to rename.
: +
Renaming is disabled, nothing to rename
+ } +
+ } + + { + !isFetching && isPopulated && !!items.length && +
+ +
+ All paths are relative to: + + {path} + +
+ +
+ Naming pattern: + + {episodeFormat} + +
+
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+
+ } +
+ + + { + isPopulated && !!items.length && + + } + + + + + +
+ ); + } +} + +OrganizePreviewModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + seasonNumber: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + renameEpisodes: PropTypes.bool, + episodeFormat: PropTypes.string, + onOrganizePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default OrganizePreviewModalContent; diff --git a/frontend/src/Organize/OrganizePreviewModalContentConnector.js b/frontend/src/Organize/OrganizePreviewModalContentConnector.js new file mode 100644 index 000000000..cbb150407 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewModalContentConnector.js @@ -0,0 +1,91 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import { fetchOrganizePreview } from 'Store/Actions/organizePreviewActions'; +import { fetchNamingSettings } from 'Store/Actions/settingsActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import OrganizePreviewModalContent from './OrganizePreviewModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.organizePreview, + (state) => state.settings.naming, + createSeriesSelector(), + (organizePreview, naming, series) => { + const props = { ...organizePreview }; + props.isFetching = organizePreview.isFetching || naming.isFetching; + props.isPopulated = organizePreview.isPopulated && naming.isPopulated; + props.error = organizePreview.error || naming.error; + props.renameEpisodes = naming.item.renameEpisodes; + props.episodeFormat = naming.item[`${series.seriesType}EpisodeFormat`]; + props.path = series.path; + + return props; + } + ); +} + +const mapDispatchToProps = { + fetchOrganizePreview, + fetchNamingSettings, + executeCommand +}; + +class OrganizePreviewModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.fetchOrganizePreview({ + seriesId, + seasonNumber + }); + + this.props.fetchNamingSettings(); + } + + // + // Listeners + + onOrganizePress = (files) => { + this.props.executeCommand({ + name: commandNames.RENAME_FILES, + seriesId: this.props.seriesId, + files + }); + + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +OrganizePreviewModalContentConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number, + fetchOrganizePreview: PropTypes.func.isRequired, + fetchNamingSettings: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(OrganizePreviewModalContentConnector); diff --git a/frontend/src/Organize/OrganizePreviewRow.css b/frontend/src/Organize/OrganizePreviewRow.css new file mode 100644 index 000000000..1b3c8ca47 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewRow.css @@ -0,0 +1,20 @@ +.row { + display: flex; + margin-bottom: 5px; + padding: 5px 0; + border-bottom: 1px solid $borderColor; + + &:last-of-type { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } +} + +.selectedContainer { + margin-right: 30px; +} + +.path { + margin-left: 10px; +} diff --git a/frontend/src/Organize/OrganizePreviewRow.js b/frontend/src/Organize/OrganizePreviewRow.js new file mode 100644 index 000000000..340232a98 --- /dev/null +++ b/frontend/src/Organize/OrganizePreviewRow.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './OrganizePreviewRow.css'; + +class OrganizePreviewRow extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value: true }); + } + + // + // Listeners + + onSelectedChange = ({ value, shiftKey }) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + id, + existingPath, + newPath, + isSelected + } = this.props; + + return ( +
+ + +
+
+ + + + {existingPath} + +
+ +
+ + + + {newPath} + +
+
+
+ ); + } +} + +OrganizePreviewRow.propTypes = { + id: PropTypes.number.isRequired, + existingPath: PropTypes.string.isRequired, + newPath: PropTypes.string.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +export default OrganizePreviewRow; diff --git a/frontend/src/Season/SeasonNumber.js b/frontend/src/Season/SeasonNumber.js new file mode 100644 index 000000000..8db967243 --- /dev/null +++ b/frontend/src/Season/SeasonNumber.js @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; + +function SeasonNumber(props) { + const { + seasonNumber, + separator + } = props; + + if (seasonNumber === 0) { + return `${separator}Specials`; + } + + if (seasonNumber > 0) { + return `${separator}Season ${seasonNumber}`; + } + + return null; +} + +SeasonNumber.propTypes = { + seasonNumber: PropTypes.number.isRequired, + separator: PropTypes.string.isRequired +}; + +SeasonNumber.defaultProps = { + separator: '- ' +}; + +export default SeasonNumber; diff --git a/frontend/src/SeasonPass/SeasonPass.js b/frontend/src/SeasonPass/SeasonPass.js new file mode 100644 index 000000000..7907f9249 --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPass.js @@ -0,0 +1,217 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import NoSeries from 'Series/NoSeries'; +import SeasonPassFilterModalConnector from './SeasonPassFilterModalConnector'; +import SeasonPassFooter from './SeasonPassFooter'; +import SeasonPassRowConnector from './SeasonPassRowConnector'; + +const columns = [ + { + name: 'status', + isVisible: true + }, + { + name: 'sortTitle', + label: 'Title', + isSortable: true, + isVisible: true + }, + { + name: 'monitored', + isVisible: true + }, + { + name: 'seasonCount', + label: 'Seasons', + isSortable: true, + isVisible: true + } +]; + +class SeasonPass extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {} + }; + } + + componentDidUpdate(prevProps) { + const { + isSaving, + saveError + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onUpdateSelectedPress = (changes) => { + this.props.onUpdateSelectedPress({ + seriesIds: this.getSelectedIds(), + ...changes + }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + isSaving, + saveError, + onSortPress, + onFilterSelect + } = this.props; + + const { + allSelected, + allUnselected, + selectedState + } = this.state; + + return ( + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load the calendar
+ } + + { + !error && isPopulated && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+
+ } + + { + !error && isPopulated && !items.length && + + } +
+ + +
+ ); + } +} + +SeasonPass.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalItems: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + onSortPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onUpdateSelectedPress: PropTypes.func.isRequired +}; + +export default SeasonPass; diff --git a/frontend/src/SeasonPass/SeasonPassConnector.js b/frontend/src/SeasonPass/SeasonPassConnector.js new file mode 100644 index 000000000..990c5f264 --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassConnector.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { setSeasonPassSort, setSeasonPassFilter, saveSeasonPass } from 'Store/Actions/seasonPassActions'; +import SeasonPass from './SeasonPass'; + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('series', 'seasonPass'), + (series) => { + return { + ...series + }; + } + ); +} + +const mapDispatchToProps = { + setSeasonPassSort, + setSeasonPassFilter, + saveSeasonPass +}; + +class SeasonPassConnector extends Component { + + // + // Listeners + + onSortPress = (sortKey) => { + this.props.setSeasonPassSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setSeasonPassFilter({ selectedFilterKey }); + } + + onUpdateSelectedPress = (payload) => { + this.props.saveSeasonPass(payload); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeasonPassConnector.propTypes = { + setSeasonPassSort: PropTypes.func.isRequired, + setSeasonPassFilter: PropTypes.func.isRequired, + saveSeasonPass: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeasonPassConnector); diff --git a/frontend/src/SeasonPass/SeasonPassFilterModalConnector.js b/frontend/src/SeasonPass/SeasonPassFilterModalConnector.js new file mode 100644 index 000000000..ba3308cea --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setSeasonPassFilter } from 'Store/Actions/seasonPassActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.series.items, + (state) => state.seasonPass.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'seasonPass' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setSeasonPassFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/SeasonPass/SeasonPassFooter.css b/frontend/src/SeasonPass/SeasonPassFooter.css new file mode 100644 index 000000000..c18eb660f --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassFooter.css @@ -0,0 +1,14 @@ +.inputContainer { + margin-right: 20px; +} + +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.updateSelectedButton { + composes: button from 'Components/Link/SpinnerButton.css'; + + height: 35px; +} diff --git a/frontend/src/SeasonPass/SeasonPassFooter.js b/frontend/src/SeasonPass/SeasonPassFooter.js new file mode 100644 index 000000000..7ce8dc491 --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassFooter.js @@ -0,0 +1,145 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import MonitorEpisodesSelectInput from 'Components/Form/MonitorEpisodesSelectInput'; +import SelectInput from 'Components/Form/SelectInput'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import styles from './SeasonPassFooter.css'; + +const NO_CHANGE = 'noChange'; + +class SeasonPassFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + monitored: NO_CHANGE, + monitor: NO_CHANGE + }; + } + + componentDidUpdate(prevProps) { + const { + isSaving, + saveError + } = prevProps; + + if (prevProps.isSaving && !isSaving && !saveError) { + this.setState({ + monitored: NO_CHANGE, + monitor: NO_CHANGE + }); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + } + + onUpdateSelectedPress = () => { + const { + monitor, + monitored + } = this.state; + + const changes = {}; + + if (monitored !== NO_CHANGE) { + changes.monitored = monitored === 'monitored'; + } + + if (monitor !== NO_CHANGE) { + changes.monitor = monitor; + } + + this.props.onUpdateSelectedPress(changes); + } + + // + // Render + + render() { + const { + selectedCount, + isSaving + } = this.props; + + const { + monitored, + monitor + } = this.state; + + const monitoredOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'monitored', value: 'Monitored' }, + { key: 'unmonitored', value: 'Unmonitored' } + ]; + + const noChanges = monitored === NO_CHANGE && monitor === NO_CHANGE; + + return ( + +
+
+ Monitor Series +
+ + +
+ +
+
+ Monitor Episodes +
+ + +
+ +
+
+ {selectedCount} Series Selected +
+ + + Update Selected + +
+
+ ); + } +} + +SeasonPassFooter.propTypes = { + selectedCount: PropTypes.number.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + onUpdateSelectedPress: PropTypes.func.isRequired +}; + +export default SeasonPassFooter; diff --git a/frontend/src/SeasonPass/SeasonPassRow.css b/frontend/src/SeasonPass/SeasonPassRow.css new file mode 100644 index 000000000..a053c6bef --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassRow.css @@ -0,0 +1,20 @@ +.status, +.monitored { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.title { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 1px; + white-space: nowrap; +} + +.seasons { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/SeasonPass/SeasonPassRow.js b/frontend/src/SeasonPass/SeasonPassRow.js new file mode 100644 index 000000000..6da75ded9 --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassRow.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import SeasonPassSeason from './SeasonPassSeason'; +import styles from './SeasonPassRow.css'; + +class SeasonPassRow extends Component { + + // + // Render + + render() { + const { + seriesId, + status, + titleSlug, + title, + monitored, + seasons, + isSaving, + isSelected, + onSelectedChange, + onSeriesMonitoredPress, + onSeasonMonitoredPress + } = this.props; + + return ( + + + + + + + + + + + + + + + + + { + seasons.map((season) => { + return ( + + ); + }) + } + + + ); + } +} + +SeasonPassRow.propTypes = { + seriesId: PropTypes.number.isRequired, + status: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + seasons: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired, + onSeriesMonitoredPress: PropTypes.func.isRequired, + onSeasonMonitoredPress: PropTypes.func.isRequired +}; + +SeasonPassRow.defaultProps = { + isSaving: false +}; + +export default SeasonPassRow; diff --git a/frontend/src/SeasonPass/SeasonPassRowConnector.js b/frontend/src/SeasonPass/SeasonPassRowConnector.js new file mode 100644 index 000000000..f2139743f --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassRowConnector.js @@ -0,0 +1,77 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import { toggleSeriesMonitored, toggleSeasonMonitored } from 'Store/Actions/seriesActions'; +import SeasonPassRow from './SeasonPassRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + (series) => { + return _.pick(series, [ + 'status', + 'titleSlug', + 'title', + 'monitored', + 'seasons', + 'isSaving' + ]); + } + ); +} + +const mapDispatchToProps = { + toggleSeriesMonitored, + toggleSeasonMonitored +}; + +class SeasonPassRowConnector extends Component { + + // + // Listeners + + onSeriesMonitoredPress = () => { + const { + seriesId, + monitored + } = this.props; + + this.props.toggleSeriesMonitored({ + seriesId, + monitored: !monitored + }); + } + + onSeasonMonitoredPress = (seasonNumber, monitored) => { + this.props.toggleSeasonMonitored({ + seriesId: this.props.seriesId, + seasonNumber, + monitored + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeasonPassRowConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + toggleSeriesMonitored: PropTypes.func.isRequired, + toggleSeasonMonitored: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeasonPassRowConnector); diff --git a/frontend/src/SeasonPass/SeasonPassSeason.css b/frontend/src/SeasonPass/SeasonPassSeason.css new file mode 100644 index 000000000..603d98ecd --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassSeason.css @@ -0,0 +1,24 @@ +.season { + display: flex; + align-items: stretch; + overflow: hidden; + margin: 2px 4px; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: #eee; + cursor: default; +} + +.info { + padding: 0 4px; +} + +.episodes { + padding: 0 4px; + background-color: $white; + color: $defaultColor; +} + +.allEpisodes { + background-color: #e0ffe0; +} diff --git a/frontend/src/SeasonPass/SeasonPassSeason.js b/frontend/src/SeasonPass/SeasonPassSeason.js new file mode 100644 index 000000000..152221b8c --- /dev/null +++ b/frontend/src/SeasonPass/SeasonPassSeason.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import padNumber from 'Utilities/Number/padNumber'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import styles from './SeasonPassSeason.css'; + +class SeasonPassSeason extends Component { + + // + // Listeners + + onSeasonMonitoredPress = () => { + const { + seasonNumber, + monitored + } = this.props; + + this.props.onSeasonMonitoredPress(seasonNumber, !monitored); + } + + // + // Render + + render() { + const { + seasonNumber, + monitored, + statistics, + isSaving + } = this.props; + + const { + episodeFileCount, + totalEpisodeCount, + percentOfEpisodes + } = statistics; + + return ( +
+
+ + + + { + seasonNumber === 0 ? 'Specials' : `S${padNumber(seasonNumber, 2)}` + } + +
+ +
+ { + totalEpisodeCount === 0 ? '0/0' : `${episodeFileCount}/${totalEpisodeCount}` + } +
+
+ ); + } +} + +SeasonPassSeason.propTypes = { + seasonNumber: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + statistics: PropTypes.object.isRequired, + isSaving: PropTypes.bool.isRequired, + onSeasonMonitoredPress: PropTypes.func.isRequired +}; + +SeasonPassSeason.defaultProps = { + isSaving: false, + statistics: { + episodeFileCount: 0, + totalEpisodeCount: 0, + percentOfEpisodes: 0 + } +}; + +export default SeasonPassSeason; diff --git a/frontend/src/Series/Delete/DeleteSeriesModal.js b/frontend/src/Series/Delete/DeleteSeriesModal.js new file mode 100644 index 000000000..486ba3764 --- /dev/null +++ b/frontend/src/Series/Delete/DeleteSeriesModal.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import DeleteSeriesModalContentConnector from './DeleteSeriesModalContentConnector'; + +function DeleteSeriesModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +DeleteSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default DeleteSeriesModal; diff --git a/frontend/src/Series/Delete/DeleteSeriesModalContent.css b/frontend/src/Series/Delete/DeleteSeriesModalContent.css new file mode 100644 index 000000000..dbfef0871 --- /dev/null +++ b/frontend/src/Series/Delete/DeleteSeriesModalContent.css @@ -0,0 +1,12 @@ +.pathContainer { + margin-bottom: 20px; +} + +.pathIcon { + margin-right: 8px; +} + +.deleteFilesMessage { + margin-top: 20px; + color: $dangerColor; +} diff --git a/frontend/src/Series/Delete/DeleteSeriesModalContent.js b/frontend/src/Series/Delete/DeleteSeriesModalContent.js new file mode 100644 index 000000000..b183ffdeb --- /dev/null +++ b/frontend/src/Series/Delete/DeleteSeriesModalContent.js @@ -0,0 +1,144 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons, inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './DeleteSeriesModalContent.css'; + +class DeleteSeriesModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + deleteFiles: false + }; + } + + // + // Listeners + + onDeleteFilesChange = ({ value }) => { + this.setState({ deleteFiles: value }); + } + + onDeleteSeriesConfirmed = () => { + const deleteFiles = this.state.deleteFiles; + + this.setState({ deleteFiles: false }); + this.props.onDeletePress(deleteFiles); + } + + // + // Render + + render() { + const { + title, + path, + statistics, + onModalClose + } = this.props; + + const { + episodeFileCount, + sizeOnDisk + } = statistics; + + const deleteFiles = this.state.deleteFiles; + let deleteFilesLabel = `Delete ${episodeFileCount} Episode Files`; + let deleteFilesHelpText = 'Delete the episode files and series folder'; + + if (episodeFileCount === 0) { + deleteFilesLabel = 'Delete Series Folder'; + deleteFilesHelpText = 'Delete the series folder and it\'s contents'; + } + + return ( + + + Delete - {title} + + + +
+ + + {path} +
+ + + {deleteFilesLabel} + + + + + { + deleteFiles && +
+
The series folder {path} and all it's content will be deleted.
+ + { + !!episodeFileCount && +
{episodeFileCount} episode files totaling {formatBytes(sizeOnDisk)}
+ } +
+ } + +
+ + + + + + +
+ ); + } +} + +DeleteSeriesModalContent.propTypes = { + title: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + statistics: PropTypes.object.isRequired, + onDeletePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +DeleteSeriesModalContent.defaultProps = { + statistics: { + episodeFileCount: 0 + } +}; + +export default DeleteSeriesModalContent; diff --git a/frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js b/frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js new file mode 100644 index 000000000..73033f957 --- /dev/null +++ b/frontend/src/Series/Delete/DeleteSeriesModalContentConnector.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import { deleteSeries } from 'Store/Actions/seriesActions'; +import DeleteSeriesModalContent from './DeleteSeriesModalContent'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + (series) => { + return series; + } + ); +} + +const mapDispatchToProps = { + deleteSeries +}; + +class DeleteSeriesModalContentConnector extends Component { + + // + // Listeners + + onDeletePress = (deleteFiles) => { + this.props.deleteSeries({ + id: this.props.seriesId, + deleteFiles + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DeleteSeriesModalContentConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired, + deleteSeries: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DeleteSeriesModalContentConnector); diff --git a/frontend/src/Series/Details/EpisodeRow.css b/frontend/src/Series/Details/EpisodeRow.css new file mode 100644 index 000000000..2ff09f37c --- /dev/null +++ b/frontend/src/Series/Details/EpisodeRow.css @@ -0,0 +1,32 @@ +.title { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + white-space: nowrap; +} + +.monitored { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 42px; +} + +.episodeNumber { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.size { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.language, +.audio, +.video, +.status { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Series/Details/EpisodeRow.js b/frontend/src/Series/Details/EpisodeRow.js new file mode 100644 index 000000000..b6e951aa8 --- /dev/null +++ b/frontend/src/Series/Details/EpisodeRow.js @@ -0,0 +1,284 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector'; +import EpisodeNumber from 'Episode/EpisodeNumber'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector'; +import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector'; +import MediaInfoConnector from 'EpisodeFile/MediaInfoConnector'; +import * as mediaInfoTypes from 'EpisodeFile/mediaInfoTypes'; + +import styles from './EpisodeRow.css'; + +class EpisodeRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onManualSearchPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + onMonitorEpisodePress = (monitored, options) => { + this.props.onMonitorEpisodePress(this.props.id, monitored, options); + } + + // + // Render + + render() { + const { + id, + seriesId, + episodeFileId, + monitored, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + airDateUtc, + title, + unverifiedSceneNumbering, + isSaving, + seriesMonitored, + seriesType, + episodeFilePath, + episodeFileRelativePath, + episodeFileSize, + alternateTitles, + columns + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'monitored') { + return ( + + + + ); + } + + if (name === 'episodeNumber') { + return ( + + + + ); + } + + if (name === 'title') { + return ( + + + + ); + } + + if (name === 'path') { + return ( + + { + episodeFilePath + } + + ); + } + + if (name === 'relativePath') { + return ( + + { + episodeFileRelativePath + } + + ); + } + + if (name === 'airDateUtc') { + return ( + + ); + } + + if (name === 'language') { + return ( + + + + ); + } + + if (name === 'audioInfo') { + return ( + + + + ); + } + + if (name === 'videoCodec') { + return ( + + + + ); + } + + if (name === 'size') { + return ( + + {!!episodeFileSize && formatBytes(episodeFileSize)} + + ); + } + + if (name === 'status') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + ); + } + + return null; + }) + } + + ); + } +} + +EpisodeRow.propTypes = { + id: PropTypes.number.isRequired, + seriesId: PropTypes.number.isRequired, + episodeFileId: PropTypes.number, + monitored: PropTypes.bool.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + sceneSeasonNumber: PropTypes.number, + sceneEpisodeNumber: PropTypes.number, + sceneAbsoluteEpisodeNumber: PropTypes.number, + airDateUtc: PropTypes.string, + title: PropTypes.string.isRequired, + isSaving: PropTypes.bool, + unverifiedSceneNumbering: PropTypes.bool, + seriesMonitored: PropTypes.bool.isRequired, + seriesType: PropTypes.string.isRequired, + episodeFilePath: PropTypes.string, + episodeFileRelativePath: PropTypes.string, + episodeFileSize: PropTypes.number, + mediaInfo: PropTypes.object, + alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onMonitorEpisodePress: PropTypes.func.isRequired +}; + +EpisodeRow.defaultProps = { + alternateTitles: [] +}; + +export default EpisodeRow; diff --git a/frontend/src/Series/Details/EpisodeRowConnector.js b/frontend/src/Series/Details/EpisodeRowConnector.js new file mode 100644 index 000000000..d8453cef3 --- /dev/null +++ b/frontend/src/Series/Details/EpisodeRowConnector.js @@ -0,0 +1,24 @@ +/* eslint max-params: 0 */ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; +import EpisodeRow from './EpisodeRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createEpisodeFileSelector(), + (series = {}, episodeFile) => { + return { + seriesMonitored: series.monitored, + seriesType: series.seriesType, + episodeFilePath: episodeFile ? episodeFile.path : null, + episodeFileRelativePath: episodeFile ? episodeFile.relativePath : null, + episodeFileSize: episodeFile ? episodeFile.size : null, + alternateTitles: series.alternateTitles + }; + } + ); +} +export default connect(createMapStateToProps)(EpisodeRow); diff --git a/frontend/src/Series/Details/SeasonInfo.css b/frontend/src/Series/Details/SeasonInfo.css new file mode 100644 index 000000000..3f4c3ac62 --- /dev/null +++ b/frontend/src/Series/Details/SeasonInfo.css @@ -0,0 +1,11 @@ +.title { + composes: title from 'Components/DescriptionList/DescriptionListItemTitle.css'; + + width: 90px; +} + +.description { + composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css'; + + margin-left: 110px; +} diff --git a/frontend/src/Series/Details/SeasonInfo.js b/frontend/src/Series/Details/SeasonInfo.js new file mode 100644 index 000000000..f89542b49 --- /dev/null +++ b/frontend/src/Series/Details/SeasonInfo.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import styles from './SeasonInfo.css'; + +function SeasonInfo(props) { + const { + totalEpisodeCount, + monitoredEpisodeCount, + episodeFileCount + } = props; + + return ( + + + + + + + + ); +} + +SeasonInfo.propTypes = { + totalEpisodeCount: PropTypes.number.isRequired, + monitoredEpisodeCount: PropTypes.number.isRequired, + episodeFileCount: PropTypes.number.isRequired +}; + +export default SeasonInfo; diff --git a/frontend/src/Series/Details/SeriesAlternateTitles.css b/frontend/src/Series/Details/SeriesAlternateTitles.css new file mode 100644 index 000000000..1af1ae68b --- /dev/null +++ b/frontend/src/Series/Details/SeriesAlternateTitles.css @@ -0,0 +1,3 @@ +.alternateTitle { + white-space: nowrap; +} diff --git a/frontend/src/Series/Details/SeriesAlternateTitles.js b/frontend/src/Series/Details/SeriesAlternateTitles.js new file mode 100644 index 000000000..18d016579 --- /dev/null +++ b/frontend/src/Series/Details/SeriesAlternateTitles.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './SeriesAlternateTitles.css'; + +function SeriesAlternateTitles({ alternateTitles }) { + return ( +
    + { + alternateTitles.map((alternateTitle) => { + return ( +
  • + {alternateTitle} +
  • + ); + }) + } +
+ ); +} + +SeriesAlternateTitles.propTypes = { + alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired +}; + +export default SeriesAlternateTitles; diff --git a/frontend/src/Series/Details/SeriesDetails.css b/frontend/src/Series/Details/SeriesDetails.css new file mode 100644 index 000000000..f161e524a --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetails.css @@ -0,0 +1,155 @@ +.innerContentBody { + padding: 0; +} + +.header { + position: relative; + width: 100%; + height: 425px; +} + +.backdrop { + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + background-size: cover; +} + +.backdropOverlay { + position: absolute; + width: 100%; + height: 100%; + background: $black; + opacity: 0.7; +} + +.headerContent { + display: flex; + padding: 30px; + width: 100%; + height: 100%; + color: $white; +} + +.poster { + flex-shrink: 0; + margin-right: 35px; + width: 250px; + height: 368px; +} + +.info { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; +} + +.titleRow { + display: flex; + justify-content: space-between; + flex: 0 0 auto; +} + +.titleContainer { + display: flex; + margin-bottom: 5px; +} + +.title { + font-weight: 300; + font-size: 50px; + line-height: 50px; +} + +.toggleMonitoredContainer { + align-self: center; + margin-right: 10px; +} + +.monitorToggleButton { + composes: toggleButton from 'Components/MonitorToggleButton.css'; + + width: 40px; + + &:hover { + color: $iconButtonHoverLightColor; + } +} + +.alternateTitlesIconContainer { + align-self: flex-end; + margin-left: 20px; +} + +.seriesNavigationButtons { + white-space: nowrap; +} + +.seriesNavigationButton { + composes: button from 'Components/Link/IconButton.css'; + + margin-left: 5px; + width: 30px; + color: #e1e2e3; + white-space: nowrap; + + &:hover { + color: $iconButtonHoverLightColor; + } +} + +.details { + margin-bottom: 8px; + font-weight: 300; + font-size: 20px; +} + +.runtime { + margin-right: 15px; +} + +.detailsLabel { + composes: label from 'Components/Label.css'; + + margin: 5px 10px 5px 0; +} + +.path, +.sizeOnDisk, +.qualityProfileName, +.network, +.links, +.tags { + margin-left: 8px; + font-weight: 300; + font-size: 17px; +} + +.overview { + flex: 1 0 auto; + margin-top: 8px; + min-height: 0; + font-size: $intermediateFontSize; +} + +.contentContainer { + padding: 20px; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentContainer { + padding: 20px 0; + } + + .headerContent { + padding: 15px; + } +} + +@media only screen and (max-width: $breakpointLarge) { + .poster { + display: none; + } +} diff --git a/frontend/src/Series/Details/SeriesDetails.js b/frontend/src/Series/Details/SeriesDetails.js new file mode 100644 index 000000000..af693dcb9 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetails.js @@ -0,0 +1,695 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import fonts from 'Styles/Variables/fonts'; +import HeartRating from 'Components/HeartRating'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Label from 'Components/Label'; +import Measure from 'Components/Measure'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import Popover from 'Components/Tooltip/Popover'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import EpisodeFileEditorModal from 'EpisodeFile/Editor/EpisodeFileEditorModal'; +import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; +import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector'; +import SeriesPoster from 'Series/SeriesPoster'; +import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; +import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; +import SeriesHistoryModal from 'Series/History/SeriesHistoryModal'; +import SeriesAlternateTitles from './SeriesAlternateTitles'; +import SeriesDetailsSeasonConnector from './SeriesDetailsSeasonConnector'; +import SeriesTagsConnector from './SeriesTagsConnector'; +import SeriesDetailsLinks from './SeriesDetailsLinks'; +import styles from './SeriesDetails.css'; +import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal'; + +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +function getFanartUrl(images) { + const fanartImage = _.find(images, { coverType: 'fanart' }); + if (fanartImage) { + // Remove protocol + return fanartImage.url.replace(/^https?:/, ''); + } +} + +function getExpandedState(newState) { + return { + allExpanded: newState.allSelected, + allCollapsed: newState.allUnselected, + expandedState: newState.selectedState + }; +} + +class SeriesDetails extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOrganizeModalOpen: false, + isManageEpisodesOpen: false, + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: false, + isSeriesHistoryModalOpen: false, + isInteractiveImportModalOpen: false, + allExpanded: false, + allCollapsed: false, + expandedState: {}, + overviewHeight: 0 + }; + } + + // + // Listeners + + onOrganizePress = () => { + this.setState({ isOrganizeModalOpen: true }); + } + + onOrganizeModalClose = () => { + this.setState({ isOrganizeModalOpen: false }); + } + + onManageEpisodesPress = () => { + this.setState({ isManageEpisodesOpen: true }); + } + + onManageEpisodesModalClose = () => { + this.setState({ isManageEpisodesOpen: false }); + } + + onInteractiveImportPress = () => { + this.setState({ isInteractiveImportModalOpen: true }); + } + + onInteractiveImportModalClose = () => { + this.setState({ isInteractiveImportModalOpen: false }); + } + + onEditSeriesPress = () => { + this.setState({ isEditSeriesModalOpen: true }); + } + + onEditSeriesModalClose = () => { + this.setState({ isEditSeriesModalOpen: false }); + } + + onDeleteSeriesPress = () => { + this.setState({ + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: true + }); + } + + onDeleteSeriesModalClose = () => { + this.setState({ isDeleteSeriesModalOpen: false }); + } + + onSeriesHistoryPress = () => { + this.setState({ isSeriesHistoryModalOpen: true }); + } + + onSeriesHistoryModalClose = () => { + this.setState({ isSeriesHistoryModalOpen: false }); + } + + onExpandAllPress = () => { + const { + allExpanded, + expandedState + } = this.state; + + this.setState(getExpandedState(selectAll(expandedState, !allExpanded))); + } + + onExpandPress = (seasonNumber, isExpanded) => { + this.setState((state) => { + const convertedState = { + allSelected: state.allExpanded, + allUnselected: state.allCollapsed, + selectedState: state.expandedState + }; + + const newState = toggleSelected(convertedState, [], seasonNumber, isExpanded, false); + + return getExpandedState(newState); + }); + } + + onMeasure = ({ height }) => { + this.setState({ overviewHeight: height }); + } + + // + // Render + + render() { + const { + id, + tvdbId, + tvMazeId, + imdbId, + title, + runtime, + ratings, + path, + statistics, + qualityProfileId, + monitored, + status, + network, + overview, + images, + seasons, + alternateTitles, + tags, + isSaving, + isRefreshing, + isSearching, + isFetching, + isPopulated, + episodesError, + episodeFilesError, + hasEpisodes, + hasMonitoredEpisodes, + hasEpisodeFiles, + previousSeries, + nextSeries, + onMonitorTogglePress, + onRefreshPress, + onSearchPress + } = this.props; + + const { + episodeFileCount, + sizeOnDisk + } = statistics; + + const { + isOrganizeModalOpen, + isManageEpisodesOpen, + isEditSeriesModalOpen, + isDeleteSeriesModalOpen, + isSeriesHistoryModalOpen, + isInteractiveImportModalOpen, + allExpanded, + allCollapsed, + expandedState, + overviewHeight + } = this.state; + + const continuing = status === 'continuing'; + + let episodeFilesCountMessage = 'No episode files'; + + if (episodeFileCount === 1) { + episodeFilesCountMessage = '1 episode file'; + } else if (episodeFileCount > 1) { + episodeFilesCountMessage = `${episodeFileCount} episode files`; + } + + let expandIcon = icons.EXPAND_INDETERMINATE; + + if (allExpanded) { + expandIcon = icons.COLLAPSE; + } else if (allCollapsed) { + expandIcon = icons.EXPAND; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ + +
+
+
+
+ +
+ +
+ {title} +
+ + { + !!alternateTitles.length && +
+ + } + title="Alternate Titles" + body={} + position={tooltipPositions.BOTTOM} + /> +
+ } +
+ +
+ + + +
+
+ +
+
+ { + !!runtime && + + {runtime} Minutes + + } + + +
+
+ +
+ + + + + + + + + + + { + !!network && + + } + + + + + + Links + + + } + tooltip={ + + } + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + + { + !!tags.length && + + + + + Tags + + + } + tooltip={} + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + + } +
+ + +
+ +
+
+
+
+
+ +
+ { + !isPopulated && !episodesError && !episodeFilesError && + + } + + { + !isFetching && episodesError && +
Loading episodes failed
+ } + + { + !isFetching && episodeFilesError && +
Loading episode files failed
+ } + + { + isPopulated && !!seasons.length && +
+ { + seasons.slice(0).reverse().map((season) => { + return ( + + ); + }) + } +
+ } + + { + isPopulated && !seasons.length && +
+ No episode information is available. +
+ } + +
+ + + + + + + + + + + + + + + ); + } +} + +SeriesDetails.propTypes = { + id: PropTypes.number.isRequired, + tvdbId: PropTypes.number.isRequired, + tvMazeId: PropTypes.number, + imdbId: PropTypes.string, + title: PropTypes.string.isRequired, + runtime: PropTypes.number.isRequired, + ratings: PropTypes.object.isRequired, + path: PropTypes.string.isRequired, + statistics: PropTypes.object.isRequired, + qualityProfileId: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + network: PropTypes.string, + overview: PropTypes.string.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + seasons: PropTypes.arrayOf(PropTypes.object).isRequired, + alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + isSaving: PropTypes.bool.isRequired, + isRefreshing: PropTypes.bool.isRequired, + isSearching: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + episodesError: PropTypes.object, + episodeFilesError: PropTypes.object, + hasEpisodes: PropTypes.bool.isRequired, + hasMonitoredEpisodes: PropTypes.bool.isRequired, + hasEpisodeFiles: PropTypes.bool.isRequired, + previousSeries: PropTypes.object.isRequired, + nextSeries: PropTypes.object.isRequired, + onMonitorTogglePress: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +SeriesDetails.defaultProps = { + statistics: {}, + tag: [], + isSaving: false +}; + +export default SeriesDetails; diff --git a/frontend/src/Series/Details/SeriesDetailsConnector.js b/frontend/src/Series/Details/SeriesDetailsConnector.js new file mode 100644 index 000000000..25bf13de1 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsConnector.js @@ -0,0 +1,271 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions'; +import { fetchEpisodeFiles, clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; +import { toggleSeriesMonitored } from 'Store/Actions/seriesActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import SeriesDetails from './SeriesDetails'; + +const selectEpisodes = createSelector( + (state) => state.episodes, + (episodes) => { + const { + items, + isFetching, + isPopulated, + error + } = episodes; + + const hasEpisodes = !!items.length; + const hasMonitoredEpisodes = items.some((e) => e.monitored); + + return { + isEpisodesFetching: isFetching, + isEpisodesPopulated: isPopulated, + episodesError: error, + hasEpisodes, + hasMonitoredEpisodes + }; + } +); + +const selectEpisodeFiles = createSelector( + (state) => state.episodeFiles, + (episodeFiles) => { + const { + items, + isFetching, + isPopulated, + error + } = episodeFiles; + + const hasEpisodeFiles = !!items.length; + + return { + isEpisodeFilesFetching: isFetching, + isEpisodeFilesPopulated: isPopulated, + episodeFilesError: error, + hasEpisodeFiles + }; + } +); + +function createMapStateToProps() { + return createSelector( + (state, { titleSlug }) => titleSlug, + selectEpisodes, + selectEpisodeFiles, + createAllSeriesSelector(), + createCommandsSelector(), + (titleSlug, episodes, episodeFiles, allSeries, commands) => { + const sortedSeries = _.orderBy(allSeries, 'sortTitle'); + const seriesIndex = _.findIndex(sortedSeries, { titleSlug }); + const series = sortedSeries[seriesIndex]; + + if (!series) { + return {}; + } + + const { + isEpisodesFetching, + isEpisodesPopulated, + episodesError, + hasEpisodes, + hasMonitoredEpisodes + } = episodes; + + const { + isEpisodeFilesFetching, + isEpisodeFilesPopulated, + episodeFilesError, + hasEpisodeFiles + } = episodeFiles; + + const previousSeries = sortedSeries[seriesIndex - 1] || _.last(sortedSeries); + const nextSeries = sortedSeries[seriesIndex + 1] || _.first(sortedSeries); + const isSeriesRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_SERIES, seriesId: series.id })); + const seriesRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_SERIES }); + const allSeriesRefreshing = ( + isCommandExecuting(seriesRefreshingCommand) && + !seriesRefreshingCommand.body.seriesId + ); + const isRefreshing = isSeriesRefreshing || allSeriesRefreshing; + const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.SERIES_SEARCH, seriesId: series.id })); + const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, seriesId: series.id })); + const isRenamingSeriesCommand = findCommand(commands, { name: commandNames.RENAME_SERIES }); + const isRenamingSeries = ( + isCommandExecuting(isRenamingSeriesCommand) && + isRenamingSeriesCommand.body.seriesIds.indexOf(series.id) > -1 + ); + + const isFetching = isEpisodesFetching || isEpisodeFilesFetching; + const isPopulated = isEpisodesPopulated && isEpisodeFilesPopulated; + const alternateTitles = _.reduce(series.alternateTitles, (acc, alternateTitle) => { + if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) && + (alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) { + acc.push(alternateTitle.title); + } + + return acc; + }, []); + + return { + ...series, + alternateTitles, + isSeriesRefreshing, + allSeriesRefreshing, + isRefreshing, + isSearching, + isRenamingFiles, + isRenamingSeries, + isFetching, + isPopulated, + episodesError, + episodeFilesError, + hasEpisodes, + hasMonitoredEpisodes, + hasEpisodeFiles, + previousSeries, + nextSeries + }; + } + ); +} + +const mapDispatchToProps = { + fetchEpisodes, + clearEpisodes, + fetchEpisodeFiles, + clearEpisodeFiles, + toggleSeriesMonitored, + fetchQueueDetails, + clearQueueDetails, + executeCommand +}; + +class SeriesDetailsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + registerPagePopulator(this.populate); + this.populate(); + } + + componentDidUpdate(prevProps) { + const { + id, + isSeriesRefreshing, + allSeriesRefreshing, + isRenamingFiles, + isRenamingSeries + } = this.props; + + if ( + (prevProps.isSeriesRefreshing && !isSeriesRefreshing) || + (prevProps.allSeriesRefreshing && !allSeriesRefreshing) || + (prevProps.isRenamingFiles && !isRenamingFiles) || + (prevProps.isRenamingSeries && !isRenamingSeries) + ) { + this.populate(); + } + + // If the id has changed we need to clear the episodes/episode + // files and fetch from the server. + + if (prevProps.id !== id) { + this.unpopulate(); + this.populate(); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.populate); + this.unpopulate(); + } + + // + // Control + + populate = () => { + const seriesId = this.props.id; + + this.props.fetchEpisodes({ seriesId }); + this.props.fetchEpisodeFiles({ seriesId }); + this.props.fetchQueueDetails({ seriesId }); + } + + unpopulate = () => { + this.props.clearEpisodes(); + this.props.clearEpisodeFiles(); + this.props.clearQueueDetails(); + } + + // + // Listeners + + onMonitorTogglePress = (monitored) => { + this.props.toggleSeriesMonitored({ + seriesId: this.props.id, + monitored + }); + } + + onRefreshPress = () => { + this.props.executeCommand({ + name: commandNames.REFRESH_SERIES, + seriesId: this.props.id + }); + } + + onSearchPress = () => { + this.props.executeCommand({ + name: commandNames.SERIES_SEARCH, + seriesId: this.props.id + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeriesDetailsConnector.propTypes = { + id: PropTypes.number.isRequired, + titleSlug: PropTypes.string.isRequired, + isSeriesRefreshing: PropTypes.bool.isRequired, + allSeriesRefreshing: PropTypes.bool.isRequired, + isRefreshing: PropTypes.bool.isRequired, + isRenamingFiles: PropTypes.bool.isRequired, + isRenamingSeries: PropTypes.bool.isRequired, + fetchEpisodes: PropTypes.func.isRequired, + clearEpisodes: PropTypes.func.isRequired, + fetchEpisodeFiles: PropTypes.func.isRequired, + clearEpisodeFiles: PropTypes.func.isRequired, + toggleSeriesMonitored: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsConnector); diff --git a/frontend/src/Series/Details/SeriesDetailsLinks.css b/frontend/src/Series/Details/SeriesDetailsLinks.css new file mode 100644 index 000000000..0f65b9154 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsLinks.css @@ -0,0 +1,13 @@ +.links { + margin: 0; +} + +.link { + white-space: nowrap; +} + +.linkLabel { + composes: label from 'Components/Label.css'; + + cursor: pointer; +} diff --git a/frontend/src/Series/Details/SeriesDetailsLinks.js b/frontend/src/Series/Details/SeriesDetailsLinks.js new file mode 100644 index 000000000..9cacdb1a3 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsLinks.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import styles from './SeriesDetailsLinks.css'; + +function SeriesDetailsLinks(props) { + const { + tvdbId, + tvMazeId, + imdbId + } = props; + + return ( +
+ + + + + + + + + { + !!tvMazeId && + + + + } + + { + !!imdbId && + + + + } +
+ ); +} + +SeriesDetailsLinks.propTypes = { + tvdbId: PropTypes.number.isRequired, + tvMazeId: PropTypes.number, + imdbId: PropTypes.string +}; + +export default SeriesDetailsLinks; diff --git a/frontend/src/Series/Details/SeriesDetailsPageConnector.js b/frontend/src/Series/Details/SeriesDetailsPageConnector.js new file mode 100644 index 000000000..bf440a532 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsPageConnector.js @@ -0,0 +1,76 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { push } from 'react-router-redux'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import NotFound from 'Components/NotFound'; +import SeriesDetailsConnector from './SeriesDetailsConnector'; + +function createMapStateToProps() { + return createSelector( + (state, { match }) => match, + createAllSeriesSelector(), + (match, allSeries) => { + const titleSlug = match.params.titleSlug; + const seriesIndex = _.findIndex(allSeries, { titleSlug }); + + if (seriesIndex > -1) { + return { + titleSlug + }; + } + + return {}; + } + ); +} + +const mapDispatchToProps = { + push +}; + +class SeriesDetailsPageConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + if (!this.props.titleSlug) { + this.props.push(`${window.Sonarr.urlBase}/`); + return; + } + } + + // + // Render + + render() { + const { + titleSlug + } = this.props; + + if (!titleSlug) { + return ( + + ); + } + + return ( + + ); + } +} + +SeriesDetailsPageConnector.propTypes = { + titleSlug: PropTypes.string, + match: PropTypes.shape({ params: PropTypes.shape({ titleSlug: PropTypes.string.isRequired }).isRequired }).isRequired, + push: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsPageConnector); diff --git a/frontend/src/Series/Details/SeriesDetailsSeason.css b/frontend/src/Series/Details/SeriesDetailsSeason.css new file mode 100644 index 000000000..f417c415a --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsSeason.css @@ -0,0 +1,112 @@ +.season { + margin-bottom: 20px; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; + + &:last-of-type { + margin-bottom: 0; + } +} + +.header { + position: relative; + display: flex; + align-items: center; + width: 100%; + font-size: 24px; +} + +.seasonNumber { + margin-right: 10px; + margin-left: 5px; +} + +.episodeCountTooltip { + display: flex; +} + +.expandButton { + composes: link from 'Components/Link/Link.css'; + + flex-grow: 1; + margin: 0 20px; + text-align: center; +} + +.left { + display: flex; + align-items: center; + flex: 0 1 300px; +} + +.left, +.actions { + padding: 15px 10px; +} + +.actionsMenu { + composes: menu from 'Components/Menu/Menu.css'; + + flex: 0 0 45px; +} + +.actionsMenuContent { + composes: menuContent from 'Components/Menu/MenuContent.css'; + + white-space: nowrap; + font-size: $defaultFontSize; +} + +.actionMenuIcon { + margin-right: 8px; +} + +.actionButton { + composes: button from 'Components/Link/IconButton.css'; + + width: 30px; +} + +.episodes { + padding-top: 15px; + border-top: 1px solid $borderColor; +} + +.collapseButtonContainer { + padding: 10px 15px; + width: 100%; + border-top: 1px solid $borderColor; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + background-color: #fafafa; + text-align: center; +} + +.expandButtonIcon { + composes: actionButton; + + position: absolute; + top: 50%; + left: 50%; + margin-top: -12px; + margin-left: -15px; +} + +.noEpisodes { + margin-bottom: 15px; + text-align: center; +} + +@media only screen and (max-width: $breakpointSmall) { + .season { + border-right: 0; + border-left: 0; + border-radius: 0; + } + + .expandButtonIcon { + position: static; + margin: 0; + } +} diff --git a/frontend/src/Series/Details/SeriesDetailsSeason.js b/frontend/src/Series/Details/SeriesDetailsSeason.js new file mode 100644 index 000000000..f63ba9c30 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsSeason.js @@ -0,0 +1,519 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import isAfter from 'Utilities/Date/isAfter'; +import isBefore from 'Utilities/Date/isBefore'; +import getToggledRange from 'Utilities/Table/getToggledRange'; +import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import Popover from 'Components/Tooltip/Popover'; +import EpisodeFileEditorModal from 'EpisodeFile/Editor/EpisodeFileEditorModal'; +import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; +import SeriesHistoryModal from 'Series/History/SeriesHistoryModal'; +import SeasonInteractiveSearchModalConnector from 'Series/Search/SeasonInteractiveSearchModalConnector'; +import EpisodeRowConnector from './EpisodeRowConnector'; +import SeasonInfo from './SeasonInfo'; +import styles from './SeriesDetailsSeason.css'; + +function getSeasonStatistics(episodes) { + let episodeCount = 0; + let episodeFileCount = 0; + let totalEpisodeCount = 0; + let monitoredEpisodeCount = 0; + let hasMonitoredEpisodes = false; + + episodes.forEach((episode) => { + if (episode.episodeFileId || (episode.monitored && isBefore(episode.airDateUtc))) { + episodeCount++; + } + + if (episode.episodeFileId) { + episodeFileCount++; + } + + if (episode.monitored) { + monitoredEpisodeCount++; + hasMonitoredEpisodes = true; + } + + totalEpisodeCount++; + }); + + return { + episodeCount, + episodeFileCount, + totalEpisodeCount, + monitoredEpisodeCount, + hasMonitoredEpisodes + }; +} + +function getEpisodeCountKind(monitored, episodeFileCount, episodeCount) { + if (episodeFileCount === episodeCount && episodeCount > 0) { + return kinds.SUCCESS; + } + + if (!monitored) { + return kinds.WARNING; + } + + return kinds.DANGER; +} + +class SeriesDetailsSeason extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isOrganizeModalOpen: false, + isManageEpisodesOpen: false, + isHistoryModalOpen: false, + isInteractiveSearchModalOpen: false, + lastToggledEpisode: null + }; + } + + componentDidMount() { + this._expandByDefault(); + } + + componentDidUpdate(prevProps) { + const { + seriesId, + items + } = this.props; + + if (prevProps.seriesId !== seriesId) { + this._expandByDefault(); + return; + } + + if ( + getSeasonStatistics(prevProps.items).episodeFileCount > 0 && + getSeasonStatistics(items).episodeFileCount === 0 + ) { + this.setState({ + isOrganizeModalOpen: false, + isManageEpisodesOpen: false + }); + } + } + + // + // Control + + _expandByDefault() { + const { + seasonNumber, + onExpandPress, + items + } = this.props; + + const expand = _.some(items, (item) => { + return isAfter(item.airDateUtc) || + isAfter(item.airDateUtc, { days: -30 }); + }); + + onExpandPress(seasonNumber, expand && seasonNumber > 0); + } + + // + // Listeners + + onOrganizePress = () => { + this.setState({ isOrganizeModalOpen: true }); + } + + onOrganizeModalClose = () => { + this.setState({ isOrganizeModalOpen: false }); + } + + onManageEpisodesPress = () => { + this.setState({ isManageEpisodesOpen: true }); + } + + onManageEpisodesModalClose = () => { + this.setState({ isManageEpisodesOpen: false }); + } + + onHistoryPress = () => { + this.setState({ isHistoryModalOpen: true }); + } + + onHistoryModalClose = () => { + this.setState({ isHistoryModalOpen: false }); + } + + onInteractiveSearchPress = () => { + this.setState({ isInteractiveSearchModalOpen: true }); + } + + onInteractiveSearchModalClose = () => { + this.setState({ isInteractiveSearchModalOpen: false }); + } + + onExpandPress = () => { + const { + seasonNumber, + isExpanded + } = this.props; + + this.props.onExpandPress(seasonNumber, !isExpanded); + } + + onMonitorEpisodePress = (episodeId, monitored, { shiftKey }) => { + const lastToggled = this.state.lastToggledEpisode; + const episodeIds = [episodeId]; + + if (shiftKey && lastToggled) { + const { lower, upper } = getToggledRange(this.props.items, episodeId, lastToggled); + const items = this.props.items; + + for (let i = lower; i < upper; i++) { + episodeIds.push(items[i].id); + } + } + + this.setState({ lastToggledEpisode: episodeId }); + + this.props.onMonitorEpisodePress(_.uniq(episodeIds), monitored); + } + + // + // Render + + render() { + const { + seriesId, + monitored, + seasonNumber, + items, + columns, + isSaving, + isExpanded, + isSearching, + seriesMonitored, + isSmallScreen, + onTableOptionChange, + onMonitorSeasonPress, + onSearchPress + } = this.props; + + const { + episodeCount, + episodeFileCount, + totalEpisodeCount, + monitoredEpisodeCount, + hasMonitoredEpisodes + } = getSeasonStatistics(items); + + const { + isOrganizeModalOpen, + isManageEpisodesOpen, + isHistoryModalOpen, + isInteractiveSearchModalOpen + } = this.state; + + return ( +
+
+
+ + + { + seasonNumber === 0 ? + + Specials + : + + Season {seasonNumber} + + } + + + {episodeFileCount} / {episodeCount} + + } + title="Season Information" + body={ +
+ +
+ } + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> +
+ + + + { + !isSmallScreen && +   + } + + + { + isSmallScreen ? + + + + + + + + + + Search + + + + + + Interactive Search + + + + + + Preview Rename + + + + + + Manage Episodes + + + + + + History + + + : + +
+ + + + + + + + + +
+ } + +
+ +
+ { + isExpanded && +
+ { + items.length ? + + + { + items.map((item) => { + return ( + + ); + }) + } + +
: + +
+ No episodes in this season +
+ } +
+ +
+
+ } +
+ + + + + + + + +
+ ); + } +} + +SeriesDetailsSeason.propTypes = { + seriesId: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + seasonNumber: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool, + isExpanded: PropTypes.bool, + isSearching: PropTypes.bool.isRequired, + seriesMonitored: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onTableOptionChange: PropTypes.func.isRequired, + onMonitorSeasonPress: PropTypes.func.isRequired, + onExpandPress: PropTypes.func.isRequired, + onMonitorEpisodePress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +export default SeriesDetailsSeason; diff --git a/frontend/src/Series/Details/SeriesDetailsSeasonConnector.js b/frontend/src/Series/Details/SeriesDetailsSeasonConnector.js new file mode 100644 index 000000000..12b9a3166 --- /dev/null +++ b/frontend/src/Series/Details/SeriesDetailsSeasonConnector.js @@ -0,0 +1,118 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import { toggleSeasonMonitored } from 'Store/Actions/seriesActions'; +import { toggleEpisodesMonitored, setEpisodesTableOption } from 'Store/Actions/episodeActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import SeriesDetailsSeason from './SeriesDetailsSeason'; + +function createMapStateToProps() { + return createSelector( + (state, { seasonNumber }) => seasonNumber, + (state) => state.episodes, + createSeriesSelector(), + createCommandsSelector(), + createDimensionsSelector(), + (seasonNumber, episodes, series, commands, dimensions) => { + const isSearching = isCommandExecuting(findCommand(commands, { + name: commandNames.SEASON_SEARCH, + seriesId: series.id, + seasonNumber + })); + + const episodesInSeason = _.filter(episodes.items, { seasonNumber }); + const sortedEpisodes = _.orderBy(episodesInSeason, 'episodeNumber', 'desc'); + + return { + items: sortedEpisodes, + columns: episodes.columns, + isSearching, + seriesMonitored: series.monitored, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +const mapDispatchToProps = { + toggleSeasonMonitored, + toggleEpisodesMonitored, + setEpisodesTableOption, + executeCommand +}; + +class SeriesDetailsSeasonConnector extends Component { + + // + // Listeners + + onTableOptionChange = (payload) => { + this.props.setEpisodesTableOption(payload); + } + + onMonitorSeasonPress = (monitored) => { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.toggleSeasonMonitored({ + seriesId, + seasonNumber, + monitored + }); + } + + onSearchPress = () => { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.executeCommand({ + name: commandNames.SEASON_SEARCH, + seriesId, + seasonNumber + }); + } + + onMonitorEpisodePress = (episodeIds, monitored) => { + this.props.toggleEpisodesMonitored({ + episodeIds, + monitored + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeriesDetailsSeasonConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number.isRequired, + toggleSeasonMonitored: PropTypes.func.isRequired, + toggleEpisodesMonitored: PropTypes.func.isRequired, + setEpisodesTableOption: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesDetailsSeasonConnector); diff --git a/frontend/src/Series/Details/SeriesTags.js b/frontend/src/Series/Details/SeriesTags.js new file mode 100644 index 000000000..3876c9273 --- /dev/null +++ b/frontend/src/Series/Details/SeriesTags.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; + +function SeriesTags({ tags }) { + return ( +
+ { + tags.map((tag) => { + return ( + + ); + }) + } +
+ ); +} + +SeriesTags.propTypes = { + tags: PropTypes.arrayOf(PropTypes.string).isRequired +}; + +export default SeriesTags; diff --git a/frontend/src/Series/Details/SeriesTagsConnector.js b/frontend/src/Series/Details/SeriesTagsConnector.js new file mode 100644 index 000000000..e89bc1800 --- /dev/null +++ b/frontend/src/Series/Details/SeriesTagsConnector.js @@ -0,0 +1,30 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import SeriesTags from './SeriesTags'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createTagsSelector(), + (series, tagList) => { + const tags = _.reduce(series.tags, (acc, tag) => { + const matchingTag = _.find(tagList, { id: tag }); + + if (matchingTag) { + acc.push(matchingTag.label); + } + + return acc; + }, []); + + return { + tags + }; + } + ); +} + +export default connect(createMapStateToProps)(SeriesTags); diff --git a/frontend/src/Series/Edit/EditSeriesModal.js b/frontend/src/Series/Edit/EditSeriesModal.js new file mode 100644 index 000000000..7c34f9586 --- /dev/null +++ b/frontend/src/Series/Edit/EditSeriesModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EditSeriesModalContentConnector from './EditSeriesModalContentConnector'; + +function EditSeriesModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditSeriesModal; diff --git a/frontend/src/Series/Edit/EditSeriesModalConnector.js b/frontend/src/Series/Edit/EditSeriesModalConnector.js new file mode 100644 index 000000000..2dfa43e31 --- /dev/null +++ b/frontend/src/Series/Edit/EditSeriesModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditSeriesModal from './EditSeriesModal'; + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditSeriesModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'series' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditSeriesModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(EditSeriesModalConnector); diff --git a/frontend/src/Series/Edit/EditSeriesModalContent.css b/frontend/src/Series/Edit/EditSeriesModalContent.css new file mode 100644 index 000000000..a3c7f464c --- /dev/null +++ b/frontend/src/Series/Edit/EditSeriesModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Series/Edit/EditSeriesModalContent.js b/frontend/src/Series/Edit/EditSeriesModalContent.js new file mode 100644 index 000000000..c237cc824 --- /dev/null +++ b/frontend/src/Series/Edit/EditSeriesModalContent.js @@ -0,0 +1,223 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import MoveSeriesModal from 'Series/MoveSeries/MoveSeriesModal'; +import styles from './EditSeriesModalContent.css'; + +class EditSeriesModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isConfirmMoveModalOpen: false + }; + } + + // + // Listeners + + onSavePress = () => { + const { + isPathChanging, + onSavePress + } = this.props; + + if (isPathChanging && !this.state.isConfirmMoveModalOpen) { + this.setState({ isConfirmMoveModalOpen: true }); + } else { + this.setState({ isConfirmMoveModalOpen: false }); + + onSavePress(false); + } + } + + onMoveSeriesPress = () => { + this.setState({ isConfirmMoveModalOpen: false }); + + this.props.onSavePress(true); + } + + // + // Render + + render() { + const { + title, + item, + isSaving, + showLanguageProfile, + originalPath, + onInputChange, + onModalClose, + onDeleteSeriesPress, + ...otherProps + } = this.props; + + const { + monitored, + seasonFolder, + qualityProfileId, + languageProfileId, + seriesType, + path, + tags + } = item; + + return ( + + + Edit - {title} + + + +
+ + Monitored + + + + + + Use Season Folder + + + + + + Quality Profile + + + + + { + showLanguageProfile && + + Language Profile + + + + } + + + Series Type + + + + + + Path + + + + + + Tags + + + +
+
+ + + + + + + + Save + + + + +
+ ); + } +} + +EditSeriesModalContent.propTypes = { + seriesId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + item: PropTypes.object.isRequired, + isSaving: PropTypes.bool.isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + isPathChanging: PropTypes.bool.isRequired, + originalPath: PropTypes.string.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteSeriesPress: PropTypes.func.isRequired +}; + +export default EditSeriesModalContent; diff --git a/frontend/src/Series/Edit/EditSeriesModalContentConnector.js b/frontend/src/Series/Edit/EditSeriesModalContentConnector.js new file mode 100644 index 000000000..c1e6a8e9f --- /dev/null +++ b/frontend/src/Series/Edit/EditSeriesModalContentConnector.js @@ -0,0 +1,120 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import { setSeriesValue, saveSeries } from 'Store/Actions/seriesActions'; +import EditSeriesModalContent from './EditSeriesModalContent'; + +function createIsPathChangingSelector() { + return createSelector( + (state) => state.series.pendingChanges, + createSeriesSelector(), + (pendingChanges, series) => { + const path = pendingChanges.path; + + if (path == null) { + return false; + } + + return series.path !== path; + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.series, + (state) => state.settings.languageProfiles, + createSeriesSelector(), + createIsPathChangingSelector(), + (seriesState, languageProfiles, series, isPathChanging) => { + const { + isSaving, + saveError, + pendingChanges + } = seriesState; + + const seriesSettings = _.pick(series, [ + 'monitored', + 'seasonFolder', + 'qualityProfileId', + 'languageProfileId', + 'seriesType', + 'path', + 'tags' + ]); + + const settings = selectSettings(seriesSettings, pendingChanges, saveError); + + return { + title: series.title, + isSaving, + saveError, + isPathChanging, + originalPath: series.path, + item: settings.settings, + showLanguageProfile: languageProfiles.items.length > 1, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetSeriesValue: setSeriesValue, + dispatchSaveSeries: saveSeries +}; + +class EditSeriesModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetSeriesValue({ name, value }); + } + + onSavePress = (moveFiles) => { + this.props.dispatchSaveSeries({ + id: this.props.seriesId, + moveFiles + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditSeriesModalContentConnector.propTypes = { + seriesId: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + dispatchSetSeriesValue: PropTypes.func.isRequired, + dispatchSaveSeries: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditSeriesModalContentConnector); diff --git a/frontend/src/Series/Editor/Delete/DeleteSeriesModal.js b/frontend/src/Series/Editor/Delete/DeleteSeriesModal.js new file mode 100644 index 000000000..84fca1612 --- /dev/null +++ b/frontend/src/Series/Editor/Delete/DeleteSeriesModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DeleteSeriesModalContentConnector from './DeleteSeriesModalContentConnector'; + +function DeleteSeriesModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +DeleteSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default DeleteSeriesModal; diff --git a/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.css b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.css new file mode 100644 index 000000000..950fdc27d --- /dev/null +++ b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.css @@ -0,0 +1,13 @@ +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.pathContainer { + margin-left: 5px; +} + +.path { + margin-left: 5px; + color: $dangerColor; +} diff --git a/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.js b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.js new file mode 100644 index 000000000..79c854cad --- /dev/null +++ b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContent.js @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './DeleteSeriesModalContent.css'; + +class DeleteSeriesModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + deleteFiles: false + }; + } + + // + // Listeners + + onDeleteFilesChange = ({ value }) => { + this.setState({ deleteFiles: value }); + } + + onDeleteSeriesConfirmed = () => { + const deleteFiles = this.state.deleteFiles; + + this.setState({ deleteFiles: false }); + this.props.onDeleteSelectedPress(deleteFiles); + } + + // + // Render + + render() { + const { + series, + onModalClose + } = this.props; + const deleteFiles = this.state.deleteFiles; + + return ( + + + Delete Selected Series + + + +
+ + {`Delete Series Folder${series.length > 1 ? 's' : ''}`} + + 1 ? 's' : ''} and all contents`} + kind={kinds.DANGER} + onChange={this.onDeleteFilesChange} + /> + +
+ +
+ {`Are you sure you want to delete ${series.length} selected series${deleteFiles ? ' and all contents' : ''}?`} +
+ +
    + { + series.map((s) => { + return ( +
  • + {s.title} + + { + deleteFiles && + + - + + {s.path} + + + } +
  • + ); + }) + } +
+
+ + + + + + +
+ ); + } +} + +DeleteSeriesModalContent.propTypes = { + series: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteSelectedPress: PropTypes.func.isRequired +}; + +export default DeleteSeriesModalContent; diff --git a/frontend/src/Series/Editor/Delete/DeleteSeriesModalContentConnector.js b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContentConnector.js new file mode 100644 index 000000000..0513842b7 --- /dev/null +++ b/frontend/src/Series/Editor/Delete/DeleteSeriesModalContentConnector.js @@ -0,0 +1,45 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import { bulkDeleteSeries } from 'Store/Actions/seriesEditorActions'; +import DeleteSeriesModalContent from './DeleteSeriesModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { seriesIds }) => seriesIds, + createAllSeriesSelector(), + (seriesIds, allSeries) => { + const selectedSeries = _.intersectionWith(allSeries, seriesIds, (s, id) => { + return s.id === id; + }); + + const sortedSeries = _.orderBy(selectedSeries, 'sortTitle'); + const series = _.map(sortedSeries, (s) => { + return { + title: s.title, + path: s.path + }; + }); + + return { + series + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onDeleteSelectedPress(deleteFiles) { + dispatch(bulkDeleteSeries({ + seriesIds: props.seriesIds, + deleteFiles + })); + + props.onModalClose(); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteSeriesModalContent); diff --git a/frontend/src/Series/Editor/Organize/OrganizeSeriesModal.js b/frontend/src/Series/Editor/Organize/OrganizeSeriesModal.js new file mode 100644 index 000000000..c970392ec --- /dev/null +++ b/frontend/src/Series/Editor/Organize/OrganizeSeriesModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import OrganizeSeriesModalContentConnector from './OrganizeSeriesModalContentConnector'; + +function OrganizeSeriesModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +OrganizeSeriesModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default OrganizeSeriesModal; diff --git a/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.css b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.css new file mode 100644 index 000000000..0b896f4ef --- /dev/null +++ b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.css @@ -0,0 +1,8 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} diff --git a/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.js b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.js new file mode 100644 index 000000000..10a459d52 --- /dev/null +++ b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContent.js @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import Icon from 'Components/Icon'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './OrganizeSeriesModalContent.css'; + +function OrganizeSeriesModalContent(props) { + const { + seriesTitles, + onModalClose, + onOrganizeSeriesPress + } = props; + + return ( + + + Organize Selected Series + + + + + Tip: To preview a rename... select "Cancel" then any series title and use the + + + +
+ Are you sure you want to organize all files in the {seriesTitles.length} selected series? +
+ +
    + { + seriesTitles.map((title) => { + return ( +
  • + {title} +
  • + ); + }) + } +
+
+ + + + + + +
+ ); +} + +OrganizeSeriesModalContent.propTypes = { + seriesTitles: PropTypes.arrayOf(PropTypes.string).isRequired, + onModalClose: PropTypes.func.isRequired, + onOrganizeSeriesPress: PropTypes.func.isRequired +}; + +export default OrganizeSeriesModalContent; diff --git a/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContentConnector.js b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContentConnector.js new file mode 100644 index 000000000..dabd1c58a --- /dev/null +++ b/frontend/src/Series/Editor/Organize/OrganizeSeriesModalContentConnector.js @@ -0,0 +1,67 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import OrganizeSeriesModalContent from './OrganizeSeriesModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { seriesIds }) => seriesIds, + createAllSeriesSelector(), + (seriesIds, allSeries) => { + const series = _.intersectionWith(allSeries, seriesIds, (s, id) => { + return s.id === id; + }); + + const sortedSeries = _.orderBy(series, 'sortTitle'); + const seriesTitles = _.map(sortedSeries, 'title'); + + return { + seriesTitles + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand +}; + +class OrganizeSeriesModalContentConnector extends Component { + + // + // Listeners + + onOrganizeSeriesPress = () => { + this.props.executeCommand({ + name: commandNames.RENAME_SERIES, + seriesIds: this.props.seriesIds + }); + + this.props.onModalClose(true); + } + + // + // Render + + render(props) { + return ( + + ); + } +} + +OrganizeSeriesModalContentConnector.propTypes = { + seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired, + onModalClose: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(OrganizeSeriesModalContentConnector); diff --git a/frontend/src/Series/Editor/SeriesEditor.js b/frontend/src/Series/Editor/SeriesEditor.js new file mode 100644 index 000000000..511ed8070 --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditor.js @@ -0,0 +1,289 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import NoSeries from 'Series/NoSeries'; +import OrganizeSeriesModal from './Organize/OrganizeSeriesModal'; +import SeriesEditorRowConnector from './SeriesEditorRowConnector'; +import SeriesEditorFooter from './SeriesEditorFooter'; +import SeriesEditorFilterModalConnector from './SeriesEditorFilterModalConnector'; + +function getColumns(showLanguageProfile) { + return [ + { + name: 'status', + isSortable: true, + isVisible: true + }, + { + name: 'sortTitle', + label: 'Title', + isSortable: true, + isVisible: true + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + isSortable: true, + isVisible: true + }, + { + name: 'languageProfileId', + label: 'Language Profile', + isSortable: true, + isVisible: showLanguageProfile + }, + { + name: 'seriesType', + label: 'Series Type', + isSortable: false, + isVisible: true + }, + { + name: 'seasonFolder', + label: 'Season Folder', + isSortable: true, + isVisible: true + }, + { + name: 'path', + label: 'Path', + isSortable: true, + isVisible: true + }, + { + name: 'tags', + label: 'Tags', + isSortable: false, + isVisible: true + } + ]; +} + +class SeriesEditor extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isOrganizingSeriesModalOpen: false, + columns: getColumns(props.showLanguageProfile) + }; + } + + componentDidUpdate(prevProps) { + const { + isDeleting, + deleteError + } = this.props; + + const hasFinishedDeleting = prevProps.isDeleting && + !isDeleting && + !deleteError; + + if (hasFinishedDeleting) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onSaveSelected = (changes) => { + this.props.onSaveSelected({ + seriesIds: this.getSelectedIds(), + ...changes + }); + } + + onOrganizeSeriesPress = () => { + this.setState({ isOrganizingSeriesModalOpen: true }); + } + + onOrganizeSeriesModalClose = (organized) => { + this.setState({ isOrganizingSeriesModalOpen: false }); + + if (organized === true) { + this.onSelectAllChange({ value: false }); + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + isSaving, + saveError, + isDeleting, + deleteError, + isOrganizingSeries, + showLanguageProfile, + onSortPress, + onFilterSelect + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + columns + } = this.state; + + const selectedSeriesIds = this.getSelectedIds(); + + return ( + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load the calendar
+ } + + { + !error && isPopulated && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+
+ } + + { + !error && isPopulated && !items.length && + + } +
+ + + + +
+ ); + } +} + +SeriesEditor.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalItems: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + isOrganizingSeries: PropTypes.bool.isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + onSortPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onSaveSelected: PropTypes.func.isRequired +}; + +export default SeriesEditor; diff --git a/frontend/src/Series/Editor/SeriesEditorConnector.js b/frontend/src/Series/Editor/SeriesEditorConnector.js new file mode 100644 index 000000000..fb9cb478f --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorConnector.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { setSeriesEditorSort, setSeriesEditorFilter, saveSeriesEditor } from 'Store/Actions/seriesEditorActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import SeriesEditor from './SeriesEditor'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.languageProfiles, + createClientSideCollectionSelector('series', 'seriesEditor'), + createCommandExecutingSelector(commandNames.RENAME_SERIES), + (languageProfiles, series, isOrganizingSeries) => { + return { + isOrganizingSeries, + showLanguageProfile: languageProfiles.items.length > 1, + ...series + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetSeriesEditorSort: setSeriesEditorSort, + dispatchSetSeriesEditorFilter: setSeriesEditorFilter, + dispatchSaveSeriesEditor: saveSeriesEditor, + dispatchFetchRootFolders: fetchRootFolders, + dispatchExecuteCommand: executeCommand +}; + +class SeriesEditorConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchRootFolders(); + } + + // + // Listeners + + onSortPress = (sortKey) => { + this.props.dispatchSetSeriesEditorSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.dispatchSetSeriesEditorFilter({ selectedFilterKey }); + } + + onSaveSelected = (payload) => { + this.props.dispatchSaveSeriesEditor(payload); + } + + onMoveSelected = (payload) => { + this.props.dispatchExecuteCommand({ + name: commandNames.MOVE_SERIES, + ...payload + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeriesEditorConnector.propTypes = { + dispatchSetSeriesEditorSort: PropTypes.func.isRequired, + dispatchSetSeriesEditorFilter: PropTypes.func.isRequired, + dispatchSaveSeriesEditor: PropTypes.func.isRequired, + dispatchFetchRootFolders: PropTypes.func.isRequired, + dispatchExecuteCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesEditorConnector); diff --git a/frontend/src/Series/Editor/SeriesEditorFilterModalConnector.js b/frontend/src/Series/Editor/SeriesEditorFilterModalConnector.js new file mode 100644 index 000000000..07a5230b2 --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setSeriesEditorFilter } from 'Store/Actions/seriesEditorActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.series.items, + (state) => state.seriesEditor.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'seriesEditor' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setSeriesEditorFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Series/Editor/SeriesEditorFooter.css b/frontend/src/Series/Editor/SeriesEditorFooter.css new file mode 100644 index 000000000..5b509936b --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorFooter.css @@ -0,0 +1,57 @@ +.inputContainer { + margin-right: 20px; + min-width: 150px; +} + +.buttonContainer { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.buttonContainerContent { + flex-grow: 0; +} + +.buttons { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.organizeSelectedButton, +.tagsButton { + composes: button from 'Components/Link/SpinnerButton.css'; + + margin-right: 10px; + height: 35px; +} + +.deleteSelectedButton { + composes: button from 'Components/Link/SpinnerButton.css'; + + margin-left: 50px; + height: 35px; +} + +@media only screen and (max-width: $breakpointSmall) { + .inputContainer { + margin-right: 0; + } + + .buttonContainer { + justify-content: flex-start; + } + + .buttonContainerContent { + flex-grow: 1; + } + + .buttons { + justify-content: space-between; + } + + .selectedSeriesLabel { + text-align: left; + } +} diff --git a/frontend/src/Series/Editor/SeriesEditorFooter.js b/frontend/src/Series/Editor/SeriesEditorFooter.js new file mode 100644 index 000000000..b4edc9a83 --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorFooter.js @@ -0,0 +1,353 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import SelectInput from 'Components/Form/SelectInput'; +import LanguageProfileSelectInputConnector from 'Components/Form/LanguageProfileSelectInputConnector'; +import QualityProfileSelectInputConnector from 'Components/Form/QualityProfileSelectInputConnector'; +import RootFolderSelectInputConnector from 'Components/Form/RootFolderSelectInputConnector'; +import SeriesTypeSelectInput from 'Components/Form/SeriesTypeSelectInput'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import MoveSeriesModal from 'Series/MoveSeries/MoveSeriesModal'; +import TagsModal from './Tags/TagsModal'; +import DeleteSeriesModal from './Delete/DeleteSeriesModal'; +import SeriesEditorFooterLabel from './SeriesEditorFooterLabel'; +import styles from './SeriesEditorFooter.css'; + +const NO_CHANGE = 'noChange'; + +class SeriesEditorFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + monitored: NO_CHANGE, + qualityProfileId: NO_CHANGE, + languageProfileId: NO_CHANGE, + seriesType: NO_CHANGE, + seasonFolder: NO_CHANGE, + rootFolderPath: NO_CHANGE, + savingTags: false, + isDeleteSeriesModalOpen: false, + isTagsModalOpen: false, + isConfirmMoveModalOpen: false, + destinationRootFolder: null + }; + } + + componentDidUpdate(prevProps) { + const { + isSaving, + saveError + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + this.setState({ + monitored: NO_CHANGE, + qualityProfileId: NO_CHANGE, + languageProfileId: NO_CHANGE, + seriesType: NO_CHANGE, + seasonFolder: NO_CHANGE, + rootFolderPath: NO_CHANGE, + savingTags: false + }); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + + if (value === NO_CHANGE) { + return; + } + + switch (name) { + case 'rootFolderPath': + this.setState({ + isConfirmMoveModalOpen: true, + destinationRootFolder: value + }); + break; + case 'monitored': + this.props.onSaveSelected({ [name]: value === 'monitored' }); + break; + case 'seasonFolder': + this.props.onSaveSelected({ [name]: value === 'yes' }); + break; + default: + this.props.onSaveSelected({ [name]: value }); + } + } + + onApplyTagsPress = (tags, applyTags) => { + this.setState({ + savingTags: true, + isTagsModalOpen: false + }); + + this.props.onSaveSelected({ + tags, + applyTags + }); + } + + onDeleteSelectedPress = () => { + this.setState({ isDeleteSeriesModalOpen: true }); + } + + onDeleteSeriesModalClose = () => { + this.setState({ isDeleteSeriesModalOpen: false }); + } + + onTagsPress = () => { + this.setState({ isTagsModalOpen: true }); + } + + onTagsModalClose = () => { + this.setState({ isTagsModalOpen: false }); + } + + onSaveRootFolderPress = () => { + this.setState({ + isConfirmMoveModalOpen: false, + destinationRootFolder: null + }); + + this.props.onSaveSelected({ rootFolderPath: this.state.destinationRootFolder }); + } + + onMoveSeriesPress = () => { + this.setState({ + isConfirmMoveModalOpen: false, + destinationRootFolder: null + }); + + this.props.onSaveSelected({ + rootFolderPath: this.state.destinationRootFolder, + moveFiles: true + }); + } + + // + // Render + + render() { + const { + seriesIds, + selectedCount, + isSaving, + isDeleting, + isOrganizingSeries, + showLanguageProfile, + onOrganizeSeriesPress + } = this.props; + + const { + monitored, + qualityProfileId, + languageProfileId, + seriesType, + seasonFolder, + rootFolderPath, + savingTags, + isTagsModalOpen, + isDeleteSeriesModalOpen, + isConfirmMoveModalOpen, + destinationRootFolder + } = this.state; + + const monitoredOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'monitored', value: 'Monitored' }, + { key: 'unmonitored', value: 'Unmonitored' } + ]; + + const seasonFolderOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'yes', value: 'Yes' }, + { key: 'no', value: 'No' } + ]; + + return ( + +
+ + + +
+ +
+ + + +
+ + { + showLanguageProfile && +
+ + + +
+ } + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+
+ + +
+
+ + Rename Files + + + + Set Tags + +
+ + + Delete + +
+
+
+ + + + + + +
+ ); + } +} + +SeriesEditorFooter.propTypes = { + seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired, + selectedCount: PropTypes.number.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + isOrganizingSeries: PropTypes.bool.isRequired, + showLanguageProfile: PropTypes.bool.isRequired, + onSaveSelected: PropTypes.func.isRequired, + onOrganizeSeriesPress: PropTypes.func.isRequired +}; + +export default SeriesEditorFooter; diff --git a/frontend/src/Series/Editor/SeriesEditorFooterLabel.css b/frontend/src/Series/Editor/SeriesEditorFooterLabel.css new file mode 100644 index 000000000..9b4b40be6 --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorFooterLabel.css @@ -0,0 +1,8 @@ +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.savingIcon { + margin-left: 8px; +} diff --git a/frontend/src/Series/Editor/SeriesEditorFooterLabel.js b/frontend/src/Series/Editor/SeriesEditorFooterLabel.js new file mode 100644 index 000000000..fc77ece44 --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorFooterLabel.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import styles from './SeriesEditorFooterLabel.css'; + +function SeriesEditorFooterLabel(props) { + const { + className, + label, + isSaving + } = props; + + return ( +
+ {label} + + { + isSaving && + + } +
+ ); +} + +SeriesEditorFooterLabel.propTypes = { + className: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isSaving: PropTypes.bool.isRequired +}; + +SeriesEditorFooterLabel.defaultProps = { + className: styles.label +}; + +export default SeriesEditorFooterLabel; diff --git a/frontend/src/Series/Editor/SeriesEditorRow.css b/frontend/src/Series/Editor/SeriesEditorRow.css new file mode 100644 index 000000000..d53a30f6d --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorRow.css @@ -0,0 +1,5 @@ +.seasonFolder { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} diff --git a/frontend/src/Series/Editor/SeriesEditorRow.js b/frontend/src/Series/Editor/SeriesEditorRow.js new file mode 100644 index 000000000..2d9330c2b --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorRow.js @@ -0,0 +1,124 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import TagListConnector from 'Components/TagListConnector'; +import CheckInput from 'Components/Form/CheckInput'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import SeriesStatusCell from 'Series/Index/Table/SeriesStatusCell'; +import styles from './SeriesEditorRow.css'; + +class SeriesEditorRow extends Component { + + // + // Listeners + + onSeasonFolderChange = () => { + // Mock handler to satisfy `onChange` being required for `CheckInput`. + // + } + + // + // Render + + render() { + const { + id, + status, + titleSlug, + title, + monitored, + languageProfile, + qualityProfile, + seriesType, + seasonFolder, + path, + tags, + columns, + isSelected, + onSelectedChange + } = this.props; + + return ( + + + + + + + + + + + {qualityProfile.name} + + + { + _.find(columns, { name: 'languageProfileId' }).isVisible && + + {languageProfile.name} + + } + + + {titleCase(seriesType)} + + + + + + + + {path} + + + + + + + ); + } +} + +SeriesEditorRow.propTypes = { + id: PropTypes.number.isRequired, + status: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + languageProfile: PropTypes.object.isRequired, + qualityProfile: PropTypes.object.isRequired, + seriesType: PropTypes.string.isRequired, + seasonFolder: PropTypes.bool.isRequired, + path: PropTypes.string.isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isSelected: PropTypes.bool, + onSelectedChange: PropTypes.func.isRequired +}; + +SeriesEditorRow.defaultProps = { + tags: [] +}; + +export default SeriesEditorRow; diff --git a/frontend/src/Series/Editor/SeriesEditorRowConnector.js b/frontend/src/Series/Editor/SeriesEditorRowConnector.js new file mode 100644 index 000000000..3d1ee2e71 --- /dev/null +++ b/frontend/src/Series/Editor/SeriesEditorRowConnector.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector'; +import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector'; +import SeriesEditorRow from './SeriesEditorRow'; + +function createMapStateToProps() { + return createSelector( + createLanguageProfileSelector(), + createQualityProfileSelector(), + (languageProfile, qualityProfile) => { + return { + languageProfile, + qualityProfile + }; + } + ); +} + +function SeriesEditorRowConnector(props) { + return ( + + ); +} + +SeriesEditorRowConnector.propTypes = { + qualityProfileId: PropTypes.number.isRequired +}; + +export default connect(createMapStateToProps)(SeriesEditorRowConnector); diff --git a/frontend/src/Series/Editor/Tags/TagsModal.js b/frontend/src/Series/Editor/Tags/TagsModal.js new file mode 100644 index 000000000..0f6c2d7ec --- /dev/null +++ b/frontend/src/Series/Editor/Tags/TagsModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContentConnector from './TagsModalContentConnector'; + +function TagsModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +TagsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default TagsModal; diff --git a/frontend/src/Series/Editor/Tags/TagsModalContent.css b/frontend/src/Series/Editor/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Series/Editor/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Series/Editor/Tags/TagsModalContent.js b/frontend/src/Series/Editor/Tags/TagsModalContent.js new file mode 100644 index 000000000..ccc1120db --- /dev/null +++ b/frontend/src/Series/Editor/Tags/TagsModalContent.js @@ -0,0 +1,187 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import Label from 'Components/Label'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './TagsModalContent.css'; + +class TagsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + tags: [], + applyTags: 'add' + }; + } + + // + // Lifecycle + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + } + + onApplyTagsPress = () => { + const { + tags, + applyTags + } = this.state; + + this.props.onApplyTagsPress(tags, applyTags); + } + + // + // Render + + render() { + const { + seriesTags, + tagList, + onModalClose + } = this.props; + + const { + tags, + applyTags + } = this.state; + + const applyTagsOptions = [ + { key: 'add', value: 'Add' }, + { key: 'remove', value: 'Remove' }, + { key: 'replace', value: 'Replace' } + ]; + + return ( + + + Tags + + + +
+ + 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/Series/Editor/Tags/TagsModalContentConnector.js b/frontend/src/Series/Editor/Tags/TagsModalContentConnector.js new file mode 100644 index 000000000..9d4907971 --- /dev/null +++ b/frontend/src/Series/Editor/Tags/TagsModalContentConnector.js @@ -0,0 +1,36 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import TagsModalContent from './TagsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { seriesIds }) => seriesIds, + createAllSeriesSelector(), + createTagsSelector(), + (seriesIds, allSeries, tagList) => { + const series = _.intersectionWith(allSeries, seriesIds, (s, id) => { + return s.id === id; + }); + + const seriesTags = _.uniq(_.concat(..._.map(series, 'tags'))); + + return { + seriesTags, + tagList + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onAction() { + // Do something + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(TagsModalContent); diff --git a/frontend/src/Series/History/SeriesHistoryModal.js b/frontend/src/Series/History/SeriesHistoryModal.js new file mode 100644 index 000000000..2d46c5f6f --- /dev/null +++ b/frontend/src/Series/History/SeriesHistoryModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import SeriesHistoryModalContentConnector from './SeriesHistoryModalContentConnector'; + +function SeriesHistoryModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +SeriesHistoryModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeriesHistoryModal; diff --git a/frontend/src/Series/History/SeriesHistoryModalContent.js b/frontend/src/Series/History/SeriesHistoryModalContent.js new file mode 100644 index 000000000..ec593cd8c --- /dev/null +++ b/frontend/src/Series/History/SeriesHistoryModalContent.js @@ -0,0 +1,137 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import SeasonNumber from 'Season/SeasonNumber'; +import SeriesHistoryRowConnector from './SeriesHistoryRowConnector'; +const columns = [ + { + name: 'eventType', + isVisible: true + }, + { + name: 'episode', + label: 'Episode', + isVisible: true + }, + { + name: 'sourceTitle', + label: 'Source Title', + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isVisible: true + }, + { + name: 'details', + label: 'Details', + isVisible: true + }, + { + name: 'actions', + label: 'Actions', + isVisible: true + } +]; + +class SeriesHistoryModalContent extends Component { + + // + // Render + + render() { + const { + seasonNumber, + isFetching, + isPopulated, + error, + items, + onMarkAsFailedPress, + onModalClose + } = this.props; + + const fullSeries = seasonNumber == null; + const hasItems = !!items.length; + + return ( + + + History {seasonNumber != null && } + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to load history.
+ } + + { + isPopulated && !hasItems && !error && +
No history.
+ } + + { + isPopulated && hasItems && !error && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ + + + +
+ ); + } +} + +SeriesHistoryModalContent.propTypes = { + seasonNumber: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeriesHistoryModalContent; diff --git a/frontend/src/Series/History/SeriesHistoryModalContentConnector.js b/frontend/src/Series/History/SeriesHistoryModalContentConnector.js new file mode 100644 index 000000000..8c8d4e5b2 --- /dev/null +++ b/frontend/src/Series/History/SeriesHistoryModalContentConnector.js @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchSeriesHistory, clearSeriesHistory, seriesHistoryMarkAsFailed } from 'Store/Actions/seriesHistoryActions'; +import SeriesHistoryModalContent from './SeriesHistoryModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.seriesHistory, + (seriesHistory) => { + return seriesHistory; + } + ); +} + +const mapDispatchToProps = { + fetchSeriesHistory, + clearSeriesHistory, + seriesHistoryMarkAsFailed +}; + +class SeriesHistoryModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.fetchSeriesHistory({ + seriesId, + seasonNumber + }); + } + + componentWillUnmount() { + this.props.clearSeriesHistory(); + } + + // + // Listeners + + onMarkAsFailedPress = (historyId) => { + const { + seriesId, + seasonNumber + } = this.props; + + this.props.seriesHistoryMarkAsFailed({ + historyId, + seriesId, + seasonNumber + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeriesHistoryModalContentConnector.propTypes = { + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number, + fetchSeriesHistory: PropTypes.func.isRequired, + clearSeriesHistory: PropTypes.func.isRequired, + seriesHistoryMarkAsFailed: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesHistoryModalContentConnector); diff --git a/frontend/src/Series/History/SeriesHistoryRow.css b/frontend/src/Series/History/SeriesHistoryRow.css new file mode 100644 index 000000000..8c3fb8272 --- /dev/null +++ b/frontend/src/Series/History/SeriesHistoryRow.css @@ -0,0 +1,6 @@ +.details, +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 65px; +} diff --git a/frontend/src/Series/History/SeriesHistoryRow.js b/frontend/src/Series/History/SeriesHistoryRow.js new file mode 100644 index 000000000..ecf176091 --- /dev/null +++ b/frontend/src/Series/History/SeriesHistoryRow.js @@ -0,0 +1,186 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import EpisodeLanguage from 'Episode/EpisodeLanguage'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import EpisodeNumber from 'Episode/EpisodeNumber'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; +import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; +import styles from './SeriesHistoryRow.css'; + +function getTitle(eventType) { + switch (eventType) { + case 'grabbed': return 'Grabbed'; + case 'seriesFolderImported': return 'Series Folder Imported'; + case 'downloadFolderImported': return 'Download Folder Imported'; + case 'downloadFailed': return 'Download Failed'; + case 'episodeFileDeleted': return 'Episode File Deleted'; + case 'episodeFileRenamed': return 'Episode File Renamed'; + default: return 'Unknown'; + } +} + +class SeriesHistoryRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMarkAsFailedModalOpen: false + }; + } + + // + // Listeners + + onMarkAsFailedPress = () => { + this.setState({ isMarkAsFailedModalOpen: true }); + } + + onConfirmMarkAsFailed = () => { + this.props.onMarkAsFailedPress(this.props.id); + this.setState({ isMarkAsFailedModalOpen: false }); + } + + onMarkAsFailedModalClose = () => { + this.setState({ isMarkAsFailedModalOpen: false }); + } + + // + // Render + + render() { + const { + eventType, + sourceTitle, + language, + languageCutoffNotMet, + quality, + qualityCutoffNotMet, + date, + data, + fullSeries, + series, + episode + } = this.props; + + const { + isMarkAsFailedModalOpen + } = this.state; + + const EpisodeComponent = fullSeries ? SeasonEpisodeNumber : EpisodeNumber; + + return ( + + + + + + + + + {sourceTitle} + + + + + + + + + + + + + + + } + title={getTitle(eventType)} + body={ + + } + position={tooltipPositions.LEFT} + /> + + + + { + eventType === 'grabbed' && + + } + + + + + ); + } +} + +SeriesHistoryRow.propTypes = { + id: PropTypes.number.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + language: PropTypes.object.isRequired, + languageCutoffNotMet: PropTypes.bool.isRequired, + quality: PropTypes.object.isRequired, + qualityCutoffNotMet: PropTypes.bool.isRequired, + date: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + fullSeries: PropTypes.bool.isRequired, + series: PropTypes.object.isRequired, + episode: PropTypes.object.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired +}; + +export default SeriesHistoryRow; diff --git a/frontend/src/Series/History/SeriesHistoryRowConnector.js b/frontend/src/Series/History/SeriesHistoryRowConnector.js new file mode 100644 index 000000000..840e522f0 --- /dev/null +++ b/frontend/src/Series/History/SeriesHistoryRowConnector.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; +import SeriesHistoryRow from './SeriesHistoryRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createEpisodeSelector(), + (series, episode) => { + return { + series, + episode + }; + } + ); +} + +const mapDispatchToProps = { + fetchHistory, + markAsFailed +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesHistoryRow); diff --git a/frontend/src/Series/Index/Menus/SeriesIndexFilterMenu.js b/frontend/src/Series/Index/Menus/SeriesIndexFilterMenu.js new file mode 100644 index 000000000..29f2a15c8 --- /dev/null +++ b/frontend/src/Series/Index/Menus/SeriesIndexFilterMenu.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align } from 'Helpers/Props'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import SeriesIndexFilterModalConnector from 'Series/Index/SeriesIndexFilterModalConnector'; + +function SeriesIndexFilterMenu(props) { + const { + selectedFilterKey, + filters, + customFilters, + isDisabled, + onFilterSelect + } = props; + + return ( + + ); +} + +SeriesIndexFilterMenu.propTypes = { + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isDisabled: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired +}; + +SeriesIndexFilterMenu.defaultProps = { + showCustomFilters: false +}; + +export default SeriesIndexFilterMenu; diff --git a/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.js b/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.js new file mode 100644 index 000000000..83aabf604 --- /dev/null +++ b/frontend/src/Series/Index/Menus/SeriesIndexSortMenu.js @@ -0,0 +1,159 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, sortDirections } from 'Helpers/Props'; +import SortMenu from 'Components/Menu/SortMenu'; +import MenuContent from 'Components/Menu/MenuContent'; +import SortMenuItem from 'Components/Menu/SortMenuItem'; + +function SeriesIndexSortMenu(props) { + const { + sortKey, + sortDirection, + isDisabled, + onSortSelect + } = props; + + return ( + + + + Monitored/Status + + + + Title + + + + Network + + + + Quality Profile + + + + Language Profile + + + + Next Airing + + + + Previous Airing + + + + Added + + + + Seasons + + + + Episodes + + + + Episode Count + + + + Latest Season + + + + Path + + + + Size on Disk + + + + ); +} + +SeriesIndexSortMenu.propTypes = { + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + isDisabled: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired +}; + +export default SeriesIndexSortMenu; diff --git a/frontend/src/Series/Index/Menus/SeriesIndexViewMenu.js b/frontend/src/Series/Index/Menus/SeriesIndexViewMenu.js new file mode 100644 index 000000000..2fa6f6f98 --- /dev/null +++ b/frontend/src/Series/Index/Menus/SeriesIndexViewMenu.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align } from 'Helpers/Props'; +import ViewMenu from 'Components/Menu/ViewMenu'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; + +function SeriesIndexViewMenu(props) { + const { + view, + isDisabled, + onViewSelect + } = props; + + return ( + + + + Table + + + + Posters + + + + Overview + + + + ); +} + +SeriesIndexViewMenu.propTypes = { + view: PropTypes.string.isRequired, + isDisabled: PropTypes.bool.isRequired, + onViewSelect: PropTypes.func.isRequired +}; + +export default SeriesIndexViewMenu; diff --git a/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModal.js b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModal.js new file mode 100644 index 000000000..0b7a28ba0 --- /dev/null +++ b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import SeriesIndexOverviewOptionsModalContentConnector from './SeriesIndexOverviewOptionsModalContentConnector'; + +function SeriesIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +SeriesIndexOverviewOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeriesIndexOverviewOptionsModal; diff --git a/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContent.js b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContent.js new file mode 100644 index 000000000..3cc19475c --- /dev/null +++ b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContent.js @@ -0,0 +1,305 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +const posterSizeOptions = [ + { key: 'small', value: 'Small' }, + { key: 'medium', value: 'Medium' }, + { key: 'large', value: 'Large' } +]; + +class SeriesIndexOverviewOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + detailedProgressBar: props.detailedProgressBar, + size: props.size, + showMonitored: props.showMonitored, + showNetwork: props.showNetwork, + showQualityProfile: props.showQualityProfile, + showPreviousAiring: props.showPreviousAiring, + showAdded: props.showAdded, + showSeasonCount: props.showSeasonCount, + showPath: props.showPath, + showSizeOnDisk: props.showSizeOnDisk, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + detailedProgressBar, + size, + showMonitored, + showNetwork, + showQualityProfile, + showPreviousAiring, + showAdded, + showSeasonCount, + showPath, + showSizeOnDisk, + showSearchAction + } = this.props; + + const state = {}; + + if (detailedProgressBar !== prevProps.detailedProgressBar) { + state.detailedProgressBar = detailedProgressBar; + } + + if (size !== prevProps.size) { + state.size = size; + } + + if (showMonitored !== prevProps.showMonitored) { + state.showMonitored = showMonitored; + } + + if (showNetwork !== prevProps.showNetwork) { + state.showNetwork = showNetwork; + } + + if (showQualityProfile !== prevProps.showQualityProfile) { + state.showQualityProfile = showQualityProfile; + } + + if (showPreviousAiring !== prevProps.showPreviousAiring) { + state.showPreviousAiring = showPreviousAiring; + } + + if (showAdded !== prevProps.showAdded) { + state.showAdded = showAdded; + } + + if (showSeasonCount !== prevProps.showSeasonCount) { + state.showSeasonCount = showSeasonCount; + } + + if (showPath !== prevProps.showPath) { + state.showPath = showPath; + } + + if (showSizeOnDisk !== prevProps.showSizeOnDisk) { + state.showSizeOnDisk = showSizeOnDisk; + } + + if (showSearchAction !== prevProps.showSearchAction) { + state.showSearchAction = showSearchAction; + } + + if (!_.isEmpty(state)) { + this.setState(state); + } + } + + // + // Listeners + + onChangeOverviewOption = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onChangeOverviewOption({ [name]: value }); + }); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + detailedProgressBar, + size, + showMonitored, + showNetwork, + showQualityProfile, + showPreviousAiring, + showAdded, + showSeasonCount, + showPath, + showSizeOnDisk, + showSearchAction + } = this.state; + + return ( + + + Overview Options + + + +
+ + Poster Size + + + + + + Detailed Progress Bar + + + + + + Show Monitored + + + + + + Show Network + + + + + + Show Quality Profile + + + + + + Show Previous Airing + + + + + + Show Date Added + + + + + + Show Season Count + + + + + + Show Path + + + + + + Show Size on Disk + + + + + + Show Search + + + +
+
+ + + + +
+ ); + } +} + +SeriesIndexOverviewOptionsModalContent.propTypes = { + size: PropTypes.string.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showNetwork: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + showPreviousAiring: PropTypes.bool.isRequired, + showAdded: PropTypes.bool.isRequired, + showSeasonCount: PropTypes.bool.isRequired, + showPath: PropTypes.bool.isRequired, + showSizeOnDisk: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onChangeOverviewOption: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeriesIndexOverviewOptionsModalContent; diff --git a/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContentConnector.js b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContentConnector.js new file mode 100644 index 000000000..5feffa506 --- /dev/null +++ b/frontend/src/Series/Index/Overview/Options/SeriesIndexOverviewOptionsModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setSeriesOverviewOption } from 'Store/Actions/seriesIndexActions'; +import SeriesIndexOverviewOptionsModalContent from './SeriesIndexOverviewOptionsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.seriesIndex, + (seriesIndex) => { + return seriesIndex.overviewOptions; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onChangeOverviewOption(payload) { + dispatch(setSeriesOverviewOption(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesIndexOverviewOptionsModalContent); diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverview.css b/frontend/src/Series/Index/Overview/SeriesIndexOverview.css new file mode 100644 index 000000000..6311d9be1 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverview.css @@ -0,0 +1,96 @@ +$hoverScale: 1.05; + +.container { + &:hover { + .content { + background-color: $tableRowHoverBackgroundColor; + } + } +} + +.content { + display: flex; + flex-grow: 1; +} + +.poster { + position: relative; +} + +.posterContainer { + position: relative; +} + +.link { + composes: link from 'Components/Link/Link.css'; + + display: block; + color: $defaultColor; + + &:hover { + color: $defaultColor; + text-decoration: none; + } +} + +.ended { + position: absolute; + top: 0; + right: 0; + z-index: 1; + width: 0; + height: 0; + border-width: 0 25px 25px 0; + border-style: solid; + border-color: transparent $dangerColor transparent transparent; + color: $white; +} + +.info { + display: flex; + flex: 1 0 1px; + flex-direction: column; + overflow: hidden; + padding-left: 10px; +} + +.titleRow { + display: flex; + justify-content: space-between; + flex: 0 0 auto; + margin-bottom: 10px; + line-height: 32px; +} + +.title { + @add-mixin truncate; + composes: link; + + flex: 1 0 1px; + font-weight: 300; + font-size: 30px; +} + +.actions { + white-space: nowrap; +} + +.details { + display: flex; + justify-content: space-between; + flex: 1 0 auto; +} + +.overview { + composes: link; + + flex: 0 1 1000px; + overflow: hidden; + min-height: 0; +} + +@media only screen and (max-width: $breakpointSmall) { + .overview { + display: none; + } +} diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverview.js b/frontend/src/Series/Index/Overview/SeriesIndexOverview.js new file mode 100644 index 000000000..c89f76430 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverview.js @@ -0,0 +1,280 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import { icons } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import fonts from 'Styles/Variables/fonts'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import SeriesPoster from 'Series/SeriesPoster'; +import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; +import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; +import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar'; +import SeriesIndexOverviewInfo from './SeriesIndexOverviewInfo'; +import styles from './SeriesIndexOverview.css'; + +const columnPadding = parseInt(dimensions.seriesIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.seriesIndexColumnPaddingSmallScreen); +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +// Hardcoded height beased on line-height of 32 + bottom margin of 10. +// Less side-effecty than using react-measure. +const titleRowHeight = 42; + +function getContentHeight(rowHeight, isSmallScreen) { + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + + return rowHeight - padding; +} + +class SeriesIndexOverview extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: false + }; + } + + // + // Listeners + + onEditSeriesPress = () => { + this.setState({ isEditSeriesModalOpen: true }); + } + + onEditSeriesModalClose = () => { + this.setState({ isEditSeriesModalOpen: false }); + } + + onDeleteSeriesPress = () => { + this.setState({ + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: true + }); + } + + onDeleteSeriesModalClose = () => { + this.setState({ isDeleteSeriesModalOpen: false }); + } + + // + // Render + + render() { + const { + style, + id, + title, + overview, + monitored, + status, + titleSlug, + nextAiring, + statistics, + images, + posterWidth, + posterHeight, + qualityProfile, + overviewOptions, + showSearchAction, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + rowHeight, + isSmallScreen, + isRefreshingSeries, + isSearchingSeries, + onRefreshSeriesPress, + onSearchPress, + ...otherProps + } = this.props; + + const { + seasonCount, + episodeCount, + episodeFileCount, + totalEpisodeCount + } = statistics; + + const { + isEditSeriesModalOpen, + isDeleteSeriesModalOpen + } = this.state; + + const link = `/series/${titleSlug}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px` + }; + + const contentHeight = getContentHeight(rowHeight, isSmallScreen); + const overviewHeight = contentHeight - titleRowHeight; + + return ( +
+
+
+
+ { + status === 'ended' && +
+ } + + + + +
+ + +
+ +
+
+ + {title} + + +
+ + + { + showSearchAction && + + } + + +
+
+ +
+ + + + + +
+
+
+ + + + +
+ ); + } +} + +SeriesIndexOverview.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + overview: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + nextAiring: PropTypes.string, + statistics: PropTypes.object.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + posterWidth: PropTypes.number.isRequired, + posterHeight: PropTypes.number.isRequired, + rowHeight: PropTypes.number.isRequired, + qualityProfile: PropTypes.object.isRequired, + overviewOptions: PropTypes.object.isRequired, + showSearchAction: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + isRefreshingSeries: PropTypes.bool.isRequired, + isSearchingSeries: PropTypes.bool.isRequired, + onRefreshSeriesPress: PropTypes.func.isRequired +}; + +SeriesIndexOverview.defaultProps = { + statistics: { + seasonCount: 0, + episodeCount: 0, + episodeFileCount: 0, + totalEpisodeCount: 0 + } +}; + +export default SeriesIndexOverview; diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.css b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.css new file mode 100644 index 000000000..5dc53762f --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.css @@ -0,0 +1,12 @@ +.infos { + display: flex; + flex: 0 0 250px; + flex-direction: column; + margin-left: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .infos { + margin-left: 0; + } +} diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.js b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.js new file mode 100644 index 000000000..844219f04 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfo.js @@ -0,0 +1,266 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import SeriesIndexOverviewInfoRow from './SeriesIndexOverviewInfoRow'; +import styles from './SeriesIndexOverviewInfo.css'; + +const infoRowHeight = parseInt(dimensions.seriesIndexOverviewInfoRowHeight); + +const rows = [ + { + name: 'monitored', + showProp: 'showMonitored', + valueProp: 'monitored' + + }, + { + name: 'network', + showProp: 'showNetwork', + valueProp: 'network' + }, + { + name: 'qualityProfileId', + showProp: 'showQualityProfile', + valueProp: 'qualityProfileId' + }, + { + name: 'previousAiring', + showProp: 'showPreviousAiring', + valueProp: 'previousAiring' + }, + { + name: 'added', + showProp: 'showAdded', + valueProp: 'added' + }, + { + name: 'seasonCount', + showProp: 'showSeasonCount', + valueProp: 'seasonCount' + }, + { + name: 'path', + showProp: 'showPath', + valueProp: 'path' + }, + { + name: 'sizeOnDisk', + showProp: 'showSizeOnDisk', + valueProp: 'sizeOnDisk' + } +]; + +function isVisible(row, props) { + const { + name, + showProp, + valueProp + } = row; + + if (props[valueProp] == null) { + return false; + } + + return props[showProp] || props.sortKey === name; +} + +function getInfoRowProps(row, props) { + const { name } = row; + + if (name === 'monitored') { + const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored'; + + return { + title: monitoredText, + iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED, + label: monitoredText + }; + } + + if (name === 'network') { + return { + title: 'Network', + iconName: icons.NETWORK, + label: props.network + }; + } + + if (name === 'qualityProfileId') { + return { + title: 'Quality Profile', + iconName: icons.PROFILE, + label: props.qualityProfile.name + }; + } + + if (name === 'previousAiring') { + const { + previousAiring, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat + } = props; + + return { + title: `Previous Airing: ${formatDateTime(previousAiring, longDateFormat, timeFormat)}`, + iconName: icons.CALENDAR, + label: getRelativeDate( + previousAiring, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + }; + } + + if (name === 'added') { + const { + added, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat + } = props; + + return { + title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, + iconName: icons.ADD, + label: getRelativeDate( + added, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + }; + } + + if (name === 'seasonCount') { + const { seasonCount } = props; + let seasons = '1 season'; + + if (seasonCount === 0) { + seasons = 'No seasons'; + } else if (seasonCount > 1) { + seasons = `${seasonCount} seasons`; + } + + return { + title: 'Season Count', + iconName: icons.CIRCLE, + label: seasons + }; + } + + if (name === 'path') { + return { + title: 'Path', + iconName: icons.FOLDER, + label: props.path + }; + } + + if (name === 'sizeOnDisk') { + return { + title: 'Size on Disk', + iconName: icons.DRIVE, + label: formatBytes(props.sizeOnDisk) + }; + } +} + +function SeriesIndexOverviewInfo(props) { + const { + height, + nextAiring, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat + } = props; + + let shownRows = 1; + const maxRows = Math.floor(height / (infoRowHeight + 4)); + + return ( +
+ { + !!nextAiring && + + } + + { + rows.map((row) => { + if (!isVisible(row, props)) { + return null; + } + + if (shownRows >= maxRows) { + return null; + } + + shownRows++; + + const infoRowProps = getInfoRowProps(row, props); + + return ( + + ); + }) + } +
+ ); +} + +SeriesIndexOverviewInfo.propTypes = { + height: PropTypes.number.isRequired, + showNetwork: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + showPreviousAiring: PropTypes.bool.isRequired, + showAdded: PropTypes.bool.isRequired, + showSeasonCount: PropTypes.bool.isRequired, + showPath: PropTypes.bool.isRequired, + showSizeOnDisk: PropTypes.bool.isRequired, + monitored: PropTypes.bool.isRequired, + nextAiring: PropTypes.string, + network: PropTypes.string, + qualityProfile: PropTypes.object.isRequired, + previousAiring: PropTypes.string, + added: PropTypes.string, + seasonCount: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + sizeOnDisk: PropTypes.number, + sortKey: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default SeriesIndexOverviewInfo; diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.css b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.css new file mode 100644 index 000000000..bae40ed1f --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.css @@ -0,0 +1,10 @@ +.infoRow { + flex: 0 0 $seriesIndexOverviewInfoRowHeight; + margin: 2px 0; +} + +.icon { + margin-right: 5px; + width: 25px !important; + text-align: center; +} diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.js b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.js new file mode 100644 index 000000000..87c388869 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewInfoRow.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import styles from './SeriesIndexOverviewInfoRow.css'; + +function SeriesIndexOverviewInfoRow(props) { + const { + title, + iconName, + label + } = props; + + return ( +
+ + + {label} +
+ ); +} + +SeriesIndexOverviewInfoRow.propTypes = { + title: PropTypes.string, + iconName: PropTypes.object.isRequired, + label: PropTypes.string.isRequired +}; + +export default SeriesIndexOverviewInfoRow; diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviews.css b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.css new file mode 100644 index 000000000..9c6520fb5 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.css @@ -0,0 +1,3 @@ +.grid { + flex: 1 0 auto; +} diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviews.js b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.js new file mode 100644 index 000000000..3d0ab8c20 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviews.js @@ -0,0 +1,289 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { Grid, WindowScroller } from 'react-virtualized'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import dimensions from 'Styles/Variables/dimensions'; +import { sortDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; +import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector'; +import SeriesIndexOverview from './SeriesIndexOverview'; +import styles from './SeriesIndexOverviews.css'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.seriesIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.seriesIndexColumnPaddingSmallScreen); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +function calculatePosterWidth(posterSize, isSmallScreen) { + const maxiumPosterWidth = isSmallScreen ? 152 : 162; + + if (posterSize === 'large') { + return maxiumPosterWidth; + } + + if (posterSize === 'medium') { + return Math.floor(maxiumPosterWidth * 0.75); + } + + return Math.floor(maxiumPosterWidth * 0.5); +} + +function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) { + const { + detailedProgressBar + } = overviewOptions; + + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding + ]; + + return heights.reduce((acc, height) => acc + height, 0); +} + +function calculatePosterHeight(posterWidth) { + return Math.ceil((250 / 170) * posterWidth); +} + +class SeriesIndexOverviews extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnCount: 1, + posterWidth: 162, + posterHeight: 238, + rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}) + }; + + this._isInitialized = false; + this._grid = null; + } + + componentDidMount() { + this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody); + } + + componentDidUpdate(prevProps) { + const { + items, + filters, + sortKey, + sortDirection, + overviewOptions, + jumpToCharacter + } = this.props; + + const itemsChanged = hasDifferentItems(prevProps.items, items); + const overviewOptionsChanged = !_.isMatch(prevProps.overviewOptions, overviewOptions); + + if ( + prevProps.sortKey !== sortKey || + prevProps.overviewOptions !== overviewOptions || + itemsChanged + ) { + this.calculateGrid(); + } + + if ( + prevProps.filters !== filters || + prevProps.sortKey !== sortKey || + prevProps.sortDirection !== sortDirection || + itemsChanged || + overviewOptionsChanged + ) { + this._grid.recomputeGridSize(); + } + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const { + rowHeight + } = this.state; + + const scrollTop = rowHeight * index; + + this.props.onScroll({ scrollTop }); + } + } + } + + // + // Control + + scrollToFirstCharacter(character) { + const items = this.props.items; + const { + rowHeight + } = this.state; + + const index = getIndexOfFirstCharacter(items, character); + + if (index != null) { + const scrollTop = rowHeight * index; + + this.props.onScroll({ scrollTop }); + } + } + + setGridRef = (ref) => { + this._grid = ref; + } + + calculateGrid = (width = this.state.width, isSmallScreen) => { + const { + sortKey, + overviewOptions + } = this.props; + + const posterWidth = calculatePosterWidth(overviewOptions.size, isSmallScreen); + const posterHeight = calculatePosterHeight(posterWidth); + const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions); + + this.setState({ + width, + posterWidth, + posterHeight, + rowHeight + }); + } + + cellRenderer = ({ key, rowIndex, style }) => { + const { + items, + sortKey, + overviewOptions, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + isSmallScreen + } = this.props; + + const { + posterWidth, + posterHeight, + rowHeight + } = this.state; + + const series = items[rowIndex]; + + if (!series) { + return null; + } + + return ( + + ); + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.calculateGrid(width, this.props.isSmallScreen); + } + + onSectionRendered = () => { + if (!this._isInitialized && this._contentBodyNode) { + this.props.onRender(); + this._isInitialized = true; + } + } + + // + // Render + + render() { + const { + items, + scrollTop, + isSmallScreen, + onScroll + } = this.props; + + const { + width, + rowHeight + } = this.state; + + return ( + + + {({ height, isScrolling }) => { + return ( + + ); + } + } + + + ); + } +} + +SeriesIndexOverviews.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + overviewOptions: PropTypes.object.isRequired, + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + contentBody: PropTypes.object.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default SeriesIndexOverviews; diff --git a/frontend/src/Series/Index/Overview/SeriesIndexOverviewsConnector.js b/frontend/src/Series/Index/Overview/SeriesIndexOverviewsConnector.js new file mode 100644 index 000000000..cf7025984 --- /dev/null +++ b/frontend/src/Series/Index/Overview/SeriesIndexOverviewsConnector.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import SeriesIndexOverviews from './SeriesIndexOverviews'; + +function createMapStateToProps() { + return createSelector( + (state) => state.seriesIndex.overviewOptions, + createClientSideCollectionSelector('series', 'seriesIndex'), + createUISettingsSelector(), + createDimensionsSelector(), + (overviewOptions, series, uiSettings, dimensions) => { + return { + overviewOptions, + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat, + isSmallScreen: dimensions.isSmallScreen, + ...series + }; + } + ); +} + +export default connect(createMapStateToProps)(SeriesIndexOverviews); diff --git a/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModal.js b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModal.js new file mode 100644 index 000000000..8b1d79dcb --- /dev/null +++ b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import SeriesIndexPosterOptionsModalContentConnector from './SeriesIndexPosterOptionsModalContentConnector'; + +function SeriesIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +SeriesIndexPosterOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeriesIndexPosterOptionsModal; diff --git a/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContent.js b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContent.js new file mode 100644 index 000000000..b7f66401e --- /dev/null +++ b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContent.js @@ -0,0 +1,213 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; + +const posterSizeOptions = [ + { key: 'small', value: 'Small' }, + { key: 'medium', value: 'Medium' }, + { key: 'large', value: 'Large' } +]; + +class SeriesIndexPosterOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + detailedProgressBar: props.detailedProgressBar, + size: props.size, + showTitle: props.showTitle, + showMonitored: props.showMonitored, + showQualityProfile: props.showQualityProfile, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showSearchAction + } = this.props; + + const state = {}; + + if (detailedProgressBar !== prevProps.detailedProgressBar) { + state.detailedProgressBar = detailedProgressBar; + } + + if (size !== prevProps.size) { + state.size = size; + } + + if (showTitle !== prevProps.showTitle) { + state.showTitle = showTitle; + } + + if (showMonitored !== prevProps.showMonitored) { + state.showMonitored = showMonitored; + } + + if (showQualityProfile !== prevProps.showQualityProfile) { + state.showQualityProfile = showQualityProfile; + } + + if (showSearchAction !== prevProps.showSearchAction) { + state.showSearchAction = showSearchAction; + } + + if (!_.isEmpty(state)) { + this.setState(state); + } + } + + // + // Listeners + + onChangePosterOption = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onChangePosterOption({ [name]: value }); + }); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showSearchAction + } = this.state; + + return ( + + + Poster Options + + + +
+ + Poster Size + + + + + + Detailed Progress Bar + + + + + + Show Title + + + + + + Show Monitored + + + + + + Show Quality Profile + + + + + + Show Search + + + +
+
+ + + + +
+ ); + } +} + +SeriesIndexPosterOptionsModalContent.propTypes = { + size: PropTypes.string.isRequired, + showTitle: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onChangePosterOption: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeriesIndexPosterOptionsModalContent; diff --git a/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContentConnector.js b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContentConnector.js new file mode 100644 index 000000000..bb15208d7 --- /dev/null +++ b/frontend/src/Series/Index/Posters/Options/SeriesIndexPosterOptionsModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setSeriesPosterOption } from 'Store/Actions/seriesIndexActions'; +import SeriesIndexPosterOptionsModalContent from './SeriesIndexPosterOptionsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.seriesIndex, + (seriesIndex) => { + return seriesIndex.posterOptions; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onChangePosterOption(payload) { + dispatch(setSeriesPosterOption(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesIndexPosterOptionsModalContent); diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPoster.css b/frontend/src/Series/Index/Posters/SeriesIndexPoster.css new file mode 100644 index 000000000..227784df1 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPoster.css @@ -0,0 +1,102 @@ +$hoverScale: 1.05; + +.container { + padding: 10px; +} + +.content { + transition: all 200ms ease-in; + + &:hover { + z-index: 2; + box-shadow: 0 0 12px $black; + transition: all 200ms ease-in; + + .controls { + opacity: 0.9; + transition: opacity 200ms linear 150ms; + } + } +} + +.posterContainer { + position: relative; +} + +.link { + composes: link from 'Components/Link/Link.css'; + + position: relative; + display: block; + height: 70px; + background-color: $defaultColor; +} + +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: $offWhite; + text-align: center; + font-size: 20px; +} + +.nextAiring { + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} + +.title { + @add-mixin truncate; + + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} + +.ended { + position: absolute; + top: 0; + right: 0; + z-index: 1; + width: 0; + height: 0; + border-width: 0 25px 25px 0; + border-style: solid; + border-color: transparent $dangerColor transparent transparent; + color: $white; +} + +.controls { + position: absolute; + bottom: 10px; + left: 10px; + z-index: 3; + border-radius: 4px; + background-color: #4f566f; + color: $white; + font-size: $smallFontSize; + opacity: 0; + transition: opacity 0; +} + +.action { + composes: button from 'Components/Link/IconButton.css'; + + &:hover { + color: #ccc; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .container { + padding: 5px; + } +} diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPoster.js b/frontend/src/Series/Index/Posters/SeriesIndexPoster.js new file mode 100644 index 000000000..4e4238cc7 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPoster.js @@ -0,0 +1,293 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import SeriesPoster from 'Series/SeriesPoster'; +import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; +import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; +import SeriesIndexProgressBar from 'Series/Index/ProgressBar/SeriesIndexProgressBar'; +import SeriesIndexPosterInfo from './SeriesIndexPosterInfo'; +import styles from './SeriesIndexPoster.css'; + +class SeriesIndexPoster extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasPosterError: false, + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: false + }; + } + + // + // Listeners + + onEditSeriesPress = () => { + this.setState({ isEditSeriesModalOpen: true }); + } + + onEditSeriesModalClose = () => { + this.setState({ isEditSeriesModalOpen: false }); + } + + onDeleteSeriesPress = () => { + this.setState({ + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: true + }); + } + + onDeleteSeriesModalClose = () => { + this.setState({ isDeleteSeriesModalOpen: false }); + } + + onPosterLoad = () => { + if (this.state.hasPosterError) { + this.setState({ hasPosterError: false }); + } + } + + onPosterLoadError = () => { + if (!this.state.hasPosterError) { + this.setState({ hasPosterError: true }); + } + } + + // + // Render + + render() { + const { + style, + id, + title, + monitored, + status, + titleSlug, + nextAiring, + statistics, + images, + posterWidth, + posterHeight, + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + qualityProfile, + showSearchAction, + showRelativeDates, + shortDateFormat, + timeFormat, + isRefreshingSeries, + isSearchingSeries, + onRefreshSeriesPress, + onSearchPress, + ...otherProps + } = this.props; + + const { + seasonCount, + episodeCount, + episodeFileCount, + totalEpisodeCount + } = statistics; + + const { + hasPosterError, + isEditSeriesModalOpen, + isDeleteSeriesModalOpen + } = this.state; + + const link = `/series/${titleSlug}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px` + }; + + return ( +
+
+
+ + + { + status === 'ended' && +
+ } + + + + + { + hasPosterError && +
+ {title} +
+ } + +
+ + + + { + showTitle && +
+ {title} +
+ } + + { + showMonitored && +
+ {monitored ? 'Monitored' : 'Unmonitored'} +
+ } + + { + showQualityProfile && +
+ {qualityProfile.name} +
+ } + + { + nextAiring && +
+ { + getRelativeDate( + nextAiring, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + } +
+ } + + + + + + +
+
+ ); + } +} + +SeriesIndexPoster.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + nextAiring: PropTypes.string, + statistics: PropTypes.object.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + posterWidth: PropTypes.number.isRequired, + posterHeight: PropTypes.number.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showTitle: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + qualityProfile: PropTypes.object.isRequired, + showSearchAction: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isRefreshingSeries: PropTypes.bool.isRequired, + isSearchingSeries: PropTypes.bool.isRequired, + onRefreshSeriesPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +SeriesIndexPoster.defaultProps = { + statistics: { + seasonCount: 0, + episodeCount: 0, + episodeFileCount: 0, + totalEpisodeCount: 0 + } +}; + +export default SeriesIndexPoster; diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.css b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.css new file mode 100644 index 000000000..aab27d827 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.css @@ -0,0 +1,5 @@ +.info { + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.js b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.js new file mode 100644 index 000000000..39ecdca52 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPosterInfo.js @@ -0,0 +1,125 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import styles from './SeriesIndexPosterInfo.css'; + +function SeriesIndexPosterInfo(props) { + const { + network, + qualityProfile, + showQualityProfile, + previousAiring, + added, + seasonCount, + path, + sizeOnDisk, + sortKey, + showRelativeDates, + shortDateFormat, + timeFormat + } = props; + + if (sortKey === 'network' && network) { + return ( +
+ {network} +
+ ); + } + + if (sortKey === 'qualityProfileId' && !showQualityProfile) { + return ( +
+ {qualityProfile.name} +
+ ); + } + + if (sortKey === 'previousAiring' && previousAiring) { + return ( +
+ { + getRelativeDate( + previousAiring, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + } +
+ ); + } + + if (sortKey === 'added' && added) { + const addedDate = getRelativeDate( + added, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: false + } + ); + + return ( +
+ {`Added ${addedDate}`} +
+ ); + } + + if (sortKey === 'seasonCount') { + let seasons = '1 season'; + + if (seasonCount === 0) { + seasons = 'No seasons'; + } else if (seasonCount > 1) { + seasons = `${seasonCount} seasons`; + } + + return ( +
+ {seasons} +
+ ); + } + + if (sortKey === 'path') { + return ( +
+ {path} +
+ ); + } + + if (sortKey === 'sizeOnDisk') { + return ( +
+ {formatBytes(sizeOnDisk)} +
+ ); + } + + return null; +} + +SeriesIndexPosterInfo.propTypes = { + network: PropTypes.string, + showQualityProfile: PropTypes.bool.isRequired, + qualityProfile: PropTypes.object.isRequired, + previousAiring: PropTypes.string, + added: PropTypes.string, + seasonCount: PropTypes.number.isRequired, + path: PropTypes.string.isRequired, + sizeOnDisk: PropTypes.number, + sortKey: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default SeriesIndexPosterInfo; diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPosters.css b/frontend/src/Series/Index/Posters/SeriesIndexPosters.css new file mode 100644 index 000000000..9c6520fb5 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPosters.css @@ -0,0 +1,3 @@ +.grid { + flex: 1 0 auto; +} diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPosters.js b/frontend/src/Series/Index/Posters/SeriesIndexPosters.js new file mode 100644 index 000000000..986943a05 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPosters.js @@ -0,0 +1,327 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import { Grid, WindowScroller } from 'react-virtualized'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import dimensions from 'Styles/Variables/dimensions'; +import { sortDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; +import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector'; +import SeriesIndexPoster from './SeriesIndexPoster'; +import styles from './SeriesIndexPosters.css'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.seriesIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.seriesIndexColumnPaddingSmallScreen); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +const additionalColumnCount = { + small: 3, + medium: 2, + large: 1 +}; + +function calculateColumnWidth(width, posterSize, isSmallScreen) { + const maxiumColumnWidth = isSmallScreen ? 172 : 182; + const columns = Math.floor(width / maxiumColumnWidth); + const remainder = width % maxiumColumnWidth; + + if (remainder === 0 && posterSize === 'large') { + return maxiumColumnWidth; + } + + return Math.floor(width / (columns + additionalColumnCount[posterSize])); +} + +function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions) { + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile + } = posterOptions; + + const nextAiringHeight = 19; + + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + nextAiringHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding + ]; + + if (showTitle) { + heights.push(19); + } + + if (showMonitored) { + heights.push(19); + } + + if (showQualityProfile) { + heights.push(19); + } + + switch (sortKey) { + case 'network': + case 'seasons': + case 'previousAiring': + case 'added': + case 'path': + case 'sizeOnDisk': + heights.push(19); + break; + case 'qualityProfileId': + if (!showQualityProfile) { + heights.push(19); + } + break; + default: + // No need to add a height of 0 + } + + return heights.reduce((acc, height) => acc + height, 0); +} + +function calculatePosterHeight(posterWidth) { + return Math.ceil((250 / 170) * posterWidth); +} + +class SeriesIndexPosters extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnWidth: 182, + columnCount: 1, + posterWidth: 162, + posterHeight: 238, + rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}) + }; + + this._isInitialized = false; + this._grid = null; + } + + componentDidMount() { + this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody); + } + + componentDidUpdate(prevProps) { + const { + items, + filters, + sortKey, + sortDirection, + posterOptions, + jumpToCharacter + } = this.props; + + const itemsChanged = hasDifferentItems(prevProps.items, items); + + if ( + prevProps.sortKey !== sortKey || + prevProps.posterOptions !== posterOptions || + itemsChanged + ) { + this.calculateGrid(); + } + + if ( + prevProps.filters !== filters || + prevProps.sortKey !== sortKey || + prevProps.sortDirection !== sortDirection || + itemsChanged + ) { + this._grid.recomputeGridSize(); + } + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const { + columnCount, + rowHeight + } = this.state; + + const row = Math.floor(index / columnCount); + const scrollTop = rowHeight * row; + + this.props.onScroll({ scrollTop }); + } + } + } + + // + // Control + + setGridRef = (ref) => { + this._grid = ref; + } + + calculateGrid = (width = this.state.width, isSmallScreen) => { + const { + sortKey, + posterOptions + } = this.props; + + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + const columnWidth = calculateColumnWidth(width, posterOptions.size, isSmallScreen); + const columnCount = Math.max(Math.floor(width / columnWidth), 1); + const posterWidth = columnWidth - padding; + const posterHeight = calculatePosterHeight(posterWidth); + const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions); + + this.setState({ + width, + columnWidth, + columnCount, + posterWidth, + posterHeight, + rowHeight + }); + } + + cellRenderer = ({ key, rowIndex, columnIndex, style }) => { + const { + items, + sortKey, + posterOptions, + showRelativeDates, + shortDateFormat, + timeFormat + } = this.props; + + const { + posterWidth, + posterHeight, + columnCount + } = this.state; + + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile + } = posterOptions; + + const series = items[rowIndex * columnCount + columnIndex]; + + if (!series) { + return null; + } + + return ( + + ); + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.calculateGrid(width, this.props.isSmallScreen); + } + + onSectionRendered = () => { + if (!this._isInitialized && this._contentBodyNode) { + this.props.onRender(); + this._isInitialized = true; + } + } + + // + // Render + + render() { + const { + items, + scrollTop, + isSmallScreen, + onScroll + } = this.props; + + const { + width, + columnWidth, + columnCount, + rowHeight + } = this.state; + + const rowCount = Math.ceil(items.length / columnCount); + + return ( + + + {({ height, isScrolling }) => { + return ( + + ); + } + } + + + ); + } +} + +SeriesIndexPosters.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + posterOptions: PropTypes.object.isRequired, + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + contentBody: PropTypes.object.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default SeriesIndexPosters; diff --git a/frontend/src/Series/Index/Posters/SeriesIndexPostersConnector.js b/frontend/src/Series/Index/Posters/SeriesIndexPostersConnector.js new file mode 100644 index 000000000..0d74d3256 --- /dev/null +++ b/frontend/src/Series/Index/Posters/SeriesIndexPostersConnector.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import SeriesIndexPosters from './SeriesIndexPosters'; + +function createMapStateToProps() { + return createSelector( + (state) => state.seriesIndex.posterOptions, + createClientSideCollectionSelector('series', 'seriesIndex'), + createUISettingsSelector(), + createDimensionsSelector(), + (posterOptions, series, uiSettings, dimensions) => { + return { + posterOptions, + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + timeFormat: uiSettings.timeFormat, + isSmallScreen: dimensions.isSmallScreen, + ...series + }; + } + ); +} + +export default connect(createMapStateToProps)(SeriesIndexPosters); diff --git a/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.css b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.css new file mode 100644 index 000000000..dbf3499ab --- /dev/null +++ b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.css @@ -0,0 +1,14 @@ +.progress { + composes: container from 'Components/ProgressBar.css'; + + border-radius: 0; + background-color: #5b5b5b; + color: $white; + transition: width 200ms ease; +} + +.progressBar { + composes: progressBar from 'Components/ProgressBar.css'; + + transition: width 200ms ease; +} diff --git a/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.js b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.js new file mode 100644 index 000000000..882f4850a --- /dev/null +++ b/frontend/src/Series/Index/ProgressBar/SeriesIndexProgressBar.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getProgressBarKind from 'Utilities/Series/getProgressBarKind'; +import { sizes } from 'Helpers/Props'; +import ProgressBar from 'Components/ProgressBar'; +import styles from './SeriesIndexProgressBar.css'; + +function SeriesIndexProgressBar(props) { + const { + monitored, + status, + episodeCount, + episodeFileCount, + totalEpisodeCount, + posterWidth, + detailedProgressBar + } = props; + + const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100; + const text = `${episodeFileCount} / ${episodeCount}`; + + return ( + + ); +} + +SeriesIndexProgressBar.propTypes = { + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + episodeCount: PropTypes.number.isRequired, + episodeFileCount: PropTypes.number.isRequired, + totalEpisodeCount: PropTypes.number.isRequired, + posterWidth: PropTypes.number.isRequired, + detailedProgressBar: PropTypes.bool.isRequired +}; + +export default SeriesIndexProgressBar; diff --git a/frontend/src/Series/Index/SeriesIndex.css b/frontend/src/Series/Index/SeriesIndex.css new file mode 100644 index 000000000..443372a73 --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndex.css @@ -0,0 +1,51 @@ +.pageContentBodyWrapper { + display: flex; + flex: 1 0 1px; + overflow: hidden; +} + +.contentBody { + composes: contentBody from 'Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; +} + +.postersInnerContentBody { + composes: innerContentBody from 'Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; + + /* 5px less padding than normal to handle poster's 5px margin */ + padding: calc($pageContentBodyPadding - 5px); +} + +.tableInnerContentBody { + composes: innerContentBody from 'Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.contentBodyContainer { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +@media only screen and (max-width: $breakpointSmall) { + .pageContentBodyWrapper { + flex-basis: auto; + } + + .contentBody { + flex-basis: 1px; + } + + .postersInnerContentBody { + padding: calc($pageContentBodyPaddingSmallScreen - 5px); + } +} diff --git a/frontend/src/Series/Index/SeriesIndex.js b/frontend/src/Series/Index/SeriesIndex.js new file mode 100644 index 000000000..f1eae53c6 --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndex.js @@ -0,0 +1,369 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import { align, icons, sortDirections } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageJumpBar from 'Components/Page/PageJumpBar'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import NoSeries from 'Series/NoSeries'; +import SeriesIndexTableConnector from './Table/SeriesIndexTableConnector'; +import SeriesIndexPosterOptionsModal from './Posters/Options/SeriesIndexPosterOptionsModal'; +import SeriesIndexPostersConnector from './Posters/SeriesIndexPostersConnector'; +import SeriesIndexOverviewOptionsModal from './Overview/Options/SeriesIndexOverviewOptionsModal'; +import SeriesIndexOverviewsConnector from './Overview/SeriesIndexOverviewsConnector'; +import SeriesIndexFooter from './SeriesIndexFooter'; +import SeriesIndexFilterMenu from './Menus/SeriesIndexFilterMenu'; +import SeriesIndexSortMenu from './Menus/SeriesIndexSortMenu'; +import SeriesIndexViewMenu from './Menus/SeriesIndexViewMenu'; +import styles from './SeriesIndex.css'; + +function getViewComponent(view) { + if (view === 'posters') { + return SeriesIndexPostersConnector; + } + + if (view === 'overview') { + return SeriesIndexOverviewsConnector; + } + + return SeriesIndexTableConnector; +} + +class SeriesIndex extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + contentBody: null, + jumpBarItems: [], + jumpToCharacter: null, + isPosterOptionsModalOpen: false, + isOverviewOptionsModalOpen: false, + isRendered: false + }; + } + + componentDidMount() { + this.setJumpBarItems(); + } + + componentDidUpdate(prevProps) { + const { + items, + sortKey, + sortDirection, + scrollTop + } = this.props; + + if ( + hasDifferentItems(prevProps.items, items) || + sortKey !== prevProps.sortKey || + sortDirection !== prevProps.sortDirection + ) { + this.setJumpBarItems(); + } + + if (this.state.jumpToCharacter != null && scrollTop !== prevProps.scrollTop) { + this.setState({ jumpToCharacter: null }); + } + } + + // + // Control + + setContentBodyRef = (ref) => { + this.setState({ contentBody: ref }); + } + + setJumpBarItems() { + const { + items, + sortKey, + sortDirection + } = this.props; + + // Reset if not sorting by sortTitle + if (sortKey !== 'sortTitle') { + this.setState({ jumpBarItems: [] }); + return; + } + + const characters = _.reduce(items, (acc, item) => { + const firstCharacter = item.sortTitle.charAt(0); + + if (isNaN(firstCharacter)) { + acc.push(firstCharacter); + } else { + acc.push('#'); + } + + return acc; + }, []).sort(); + + // Reverse if sorting descending + if (sortDirection === sortDirections.DESCENDING) { + characters.reverse(); + } + + this.setState({ jumpBarItems: _.sortedUniq(characters) }); + } + + // + // Listeners + + onPosterOptionsPress = () => { + this.setState({ isPosterOptionsModalOpen: true }); + } + + onPosterOptionsModalClose = () => { + this.setState({ isPosterOptionsModalOpen: false }); + } + + onOverviewOptionsPress = () => { + this.setState({ isOverviewOptionsModalOpen: true }); + } + + onOverviewOptionsModalClose = () => { + this.setState({ isOverviewOptionsModalOpen: false }); + } + + onJumpBarItemPress = (jumpToCharacter) => { + this.setState({ jumpToCharacter }); + } + + onRender = () => { + this.setState({ isRendered: true }, () => { + const { + scrollTop, + isSmallScreen + } = this.props; + + if (isSmallScreen) { + // Seems to result in the view being off by 125px (distance to the top of the page) + // document.documentElement.scrollTop = document.body.scrollTop = scrollTop; + + // This works, but then jumps another 1px after scrolling + document.documentElement.scrollTop = scrollTop; + } + }); + } + + onScroll = ({ scrollTop }) => { + this.props.onScroll({ scrollTop }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + view, + isRefreshingSeries, + isRssSyncExecuting, + scrollTop, + onSortSelect, + onFilterSelect, + onViewSelect, + onRefreshSeriesPress, + onRssSyncPress, + ...otherProps + } = this.props; + + const { + contentBody, + jumpBarItems, + jumpToCharacter, + isPosterOptionsModalOpen, + isOverviewOptionsModalOpen, + isRendered + } = this.state; + + const ViewComponent = getViewComponent(view); + const isLoaded = !!(!error && isPopulated && items.length && contentBody); + const hasNoSeries = !totalItems; + + return ( + + + + + + + + + + + + { + view === 'posters' && + + } + + { + view === 'overview' && + + } + + { + (view === 'posters' || view === 'overview') && + + } + + + + + + + + + +
+ + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load series
+ } + + { + isLoaded && +
+ + + +
+ } + + { + !error && isPopulated && !items.length && + + } +
+ + { + isLoaded && !!jumpBarItems.length && + + } +
+ + + + +
+ ); + } +} + +SeriesIndex.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalItems: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + view: PropTypes.string.isRequired, + isRefreshingSeries: PropTypes.bool.isRequired, + isRssSyncExecuting: PropTypes.bool.isRequired, + scrollTop: PropTypes.number.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onViewSelect: PropTypes.func.isRequired, + onRefreshSeriesPress: PropTypes.func.isRequired, + onRssSyncPress: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default SeriesIndex; diff --git a/frontend/src/Series/Index/SeriesIndexConnector.js b/frontend/src/Series/Index/SeriesIndexConnector.js new file mode 100644 index 000000000..63a6288cc --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndexConnector.js @@ -0,0 +1,164 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import dimensions from 'Styles/Variables/dimensions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import { fetchSeries } from 'Store/Actions/seriesActions'; +import scrollPositions from 'Store/scrollPositions'; +import { setSeriesSort, setSeriesFilter, setSeriesView } from 'Store/Actions/seriesIndexActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import withScrollPosition from 'Components/withScrollPosition'; +import SeriesIndex from './SeriesIndex'; + +const POSTERS_PADDING = 15; +const POSTERS_PADDING_SMALL_SCREEN = 5; +const TABLE_PADDING = parseInt(dimensions.pageContentBodyPadding); +const TABLE_PADDING_SMALL_SCREEN = parseInt(dimensions.pageContentBodyPaddingSmallScreen); + +// If the scrollTop is greater than zero it needs to be offset +// by the padding so when it is set initially so it is correct +// after React Virtualized takes the padding into account. + +function getScrollTop(view, scrollTop, isSmallScreen) { + if (scrollTop === 0) { + return 0; + } + + let padding = isSmallScreen ? TABLE_PADDING_SMALL_SCREEN : TABLE_PADDING; + + if (view === 'posters') { + padding = isSmallScreen ? POSTERS_PADDING_SMALL_SCREEN : POSTERS_PADDING; + } + + return scrollTop + padding; +} + +function createMapStateToProps() { + return createSelector( + createClientSideCollectionSelector('series', 'seriesIndex'), + createCommandExecutingSelector(commandNames.REFRESH_SERIES), + createCommandExecutingSelector(commandNames.RSS_SYNC), + createDimensionsSelector(), + ( + series, + isRefreshingSeries, + isRssSyncExecuting, + dimensionsState + ) => { + return { + ...series, + isRefreshingSeries, + isRssSyncExecuting, + isSmallScreen: dimensionsState.isSmallScreen + }; + } + ); +} + +const mapDispatchToProps = { + fetchSeries, + setSeriesSort, + setSeriesFilter, + setSeriesView, + executeCommand +}; + +class SeriesIndexConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + view, + scrollTop, + isSmallScreen + } = props; + + this.state = { + scrollTop: getScrollTop(view, scrollTop, isSmallScreen) + }; + } + + componentDidMount() { + this.props.fetchSeries(); + } + + // + // Listeners + + onSortSelect = (sortKey) => { + this.props.setSeriesSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setSeriesFilter({ selectedFilterKey }); + } + + onViewSelect = (view) => { + // Reset the scroll position before changing the view + this.setState({ scrollTop: 0 }, () => { + this.props.setSeriesView({ view }); + }); + } + + onScroll = ({ scrollTop }) => { + this.setState({ + scrollTop + }, () => { + scrollPositions.seriesIndex = scrollTop; + }); + } + + onRefreshSeriesPress = () => { + this.props.executeCommand({ + name: commandNames.REFRESH_SERIES + }); + } + + onRssSyncPress = () => { + this.props.executeCommand({ + name: commandNames.RSS_SYNC + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +SeriesIndexConnector.propTypes = { + isSmallScreen: PropTypes.bool.isRequired, + view: PropTypes.string.isRequired, + scrollTop: PropTypes.number.isRequired, + fetchSeries: PropTypes.func.isRequired, + setSeriesSort: PropTypes.func.isRequired, + setSeriesFilter: PropTypes.func.isRequired, + setSeriesView: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default withScrollPosition( + connect(createMapStateToProps, mapDispatchToProps)(SeriesIndexConnector), + 'seriesIndex' +); diff --git a/frontend/src/Series/Index/SeriesIndexFilterModalConnector.js b/frontend/src/Series/Index/SeriesIndexFilterModalConnector.js new file mode 100644 index 000000000..4833f42f2 --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndexFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setSeriesFilter } from 'Store/Actions/seriesIndexActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.series.items, + (state) => state.seriesIndex.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'seriesIndex' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setSeriesFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Series/Index/SeriesIndexFooter.css b/frontend/src/Series/Index/SeriesIndexFooter.css new file mode 100644 index 000000000..3aa369576 --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndexFooter.css @@ -0,0 +1,66 @@ +.footer { + display: flex; + flex-wrap: wrap; + margin-top: 20px; + font-size: $smallFontSize; +} + +.legendItem { + display: flex; + margin-bottom: 4px; + line-height: 16px; +} + +.legendItemColor { + margin-right: 8px; + width: 30px; + height: 16px; + border-radius: 4px; +} + +.continuing { + composes: legendItemColor; + + background-color: $primaryColor; +} + +.ended { + composes: legendItemColor; + + background-color: $successColor; +} + +.missingMonitored { + composes: legendItemColor; + + background-color: $dangerColor; +} + +.missingUnmonitored { + composes: legendItemColor; + + background-color: $warningColor; +} + +.statistics { + display: flex; + justify-content: space-between; + flex-wrap: wrap; +} + +@media (max-width: $breakpointLarge) { + .statistics { + display: block; + } +} + +@media (max-width: $breakpointSmall) { + .footer { + display: block; + } + + .statistics { + display: flex; + margin-top: 20px; + } +} diff --git a/frontend/src/Series/Index/SeriesIndexFooter.js b/frontend/src/Series/Index/SeriesIndexFooter.js new file mode 100644 index 000000000..8afba25a7 --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndexFooter.js @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import styles from './SeriesIndexFooter.css'; + +function SeriesIndexFooter({ series }) { + const count = series.length; + let episodes = 0; + let episodeFiles = 0; + let ended = 0; + let continuing = 0; + let monitored = 0; + let totalFileSize = 0; + + series.forEach((s) => { + const { statistics = {} } = s; + + const { + episodeCount = 0, + episodeFileCount = 0, + sizeOnDisk = 0 + } = statistics; + + episodes += episodeCount; + episodeFiles += episodeFileCount; + + if (s.status === 'ended') { + ended++; + } else { + continuing++; + } + + if (s.monitored) { + monitored++; + } + + totalFileSize += sizeOnDisk; + }); + + return ( +
+
+
+
+
Continuing (All episodes downloaded)
+
+ +
+
+
Ended (All episodes downloaded)
+
+ +
+
+
Missing Episodes (Series monitored)
+
+ +
+
+
Missing Episodes (Series not monitored)
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} + +SeriesIndexFooter.propTypes = { + series: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default SeriesIndexFooter; diff --git a/frontend/src/Series/Index/SeriesIndexItemConnector.js b/frontend/src/Series/Index/SeriesIndexItemConnector.js new file mode 100644 index 000000000..633fa722b --- /dev/null +++ b/frontend/src/Series/Index/SeriesIndexItemConnector.js @@ -0,0 +1,125 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { isCommandExecuting } from 'Utilities/Command'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector'; +import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; + +function selectShowSearchAction() { + return createSelector( + (state) => state.seriesIndex, + (seriesIndex) => { + const view = seriesIndex.view; + + switch (view) { + case 'posters': + return seriesIndex.posterOptions.showSearchAction; + case 'overview': + return seriesIndex.overviewOptions.showSearchAction; + default: + return seriesIndex.tableOptions.showSearchAction; + } + } + ); +} + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + createQualityProfileSelector(), + createLanguageProfileSelector(), + selectShowSearchAction(), + createCommandsSelector(), + ( + series, + qualityProfile, + languageProfile, + showSearchAction, + commands + ) => { + const isRefreshingSeries = commands.some((command) => { + return ( + command.name === commandNames.REFRESH_SERIES && + command.body.seriesId === series.id && + isCommandExecuting(command) + ); + }); + + const isSearchingSeries = commands.some((command) => { + return ( + command.name === commandNames.SERIES_SEARCH && + command.body.seriesId === series.id && + isCommandExecuting(command) + ); + }); + + const latestSeason = _.maxBy(series.seasons, (season) => season.seasonNumber); + + return { + ...series, + qualityProfile, + languageProfile, + latestSeason, + showSearchAction, + isRefreshingSeries, + isSearchingSeries + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand +}; + +class SeriesIndexItemConnector extends Component { + + // + // Listeners + + onRefreshSeriesPress = () => { + this.props.executeCommand({ + name: commandNames.REFRESH_SERIES, + seriesId: this.props.id + }); + } + + onSearchPress = () => { + this.props.executeCommand({ + name: commandNames.SERIES_SEARCH, + seriesId: this.props.id + }); + } + + // + // Render + + render() { + const { + component: ItemComponent, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +SeriesIndexItemConnector.propTypes = { + id: PropTypes.number.isRequired, + component: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(SeriesIndexItemConnector); diff --git a/frontend/src/Series/Index/Table/SeriesIndexActionsCell.js b/frontend/src/Series/Index/Table/SeriesIndexActionsCell.js new file mode 100644 index 000000000..d85263469 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexActionsCell.js @@ -0,0 +1,102 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; +import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; + +class SeriesIndexActionsCell extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: false + }; + } + + // + // Listeners + + onEditSeriesPress = () => { + this.setState({ isEditSeriesModalOpen: true }); + } + + onEditSeriesModalClose = () => { + this.setState({ isEditSeriesModalOpen: false }); + } + + onDeleteSeriesPress = () => { + this.setState({ + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: true + }); + } + + onDeleteSeriesModalClose = () => { + this.setState({ isDeleteSeriesModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + isRefreshingSeries, + onRefreshSeriesPress, + ...otherProps + } = this.props; + + const { + isEditSeriesModalOpen, + isDeleteSeriesModalOpen + } = this.state; + + return ( + + + + + + + + + + ); + } +} + +SeriesIndexActionsCell.propTypes = { + id: PropTypes.number.isRequired, + isRefreshingSeries: PropTypes.bool.isRequired, + onRefreshSeriesPress: PropTypes.func.isRequired +}; + +export default SeriesIndexActionsCell; diff --git a/frontend/src/Series/Index/Table/SeriesIndexHeader.css b/frontend/src/Series/Index/Table/SeriesIndexHeader.css new file mode 100644 index 000000000..826d94110 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexHeader.css @@ -0,0 +1,89 @@ +.status { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 60px; +} + +.sortTitle { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 4 0 110px; +} + +.network { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 2 0 90px; +} + +.qualityProfileId, +.languageProfileId { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 125px; +} + +.nextAiring, +.previousAiring, +.added, +.genres { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 180px; +} + +.seasonCount, +.certification { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 100px; +} + +.episodeProgress, +.latestSeason { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 150px; +} + +.episodeCount { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 130px; +} + +.path { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 150px; +} + +.sizeOnDisk { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 120px; +} + +.ratings { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 80px; +} + +.tags { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 60px; +} + +.useSceneNumbering { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 145px; +} + +.actions { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 90px; +} diff --git a/frontend/src/Series/Index/Table/SeriesIndexHeader.js b/frontend/src/Series/Index/Table/SeriesIndexHeader.js new file mode 100644 index 000000000..3de1ec64c --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexHeader.js @@ -0,0 +1,109 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal'; +import SeriesIndexTableOptionsConnector from './SeriesIndexTableOptionsConnector'; +import styles from './SeriesIndexHeader.css'; + +class SeriesIndexHeader extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isTableOptionsModalOpen: false + }; + } + + // + // Listeners + + onTableOptionsPress = () => { + this.setState({ isTableOptionsModalOpen: true }); + } + + onTableOptionsModalClose = () => { + this.setState({ isTableOptionsModalOpen: false }); + } + + // + // Render + + render() { + const { + showSearchAction, + columns, + onTableOptionChange, + ...otherProps + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + label, + isSortable, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'actions') { + return ( + + + + ); + } + + return ( + + {label} + + ); + }) + } + + + + ); + } +} + +SeriesIndexHeader.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onTableOptionChange: PropTypes.func.isRequired +}; + +export default SeriesIndexHeader; diff --git a/frontend/src/Series/Index/Table/SeriesIndexHeaderConnector.js b/frontend/src/Series/Index/Table/SeriesIndexHeaderConnector.js new file mode 100644 index 000000000..fa9a895fd --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexHeaderConnector.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { setSeriesTableOption } from 'Store/Actions/seriesIndexActions'; +import SeriesIndexHeader from './SeriesIndexHeader'; + +function createMapDispatchToProps(dispatch, props) { + return { + onTableOptionChange(payload) { + dispatch(setSeriesTableOption(payload)); + } + }; +} + +export default connect(undefined, createMapDispatchToProps)(SeriesIndexHeader); diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.css b/frontend/src/Series/Index/Table/SeriesIndexRow.css new file mode 100644 index 000000000..2513e1913 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.css @@ -0,0 +1,138 @@ +.cell { + composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; + + display: flex; + align-items: center; +} + +.status { + composes: cell; + + flex: 0 0 60px; +} + +.sortTitle { + composes: cell; + + flex: 4 0 110px; +} + +.banner { + flex: 0 0 379px; +} + +.link { + composes: link from 'Components/Link/Link.css'; + + position: relative; + display: block; + height: 70px; + background-color: $defaultColor; +} + +.bannerImage { + width: 379px; + height: 70px; +} + +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: $offWhite; + text-align: center; + font-size: 20px; +} + +.network { + composes: cell; + + flex: 2 0 90px; +} + +.qualityProfileId, +.languageProfileId { + composes: cell; + + flex: 1 0 125px; +} + +.nextAiring, +.previousAiring, +.added, +.genres { + composes: cell; + + flex: 0 0 180px; +} + +.seasonCount, +.certification { + composes: cell; + + flex: 0 0 100px; +} + +.episodeProgress, +.latestSeason { + composes: cell; + + display: flex; + justify-content: center; + flex: 0 0 150px; + flex-direction: column; +} + +.episodeCount { + composes: cell; + + flex: 0 0 130px; +} + +.path { + composes: cell; + + flex: 1 0 150px; +} + +.sizeOnDisk { + composes: cell; + + flex: 0 0 120px; +} + +.ratings { + composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 80px; +} + +.tags { + composes: cell; + + flex: 1 0 60px; +} + +.useSceneNumbering { + composes: cell; + + flex: 0 0 145px; +} + +.actions { + composes: cell; + + flex: 0 1 90px; +} + +.checkInput { + composes: input from 'Components/Form/CheckInput.css'; + + margin-top: 0; +} diff --git a/frontend/src/Series/Index/Table/SeriesIndexRow.js b/frontend/src/Series/Index/Table/SeriesIndexRow.js new file mode 100644 index 000000000..b36926c46 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexRow.js @@ -0,0 +1,515 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import getProgressBarKind from 'Utilities/Series/getProgressBarKind'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { icons } from 'Helpers/Props'; +import HeartRating from 'Components/HeartRating'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ProgressBar from 'Components/ProgressBar'; +import TagListConnector from 'Components/TagListConnector'; +import CheckInput from 'Components/Form/CheckInput'; +import VirtualTableRow from 'Components/Table/VirtualTableRow'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import EditSeriesModalConnector from 'Series/Edit/EditSeriesModalConnector'; +import DeleteSeriesModal from 'Series/Delete/DeleteSeriesModal'; +import SeriesBanner from 'Series/SeriesBanner'; +import SeriesStatusCell from './SeriesStatusCell'; +import styles from './SeriesIndexRow.css'; + +class SeriesIndexRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasBannerError: false, + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: false + }; + } + + onEditSeriesPress = () => { + this.setState({ isEditSeriesModalOpen: true }); + } + + onEditSeriesModalClose = () => { + this.setState({ isEditSeriesModalOpen: false }); + } + + onDeleteSeriesPress = () => { + this.setState({ + isEditSeriesModalOpen: false, + isDeleteSeriesModalOpen: true + }); + } + + onDeleteSeriesModalClose = () => { + this.setState({ isDeleteSeriesModalOpen: false }); + } + + onUseSceneNumberingChange = () => { + // Mock handler to satisfy `onChange` being required for `CheckInput`. + // + } + + onBannerLoad = () => { + if (this.state.hasBannerError) { + this.setState({ hasBannerError: false }); + } + } + + onBannerLoadError = () => { + if (!this.state.hasBannerError) { + this.setState({ hasBannerError: true }); + } + } + + // + // Render + + render() { + const { + style, + id, + monitored, + status, + title, + titleSlug, + network, + qualityProfile, + languageProfile, + nextAiring, + previousAiring, + added, + statistics, + latestSeason, + path, + genres, + ratings, + certification, + tags, + images, + useSceneNumbering, + showBanners, + showSearchAction, + columns, + isRefreshingSeries, + isSearchingSeries, + onRefreshSeriesPress, + onSearchPress + } = this.props; + + const { + seasonCount, + episodeCount, + episodeFileCount, + totalEpisodeCount, + sizeOnDisk + } = statistics; + + const { + hasBannerError, + isEditSeriesModalOpen, + isDeleteSeriesModalOpen + } = this.state; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'sortTitle') { + return ( + + { + showBanners ? + + + + { + hasBannerError && +
+ {title} +
+ } + : + + + } +
+ ); + } + + if (name === 'network') { + return ( + + {network} + + ); + } + + if (name === 'qualityProfileId') { + return ( + + {qualityProfile.name} + + ); + } + + if (name === 'languageProfileId') { + return ( + + {languageProfile.name} + + ); + } + + if (name === 'nextAiring') { + return ( + + ); + } + + if (name === 'previousAiring') { + return ( + + ); + } + + if (name === 'added') { + return ( + + ); + } + + if (name === 'seasonCount') { + return ( + + {seasonCount} + + ); + } + + if (name === 'episodeProgress') { + const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100; + + return ( + + + + ); + } + + if (name === 'latestSeason') { + if (!latestSeason) { + return ( + + ); + } + + const seasonStatistics = latestSeason.statistics; + const progress = seasonStatistics.episodeCount ? seasonStatistics.episodeFileCount / seasonStatistics.episodeCount * 100 : 100; + + return ( + + + + ); + } + + if (name === 'episodeCount') { + return ( + + {totalEpisodeCount} + + ); + } + + if (name === 'path') { + return ( + + {path} + + ); + } + + if (name === 'sizeOnDisk') { + return ( + + {formatBytes(sizeOnDisk)} + + ); + } + + if (name === 'genres') { + const joinedGenres = genres.join(', '); + + return ( + + + {joinedGenres} + + + ); + } + + if (name === 'ratings') { + return ( + + + + ); + } + + if (name === 'certification') { + return ( + + {certification} + + ); + } + + if (name === 'tags') { + return ( + + + + ); + } + + if (name === 'useSceneNumbering') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + + + { + showSearchAction && + + } + + + + ); + } + + return null; + }) + } + + + + +
+ ); + } +} + +SeriesIndexRow.propTypes = { + style: PropTypes.object.isRequired, + id: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + network: PropTypes.string, + qualityProfile: PropTypes.object.isRequired, + languageProfile: PropTypes.object.isRequired, + nextAiring: PropTypes.string, + previousAiring: PropTypes.string, + added: PropTypes.string, + statistics: PropTypes.object.isRequired, + latestSeason: PropTypes.object, + path: PropTypes.string.isRequired, + genres: PropTypes.arrayOf(PropTypes.string).isRequired, + ratings: PropTypes.object.isRequired, + certification: PropTypes.string, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + useSceneNumbering: PropTypes.bool.isRequired, + showBanners: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isRefreshingSeries: PropTypes.bool.isRequired, + isSearchingSeries: PropTypes.bool.isRequired, + onRefreshSeriesPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +SeriesIndexRow.defaultProps = { + statistics: { + seasonCount: 0, + episodeCount: 0, + episodeFileCount: 0, + totalEpisodeCount: 0 + }, + genres: [], + tags: [] +}; + +export default SeriesIndexRow; diff --git a/frontend/src/Series/Index/Table/SeriesIndexTable.css b/frontend/src/Series/Index/Table/SeriesIndexTable.css new file mode 100644 index 000000000..e46160a96 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexTable.css @@ -0,0 +1,5 @@ +.tableContainer { + composes: tableContainer from 'Components/Table/VirtualTable.css'; + + flex: 1 0 auto; +} diff --git a/frontend/src/Series/Index/Table/SeriesIndexTable.js b/frontend/src/Series/Index/Table/SeriesIndexTable.js new file mode 100644 index 000000000..d5cae1d51 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexTable.js @@ -0,0 +1,131 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import { sortDirections } from 'Helpers/Props'; +import VirtualTable from 'Components/Table/VirtualTable'; +import SeriesIndexItemConnector from 'Series/Index/SeriesIndexItemConnector'; +import SeriesIndexHeaderConnector from './SeriesIndexHeaderConnector'; +import SeriesIndexRow from './SeriesIndexRow'; +import styles from './SeriesIndexTable.css'; + +class SeriesIndexTable extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + scrollIndex: null + }; + } + + componentDidUpdate(prevProps) { + const jumpToCharacter = this.props.jumpToCharacter; + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const items = this.props.items; + + const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (scrollIndex != null) { + this.setState({ scrollIndex }); + } + } else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) { + this.setState({ scrollIndex: null }); + } + } + + // + // Control + + rowRenderer = ({ key, rowIndex, style }) => { + const { + items, + columns, + showBanners + } = this.props; + + const series = items[rowIndex]; + + return ( + + ); + } + + // + // Render + + render() { + const { + items, + columns, + filters, + sortKey, + sortDirection, + showBanners, + isSmallScreen, + scrollTop, + contentBody, + onSortPress, + onRender, + onScroll + } = this.props; + + return ( + + } + columns={columns} + filters={filters} + sortKey={sortKey} + sortDirection={sortDirection} + onRender={onRender} + onScroll={onScroll} + /> + ); + } +} + +SeriesIndexTable.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + showBanners: PropTypes.bool.isRequired, + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + contentBody: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onSortPress: PropTypes.func.isRequired, + onRender: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default SeriesIndexTable; diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableConnector.js b/frontend/src/Series/Index/Table/SeriesIndexTableConnector.js new file mode 100644 index 000000000..39804c9c6 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexTableConnector.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { setSeriesSort } from 'Store/Actions/seriesIndexActions'; +import SeriesIndexTable from './SeriesIndexTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.dimensions, + createClientSideCollectionSelector('series', 'seriesIndex'), + (dimensions, series) => { + return { + isSmallScreen: dimensions.isSmallScreen, + ...series, + showBanners: series.tableOptions.showBanners + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSortPress(sortKey) { + dispatch(setSeriesSort({ sortKey })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(SeriesIndexTable); diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableOptions.js b/frontend/src/Series/Index/Table/SeriesIndexTableOptions.js new file mode 100644 index 000000000..52eeda885 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexTableOptions.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +class SeriesIndexTableOptions extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + showBanners: props.showBanners, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + showBanners, + showSearchAction + } = this.props; + + if ( + showBanners !== prevProps.showBanners || + showSearchAction !== prevProps.showSearchAction + ) { + this.setState({ + showBanners, + showSearchAction + }); + } + } + + // + // Listeners + + onTableOptionChange = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onTableOptionChange({ + tableOptions: { + ...this.state, + [name]: value + } + }); + }); + } + + // + // Render + + render() { + const { + showBanners, + showSearchAction + } = this.state; + + return ( + + + Show Banners + + + + + + Show Search + + + + + ); + } +} + +SeriesIndexTableOptions.propTypes = { + showBanners: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onTableOptionChange: PropTypes.func.isRequired +}; + +export default SeriesIndexTableOptions; diff --git a/frontend/src/Series/Index/Table/SeriesIndexTableOptionsConnector.js b/frontend/src/Series/Index/Table/SeriesIndexTableOptionsConnector.js new file mode 100644 index 000000000..ac608abe5 --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesIndexTableOptionsConnector.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import SeriesIndexTableOptions from './SeriesIndexTableOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.seriesIndex.tableOptions, + (tableOptions) => { + return tableOptions; + } + ); +} + +export default connect(createMapStateToProps)(SeriesIndexTableOptions); diff --git a/frontend/src/Series/Index/Table/SeriesStatusCell.css b/frontend/src/Series/Index/Table/SeriesStatusCell.css new file mode 100644 index 000000000..a7681e36c --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesStatusCell.css @@ -0,0 +1,9 @@ +.status { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 60px; +} + +.statusIcon { + width: 20px !important; +} diff --git a/frontend/src/Series/Index/Table/SeriesStatusCell.js b/frontend/src/Series/Index/Table/SeriesStatusCell.js new file mode 100644 index 000000000..44cd42f0e --- /dev/null +++ b/frontend/src/Series/Index/Table/SeriesStatusCell.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './SeriesStatusCell.css'; + +function SeriesStatusCell(props) { + const { + className, + monitored, + status, + component: Component, + ...otherProps + } = props; + + return ( + + + + + + ); +} + +SeriesStatusCell.propTypes = { + className: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + status: PropTypes.string.isRequired, + component: PropTypes.func +}; + +SeriesStatusCell.defaultProps = { + className: styles.status, + component: VirtualTableRowCell +}; + +export default SeriesStatusCell; diff --git a/frontend/src/Series/MoveSeries/MoveSeriesModal.css b/frontend/src/Series/MoveSeries/MoveSeriesModal.css new file mode 100644 index 000000000..11f33bef2 --- /dev/null +++ b/frontend/src/Series/MoveSeries/MoveSeriesModal.css @@ -0,0 +1,5 @@ +.doNotMoveButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Series/MoveSeries/MoveSeriesModal.js b/frontend/src/Series/MoveSeries/MoveSeriesModal.js new file mode 100644 index 000000000..6d767f22f --- /dev/null +++ b/frontend/src/Series/MoveSeries/MoveSeriesModal.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './MoveSeriesModal.css'; + +function MoveSeriesModal(props) { + const { + originalPath, + destinationPath, + destinationRootFolder, + isOpen, + onSavePress, + onMoveSeriesPress + } = props; + + if ( + isOpen && + !originalPath && + !destinationPath && + !destinationRootFolder + ) { + console.error('orginalPath and destinationPath OR destinationRootFolder must be provided'); + } + + return ( + + + + Move Files + + + + { + destinationRootFolder ? + `Would you like to move the series folders to '${destinationRootFolder}'?` : + `Would you like to move the series files from '${originalPath}' to '${destinationPath}'?` + } + + + + + + + + + + ); +} + +MoveSeriesModal.propTypes = { + originalPath: PropTypes.string, + destinationPath: PropTypes.string, + destinationRootFolder: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + onSavePress: PropTypes.func.isRequired, + onMoveSeriesPress: PropTypes.func.isRequired +}; + +export default MoveSeriesModal; diff --git a/frontend/src/Series/NoSeries.css b/frontend/src/Series/NoSeries.css new file mode 100644 index 000000000..38a01f391 --- /dev/null +++ b/frontend/src/Series/NoSeries.css @@ -0,0 +1,11 @@ +.message { + margin-top: 10px; + margin-bottom: 30px; + text-align: center; + font-size: 20px; +} + +.buttonContainer { + margin-top: 20px; + text-align: center; +} diff --git a/frontend/src/Series/NoSeries.js b/frontend/src/Series/NoSeries.js new file mode 100644 index 000000000..cfcbb53ab --- /dev/null +++ b/frontend/src/Series/NoSeries.js @@ -0,0 +1,51 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import styles from './NoSeries.css'; + +function NoSeries(props) { + const { totalItems } = props; + + if (totalItems > 0) { + return ( +
+
+ All series are hidden due to the applied filter. +
+
+ ); + } + + return ( +
+
+ No series found, to get started you'll want to add a new series or import some existing ones. +
+ +
+ +
+ +
+ +
+
+ ); +} + +NoSeries.propTypes = { + totalItems: PropTypes.number.isRequired +}; + +export default NoSeries; diff --git a/frontend/src/Series/Search/SeasonInteractiveSearchModal.js b/frontend/src/Series/Search/SeasonInteractiveSearchModal.js new file mode 100644 index 000000000..7973affba --- /dev/null +++ b/frontend/src/Series/Search/SeasonInteractiveSearchModal.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import SeasonInteractiveSearchModalContent from './SeasonInteractiveSearchModalContent'; + +function SeasonInteractiveSearchModal(props) { + const { + isOpen, + seriesId, + seasonNumber, + onModalClose + } = props; + + return ( + + + + ); +} + +SeasonInteractiveSearchModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeasonInteractiveSearchModal; diff --git a/frontend/src/Series/Search/SeasonInteractiveSearchModalConnector.js b/frontend/src/Series/Search/SeasonInteractiveSearchModalConnector.js new file mode 100644 index 000000000..e270ebdec --- /dev/null +++ b/frontend/src/Series/Search/SeasonInteractiveSearchModalConnector.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions'; +import SeasonInteractiveSearchModal from './SeasonInteractiveSearchModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + dispatch(cancelFetchReleases()); + dispatch(clearReleases()); + props.onModalClose(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(SeasonInteractiveSearchModal); diff --git a/frontend/src/Series/Search/SeasonInteractiveSearchModalContent.js b/frontend/src/Series/Search/SeasonInteractiveSearchModalContent.js new file mode 100644 index 000000000..536c48a40 --- /dev/null +++ b/frontend/src/Series/Search/SeasonInteractiveSearchModalContent.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector'; +import SeasonNumber from 'Season/SeasonNumber'; + +function SeasonInteractiveSearchModalContent(props) { + const { + seriesId, + seasonNumber, + onModalClose + } = props; + + return ( + + + Interactive Search {seasonNumber != null && } + + + + + + + + + + + ); +} + +SeasonInteractiveSearchModalContent.propTypes = { + seriesId: PropTypes.number.isRequired, + seasonNumber: PropTypes.number.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SeasonInteractiveSearchModalContent; diff --git a/frontend/src/Series/SeriesBanner.js b/frontend/src/Series/SeriesBanner.js new file mode 100644 index 000000000..13aa8c117 --- /dev/null +++ b/frontend/src/Series/SeriesBanner.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import SeriesImage from './SeriesImage'; + +const bannerPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXsAAABGCAIAAACiz6ObAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAYdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjEuMWMqnEsAAAVeSURBVHhe7d3dduI4EEXheaMOfzPv/2ZzpCqLsmULQWjf1P4WkwnEtrhhr7IhnX9uAHAWigPgPBQHwHkoDoDzUBwA56E4AM5DcQCch+IAOA/FwQfuuonfA6ZRHLymuDwej3+r/zp6TI9rAxqElygODtXQ7CRmwNLj+wMdioMdas3uODOPkQe7KA5Wft+aiO5gg+LAfbc1DedZiCgOzF/JTaOD+zrIjeKguF6vmnE8D9+mlKloWsIXQ2IUByU3Rqc/HomviktQneQoTnaXy/Wi/xbfnXQ03eiAfuirL+QLIyWKk1oLQWhOic5XrunoIJvc+DK+ODKiOEmpBY9HuZpbaxByUOnxX0bHLhX74Zbpxuhx3r1Ki+IkZUGJXVAS+i5YPt5io83zsOuztrY00cmJ4mSkIlgdZBWdy/Xn51kHozTMjzuxNSbmRuKvTdTnglwoTkY2ZTS66z2ogdhEx+4oJZu9Gj2uKmmDuuHKj44VirMZmix2SIXipBMHnGZ9TWdbCrPct8M43dVD/cY6QJebnWDZTIQ8KE46R6OKBhBvQ51NdqMzQ3tp1z9/ygHsES26mW4axpxsKE4uuwNO086MajU+iY7vGHIjR7kxelL+5JAAxcnlaMAx+mnrhLVDo8pb0VFoSmxCbhS50ZK8aZUMxcnFX+XH4gVgi04fHD2iH+2WqH/8fn/xFjsnVqlQnETGp1Qmjjk91URTT7vZ2dNgBtKi46lKKE4qFCeR8fWUxt5+6pWTrHqe1d+OqqNF/aBDvGOVB8VJZLI49/CmVWPXdEz5pr91Hx2UmalKKE4eFCeRlyc45hE+EGjsZMpa03/T7vaTzmTjuHicB8VJZLI42syDsShRWXhrluK0R8rdneLMNY7ipEFxEpksjngwFq0pJTXt++4mvsNidqqiOGlQnETeKE78bcxLKU4dZupXad+Y8FPfZUFxsEFxEpkvjjb2ZtRP37QRZvvNMt34XYqDVyhOIm/MOPEN8kFxFu2u77KgONigOIlMvv61lQdj8fzg3xKXzc2Gnf4NcoqDDYqTyHRxdj52XCeZ8mXANw2UEj/o0P1OcbKgOIlMvv61WV+cS/0VTZ9o9iad3Y8d8wlAbFCcRCZf/9rSg7GmqFhcNsXR7POb33LQSEVx8qA4iUwVp7uIE6ksJTdt2Cn12W+N0aIvT+W0gT09ZEBxcnn5+leVvBb1ffH6q+FdU/SA3TqlQOvtX57KUZxUKE4u49e/Xvzts3/KhurRF2Ss7LI+ydKi48xxSpUKxUln8PqPA84HuTHltKte6/H7wzFHz8WfFnKgOOkcFcfObqwRPqoMr9EMLNHx3QeLKkb1SSELipPO7vXjmBspI8r7001ULyo/x5z7wZhjTwl5UJyMNqc5ys36gnHhd6K6r7ZclL+KJ/Vh1Wr1nnrZP/z9X/1Pe/p6CwachChORspEO80Z58Y2VhqOTouMfliPU/8yZ5iV4tFKdG6rde3JIBWKk5SNOfaytyJI35pxaHYpTrE7OuT6sOWYom3qE0EuFCevelLj042SELugMHzQmmj9Z4UL+17UGnKTFsVJzRKwzc31qjnFy/ELatZzifJhZV/ClkZOFCe7koPwLrjK88vpJtKk48et0bGFfGGkRHFwiwPOF3Nj7A0pO7gWshWRFsVBoRzo69dzY1p06lJIjeLA3ef+LYsP2AUdQCgOnhSdv3FWxTtTaCgOtr7VHR3DzqeAhuJgn2Lh5XifgkVrsIvi4JCGHYVD+ZifeLQp51AYoDiYoYyU+hwpPyY0mEBxAJyH4gA4D8UBcB6KA+A8FAfAeSgOgPNQHADnoTgAzkNxAJzldvsfnbIbPuBaveQAAAAASUVORK5CYII='; + +function SeriesBanner(props) { + return ( + + ); +} + +SeriesBanner.propTypes = { + size: PropTypes.number.isRequired +}; + +SeriesBanner.defaultProps = { + size: 70 +}; + +export default SeriesBanner; diff --git a/frontend/src/Series/SeriesImage.js b/frontend/src/Series/SeriesImage.js new file mode 100644 index 000000000..6c99be067 --- /dev/null +++ b/frontend/src/Series/SeriesImage.js @@ -0,0 +1,198 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LazyLoad from 'react-lazyload'; + +function findImage(images, coverType) { + return images.find((image) => image.coverType === coverType); +} + +function getUrl(image, coverType, size) { + if (image) { + // Remove protocol + let url = image.url.replace(/^https?:/, ''); + url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`); + + return url; + } +} + +class SeriesImage extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const pixelRatio = Math.floor(window.devicePixelRatio); + + const { + images, + coverType, + size + } = props; + + const image = findImage(images, coverType); + + this.state = { + pixelRatio, + image, + url: getUrl(image, coverType, pixelRatio * size), + isLoaded: false, + hasError: false + }; + } + + componentDidMount() { + if (!this.state.url && this.props.onError) { + this.props.onError(); + } + } + + componentDidUpdate() { + const { + images, + coverType, + placeholder, + size, + onError + } = this.props; + + const { + image, + pixelRatio + } = this.state; + + const nextImage = findImage(images, coverType); + + if (nextImage && (!image || nextImage.url !== image.url)) { + this.setState({ + image: nextImage, + url: getUrl(nextImage, coverType, pixelRatio * size), + hasError: false + // Don't reset isLoaded, as we want to immediately try to + // show the new image, whether an image was shown previously + // or the placeholder was shown. + }); + } else if (!nextImage && image) { + this.setState({ + image: nextImage, + url: placeholder, + hasError: false + }); + + if (onError) { + onError(); + } + } + } + + // + // Listeners + + onError = () => { + this.setState({ + hasError: true + }); + + if (this.props.onError) { + this.props.onError(); + } + } + + onLoad = () => { + this.setState({ + isLoaded: true, + hasError: false + }); + + if (this.props.onLoad) { + this.props.onLoad(); + } + } + + // + // Render + + render() { + const { + className, + style, + placeholder, + size, + lazy, + overflow + } = this.props; + + const { + url, + hasError, + isLoaded + } = this.state; + + if (hasError || !url) { + return ( + + ); + } + + if (lazy) { + return ( + + } + > + + + ); + } + + return ( + + ); + } +} + +SeriesImage.propTypes = { + className: PropTypes.string, + style: PropTypes.object, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + coverType: PropTypes.string.isRequired, + placeholder: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + lazy: PropTypes.bool.isRequired, + overflow: PropTypes.bool.isRequired, + onError: PropTypes.func, + onLoad: PropTypes.func +}; + +SeriesImage.defaultProps = { + size: 250, + lazy: true, + overflow: false +}; + +export default SeriesImage; diff --git a/frontend/src/Series/SeriesPoster.js b/frontend/src/Series/SeriesPoster.js new file mode 100644 index 000000000..2b04f2883 --- /dev/null +++ b/frontend/src/Series/SeriesPoster.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import SeriesImage from './SeriesImage'; + +const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3CAMAAAC+Te+kAAAAZlBMVEUvLi8vLy8vLzAvMDAwLy8wLzAwMDAwMDEwMTExMDAxMDExMTExMTIxMjIyMjIyMjMyMzMzMjMzMzMzMzQzNDQ0NDQ0NDU0NTU1NTU1NTY1NjY2NTY2NjY2Njc2Nzc3Njc3Nzc3NziHChLWAAAKHklEQVR42u2c25bbKhJATTmUPAZKPerBjTMo0fn/n5wHSYBkXUDCnXPWwEPaneVIO0XdKAouzT9kXApoAS2gBbSAFtACWkALaAEtoAW0gBbQAlpAC2gBLaAFtIAW0AJaQAtoAS2gBbSAFtACWkALaAEtoAW0gP4DQLXW+vF4GGMeD6211n87UK2NsW33OlprTB7eSw5I220PmwH2JKh+7EGOoj3Lejkly0hKx/pHQLVpu9RhzbeDHsEc1PU7QbXpDo/WfB/oQWmeUoADoMZ2Z4fV7wfV5zG7ruvMu0FPzvpxoV7+hDiPCDUJVLddzmHfBfqZlzONNAG0VrXNy/lB7wCtifKSth+KKD8oEREpshk5JRFRnRm0VkREJLKR2kYQERF9ZAUdHkokM5EO8iQiQRlBiSG552Yhdf91wfDf2UBrkj+Q6nyk9mPklAzj9PQSqZ/qR0aZtrWXZ0UUZfuXKL9ERBKzkdray/Nf/YcsoIrmpOcsynMKqMZHngfVn4MHJeVJz/jTYN7RORN1GlTb7tM5Eqw86fMg55Pc47jjpGY3698DtV3Xfgo1kjqZEulD4tTKafrVO+cP23WPU6Bm6vSC2SfVJK/w2p8fntPPu6ht13WtPgE6SK0dSeuQlMSn/ZWW1EvHWYGYOxF7AtTOAzMpHpKKRwqm8jpZMfHq7MxhUD+3bXMb9QmwdwIDqrYx6bS1WnhMuoWcrX/JQdBw5RHMPgQyJRKiee6w/rLmM8RclueOSC9RAp1YlPyBKnirEoK0sXZVlk9NQrh/URMhm9mRG/oQ6Mz/tKGehqRESgjVaGPsRLSttU+jGxJCBt+Vap1zy542QJ9/zYTjPL/iWAmasd4EUdNoYx7m68sYrT8/ahJTSlIVIrq/kc18HvQB0AWH3jhBIuN3ehlSSiGEFEoKIYWQcv4FVQGwSjlP3MavS9dBl2Lk5xiiGICPp/EDOQBzetMs6LVOBl2MkL/G5BkAYEmmm0NVAAAuIi1xrov0EmfyLqVQnhThni5Pz7mSgOlE0JXqTaulI0WAW4o8kfGAUz7SKlJroGuxsXUxiXO8Tn3/jjwZIvfypLUXJIKuppvGp+eAHKp4TkDwGaj4ufYCnQS6kWz6xBcQgVWRdsT4FcKMfjXqPpNAN1JN4xRT8CtCnEXdGCD6zI6E3citU0A3lkStEymJKwPGZQSoRBbIk+THRg6TArq5zDA+wxDAcMZZKymlVK82D2Ga9zO5En0A1AYUwYKiF5XAYQgxllbGZCD4FrXJ5d1Lqop2XauDd05EJypkDBgHYIxxrNaU4ra9ZaHjQTdX7a0Vaun1Aq8AAAA4/MGwWvzilimtzv0leea7rq0XRKVuwELQ4aNY4my+CbTTC69HAHgFDVx8sBIxB/YgLinx0/lkscgJiAgAHJEDICICcFyQqdirB0WD7lUWLKlXTgQERARE4IjAAThH5K+zv1+40rGguz0izUxJb6E4e9l6HeBzge7uVz1ygc6VVKBjG37wAHSeuIjdUpCJBd2tJ3yJeWY06OQg10GwAzuIN4Hu1+nMZOrltRclH7S0l2ivrr2Upzq6W7G02UCn1lQxBOQcOCBw4JwDAFwHSg4I04LF/vZfTlA5WWP04R0QARAAOSBcERGG31k493LfBNp8oB9yakq97cxCqDMohpO4tF9VywfaBDISzr4XItNAG/6/IkrV2UDb/wSgdzayIf+7gXYBaH1ng29yUdP/gtjHU+lz05jibz6J6kBEzoHy8AcfP3PEScD/VtBJaKogiJpjZOKBDuDE5X8r6K9dUJyA/j0kegevk5MQ6gIT+3NWryfuiY/JKALiFQA4R47IB2qc+tFvBW3UJDL1wkNAuCLnCPw6ps8c+VRFSex3T70pMlEfQgHh2ufPCFfoQ+iop6JOikzvSkrEIFG4YuDjPSibJCUyX1Kyn48+J6AKt0Mou6WtRBbrZMdAzbRmI9jo7H0kxd5FcYRplkdK7YKabEsRI2aFJeS9jY/pXv+p/3Cdre7Ef78NtJ0v7CUHQOQ4WHmf3l9HhzUv6Ox6fJ1tudzMl8CCuwwKAQBYYFWUvArVuQoQr+t6EnwlhOJrBXLPmtpsJR0jlkpki6CvnKT2KiXxJZ0dl/x7qfZECoE5VzrqwWLdfC8tiS+S7VjTZGk3FSrvSRGBM0Bc/p78sMkqeqSQ+9uKtVK9QAQGDBgDfNmAjq6SJYBul8b1pMo9V8D7XVTVXcwoJ1u82wlUSml8M8EJbV4s7TPVS9u17B5bw0/ZbNice7/RRAoZrJS/Z3bGryHp7Zlp+2Zr7n/7wrhEhvwSsXMrGOdhbrLVhWjTthjX5+Z584L6wafZ+wYpcM6idu5M2qat2d8LVQjIGaoYUKoY8nA7ct1Vp23ars+9EQEnxnIS3QEhIJUm8bTDZa/b7WUn1PW9AiCP5uzzlnD11MaXxQ+0anSurfKlSrdPOqk+r3RApPeULJ8Isr6PGID3IbJe959T5yqmK1Kb0qmx0U60KNJxmdwvN+W+q59F2LBg1sRv1m93ki11JXlDWszg9i0qUBelEwS6BfoqUqP8ImmZUykphRJCSKnUwhfuWAX9Gia+kWyz29Gu7IXUhFxUYjrPSgpxE5Lq/pDKR01S3MR8H1pJuju/r+SjjRXoJuhjbXMJ5+0ZStwENfpp+9H2P/pex9scVnjS2ZaTPdqRa5c7NJBNXy0ENcYud5Dap/mUNznbPxtnQ00TPn0UNHzKw8uTyWnvaGPtViZs22czTU/HjlxFMlyW2OPN2G5mfn+5PlAEFfaQyK+IJufWPijUAAxmX0e1OO/14VsnTznae6ifkqIPtLaGwjYd13AgHak5AzqkewEnHsLsSfzCpb77bkL5tdVBFnsEw/T27uwojEbJ526tDvR0fFKtpN6d+IjTN6brHtJHeOfyqTlyrCU4g+E9v1J62+LjzjNZV2NUXp5KHTrT0nWtVguzo/TuQeZ9UE2vJ1rUoFdHhlHSxVOvs1nO3PW5csgpjnN2nfGezulpplOMpKgO4qYSp07Zt0/n/hGpJlKZDgc2TdM/03m+R3dqtDOZRp0KjjxpK4GP+e5pzq7rjJfpj6wnbRvya50MnF3nZl8BNjlBGz/vpssx/Ow3eUHHc+syD+e4A6SiD9gn3FhARErl4uzXNapu3gDa1IrycXadIXrL1QpN09Q5ORPv/0i7pyQvqH4faM4bVRKvfkm+SyeTUJMvU0q/nSiLUNOvJzpy39Ppi3+OXPh06GIq/fzWWT8Oegb16F1vh295O3Z72uG7087cm6cT7/z66wTm2ZsIU8RqT93vd/puRx0n1/O3O+a4LVM/NmFtlvsyc90/qrUxz5fT4MZku4Q0/42uWue+I/VNoG8aBbSAFtACWkALaAEtoAW0gBbQAlpAC2gBLaAFtIAW0AJaQAtoAS2gBbSAFtACWkALaAEtoAW0gBbQAlpA/99B/wd7kHH8CSaCpAAAAABJRU5ErkJggg=='; + +function SeriesPoster(props) { + return ( + + ); +} + +SeriesPoster.propTypes = { + size: PropTypes.number.isRequired +}; + +SeriesPoster.defaultProps = { + size: 250 +}; + +export default SeriesPoster; diff --git a/frontend/src/Series/SeriesTitleLink.js b/frontend/src/Series/SeriesTitleLink.js new file mode 100644 index 000000000..b91934a28 --- /dev/null +++ b/frontend/src/Series/SeriesTitleLink.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Link from 'Components/Link/Link'; + +function SeriesTitleLink({ titleSlug, title }) { + const link = `/series/${titleSlug}`; + + return ( + + {title} + + ); +} + +SeriesTitleLink.propTypes = { + titleSlug: PropTypes.string.isRequired, + title: PropTypes.string.isRequired +}; + +export default SeriesTitleLink; diff --git a/frontend/src/Settings/AdvancedSettingsButton.css b/frontend/src/Settings/AdvancedSettingsButton.css new file mode 100644 index 000000000..4b7460ea2 --- /dev/null +++ b/frontend/src/Settings/AdvancedSettingsButton.css @@ -0,0 +1,31 @@ +.button { + composes: toolbarButton from 'Components/Page/Toolbar/PageToolbarButton.css'; + + position: relative; +} + +.labelContainer { + composes: labelContainer from 'Components/Page/Toolbar/PageToolbarButton.css'; +} + +.label { + composes: label from 'Components/Page/Toolbar/PageToolbarButton.css'; +} + +.indicatorContainer { + position: absolute; + top: 10px; + right: 12px; +} + +.indicatorBackground { + color: $themeDarkColor; +} + +.enabled { + color: $successColor; +} + +.disabled { + color: $dangerColor; +} diff --git a/frontend/src/Settings/AdvancedSettingsButton.js b/frontend/src/Settings/AdvancedSettingsButton.js new file mode 100644 index 000000000..12d9902d5 --- /dev/null +++ b/frontend/src/Settings/AdvancedSettingsButton.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import styles from './AdvancedSettingsButton.css'; + +function AdvancedSettingsButton(props) { + const { + advancedSettings, + onAdvancedSettingsPress + } = props; + + return ( + + + + + + + + + +
+
+ {advancedSettings ? 'Hide Advanced' : 'Show Advanced'} +
+
+ + ); +} + +AdvancedSettingsButton.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + onAdvancedSettingsPress: PropTypes.func.isRequired +}; + +export default AdvancedSettingsButton; diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js new file mode 100644 index 000000000..c82604a88 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; +import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector'; +import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector'; + +class DownloadClientSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // + // Render + + render() { + const { + isTestingAll, + dispatchTestAllDownloadClients + } = this.props; + + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + } + onSavePress={this.onSavePress} + /> + + + + + + + + + + ); + } +} + +DownloadClientSettings.propTypes = { + isTestingAll: PropTypes.bool.isRequired, + dispatchTestAllDownloadClients: PropTypes.func.isRequired +}; + +export default DownloadClientSettings; diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js new file mode 100644 index 000000000..5e1a8a1ca --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettingsConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { testAllDownloadClients } from 'Store/Actions/settingsActions'; +import DownloadClientSettings from './DownloadClientSettings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.downloadClients.isTestingAll, + (isTestingAll) => { + return { + isTestingAll + }; + } + ); +} + +const mapDispatchToProps = { + dispatchTestAllDownloadClients: testAllDownloadClients +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSettings); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css new file mode 100644 index 000000000..e1032ddef --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css @@ -0,0 +1,44 @@ +.downloadClient { + composes: card from 'Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from 'Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from 'Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js new file mode 100644 index 000000000..3a2265d28 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddDownloadClientPresetMenuItem from './AddDownloadClientPresetMenuItem'; +import styles from './AddDownloadClientItem.css'; + +class AddDownloadClientItem extends Component { + + // + // Listeners + + onDownloadClientSelect = () => { + const { + implementation + } = this.props; + + this.props.onDownloadClientSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onDownloadClientSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + 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..65ccf7b41 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import AddDownloadClientItem from './AddDownloadClientItem'; +import styles from './AddDownloadClientModalContent.css'; + +class AddDownloadClientModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetDownloadClients, + torrentDownloadClients, + onDownloadClientSelect, + onModalClose + } = this.props; + + return ( + + + Add DownloadClient + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
Unable to add a new downloadClient, please try again.
+ } + + { + isSchemaPopulated && !schemaError && +
+ + +
Sonarr supports any downloadClient that uses the Newznab standard, as well as other downloadClients listed below.
+
For more information on the individual downloadClients, clink on the info buttons.
+
+ +
+
+ { + usenetDownloadClients.map((downloadClient) => { + return ( + + ); + }) + } +
+
+ +
+
+ { + torrentDownloadClients.map((downloadClient) => { + return ( + + ); + }) + } +
+
+
+ } +
+ + + +
+ ); + } +} + +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..c345feb5b --- /dev/null +++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js @@ -0,0 +1,116 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function DownloadClientOptions(props) { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + onInputChange + } = props; + + return ( +
+ { + isFetching && + + } + + { + !isFetching && error && +
Unable to load download client options
+ } + + { + hasSettings && !isFetching && !error && +
+
+
+ + Enable + + + + + + Remove + + + +
+
+ +
+
+ + 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..7172bd31e --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js @@ -0,0 +1,152 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { stringSettingShape } from 'Helpers/Props/Shapes/settingShape'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditRemotePathMappingModalContent.css'; + +function EditRemotePathMappingModalContent(props) { + const { + id, + isFetching, + error, + isSaving, + saveError, + item, + downloadClientHosts, + onInputChange, + onSavePress, + onModalClose, + onDeleteRemotePathMappingPress, + ...otherProps + } = props; + + const { + host, + remotePath, + localPath + } = item; + + return ( + + + {id ? 'Edit Remote Path Mapping' : 'Add Remote Path Mapping'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new remote path mapping, please try again.
+ } + + { + !isFetching && !error && +
+ + 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, + downloadClientHosts: PropTypes.arrayOf(PropTypes.string).isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteRemotePathMappingPress: PropTypes.func +}; + +export default EditRemotePathMappingModalContent; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js new file mode 100644 index 000000000..ae0e51bc0 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js @@ -0,0 +1,138 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setRemotePathMappingValue, saveRemotePathMapping } from 'Store/Actions/settingsActions'; +import EditRemotePathMappingModalContent from './EditRemotePathMappingModalContent'; + +const newRemotePathMapping = { + host: '', + remotePath: '', + localPath: '' +}; + +const selectDownloadClientHosts = createSelector( + (state) => state.settings.downloadClients.items, + (downloadClients) => { + return downloadClients.reduce((acc, downloadClient) => { + const host = downloadClient.fields.find((field) => { + return field.name === 'host'; + }); + + if (host && !acc.includes(host.value)) { + acc.push(host.value); + } + + return acc; + }, []); + } +); + +function createRemotePathMappingSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.remotePathMappings, + selectDownloadClientHosts, + (id, remotePathMappings, downloadClientHosts) => { + const { + isFetching, + error, + isSaving, + saveError, + pendingChanges, + items + } = remotePathMappings; + + const mapping = id ? _.find(items, { id }) : newRemotePathMapping; + const settings = selectSettings(mapping, pendingChanges, saveError); + + return { + id, + isFetching, + error, + isSaving, + saveError, + item: settings.settings, + ...settings, + downloadClientHosts + }; + } + ); +} + +function createMapStateToProps() { + return createSelector( + createRemotePathMappingSelector(), + (remotePathMapping) => { + return { + ...remotePathMapping + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetRemotePathMappingValue: setRemotePathMappingValue, + dispatchSaveRemotePathMapping: saveRemotePathMapping +}; + +class EditRemotePathMappingModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.id) { + Object.keys(newRemotePathMapping).forEach((name) => { + this.props.dispatchSetRemotePathMappingValue({ + name, + value: newRemotePathMapping[name] + }); + }); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetRemotePathMappingValue({ name, value }); + } + + onSavePress = () => { + this.props.dispatchSaveRemotePathMapping({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditRemotePathMappingModalContentConnector.propTypes = { + id: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + dispatchSetRemotePathMappingValue: PropTypes.func.isRequired, + dispatchSaveRemotePathMapping: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalContentConnector); diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css new file mode 100644 index 000000000..a79efda26 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css @@ -0,0 +1,23 @@ +.remotePathMapping { + display: flex; + align-items: stretch; + margin-bottom: 10px; + height: 30px; + border-bottom: 1px solid $borderColor; + line-height: 30px; +} + +.host { + flex: 0 0 300px; +} + +.path { + flex: 0 0 400px; +} + +.actions { + display: flex; + justify-content: flex-end; + flex: 1 0 auto; + padding-right: 10px; +} diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js new file mode 100644 index 000000000..3f25dbd0f --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js @@ -0,0 +1,114 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector'; +import styles from './RemotePathMapping.css'; + +class RemotePathMapping extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditRemotePathMappingModalOpen: false, + isDeleteRemotePathMappingModalOpen: false + }; + } + + // + // Listeners + + onEditRemotePathMappingPress = () => { + this.setState({ isEditRemotePathMappingModalOpen: true }); + } + + onEditRemotePathMappingModalClose = () => { + this.setState({ isEditRemotePathMappingModalOpen: false }); + } + + onDeleteRemotePathMappingPress = () => { + this.setState({ + isEditRemotePathMappingModalOpen: false, + isDeleteRemotePathMappingModalOpen: true + }); + } + + onDeleteRemotePathMappingModalClose = () => { + this.setState({ isDeleteRemotePathMappingModalOpen: false }); + } + + onConfirmDeleteRemotePathMapping = () => { + this.props.onConfirmDeleteRemotePathMapping(this.props.id); + } + + // + // Render + + render() { + const { + id, + host, + remotePath, + localPath + } = this.props; + + return ( +
+
{host}
+
{remotePath}
+
{localPath}
+ +
+ + + +
+ + + + +
+ ); + } +} + +RemotePathMapping.propTypes = { + id: PropTypes.number.isRequired, + host: PropTypes.string.isRequired, + remotePath: PropTypes.string.isRequired, + localPath: PropTypes.string.isRequired, + onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired +}; + +RemotePathMapping.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default RemotePathMapping; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css new file mode 100644 index 000000000..4ef9dcb0f --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css @@ -0,0 +1,23 @@ +.remotePathMappingsHeader { + display: flex; + margin-bottom: 10px; + font-weight: bold; +} + +.host { + flex: 0 0 300px; +} + +.path { + flex: 0 0 400px; +} + +.addRemotePathMapping { + display: flex; + justify-content: flex-end; + padding-right: 10px; +} + +.addButton { + text-align: center; +} diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js new file mode 100644 index 000000000..f633a3279 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import RemotePathMapping from './RemotePathMapping'; +import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector'; +import styles from './RemotePathMappings.css'; + +class RemotePathMappings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddRemotePathMappingModalOpen: false + }; + } + + // + // Listeners + + onAddRemotePathMappingPress = () => { + this.setState({ isAddRemotePathMappingModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isAddRemotePathMappingModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteRemotePathMapping, + ...otherProps + } = this.props; + + return ( +
+ +
+
Host
+
Remote Path
+
Local Path
+
+ +
+ { + items.map((item, index) => { + return ( + + ); + }) + } +
+ +
+ + + +
+ + +
+
+ ); + } +} + +RemotePathMappings.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired +}; + +export default RemotePathMappings; diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js new file mode 100644 index 000000000..7a029818a --- /dev/null +++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchRemotePathMappings, deleteRemotePathMapping } from 'Store/Actions/settingsActions'; +import RemotePathMappings from './RemotePathMappings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.remotePathMappings, + (remotePathMappings) => { + return { + ...remotePathMappings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchRemotePathMappings: fetchRemotePathMappings, + dispatchDeleteRemotePathMapping: deleteRemotePathMapping +}; + +class RemotePathMappingsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchRemotePathMappings(); + } + + // + // Listeners + + onConfirmDeleteRemotePathMapping = (id) => { + this.props.dispatchDeleteRemotePathMapping({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +RemotePathMappingsConnector.propTypes = { + dispatchFetchRemotePathMappings: PropTypes.func.isRequired, + dispatchDeleteRemotePathMapping: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(RemotePathMappingsConnector); diff --git a/frontend/src/Settings/General/AnalyticSettings.js b/frontend/src/Settings/General/AnalyticSettings.js new file mode 100644 index 000000000..049265435 --- /dev/null +++ b/frontend/src/Settings/General/AnalyticSettings.js @@ -0,0 +1,42 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function AnalyticSettings(props) { + const { + settings, + onInputChange + } = props; + + const { + analyticsEnabled + } = settings; + + return ( +
+ + Send Anonymous Usage Data + + + +
+ ); +} + +AnalyticSettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default AnalyticSettings; diff --git a/frontend/src/Settings/General/BackupSettings.js b/frontend/src/Settings/General/BackupSettings.js new file mode 100644 index 000000000..c358ee511 --- /dev/null +++ b/frontend/src/Settings/General/BackupSettings.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function BackupSettings(props) { + const { + advancedSettings, + settings, + onInputChange + } = props; + + const { + backupFolder, + backupInterval, + backupRetention + } = settings; + + if (!advancedSettings) { + return null; + } + + return ( +
+ + Folder + + + + + + Interval + + + + + + Retention + + + +
+ ); +} + +BackupSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default BackupSettings; diff --git a/frontend/src/Settings/General/GeneralSettings.js b/frontend/src/Settings/General/GeneralSettings.js new file mode 100644 index 000000000..485d8a1be --- /dev/null +++ b/frontend/src/Settings/General/GeneralSettings.js @@ -0,0 +1,210 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import Form from 'Components/Form/Form'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import AnalyticSettings from './AnalyticSettings'; +import BackupSettings from './BackupSettings'; +import HostSettings from './HostSettings'; +import LoggingSettings from './LoggingSettings'; +import ProxySettings from './ProxySettings'; +import SecuritySettings from './SecuritySettings'; +import UpdateSettings from './UpdateSettings'; + +class GeneralSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRestartRequiredModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + const { + settings, + isSaving, + saveError + } = this.props; + + if (isSaving || saveError || !prevProps.isSaving) { + return; + } + + const prevSettings = prevProps.settings; + + const keys = [ + 'bindAddress', + 'port', + 'urlBase', + 'enableSsl', + 'sslPort', + 'sslCertHash', + 'authenticationMethod', + 'username', + 'password', + 'apiKey' + ]; + + const pendingRestart = _.some(keys, (key) => { + const setting = settings[key]; + const prevSetting = prevSettings[key]; + + if (!setting || !prevSetting) { + return false; + } + + const previousValue = prevSetting.previousValue; + const value = setting.value; + + return previousValue != null && previousValue !== value; + }); + + this.setState({ isRestartRequiredModalOpen: pendingRestart }); + } + + // + // Listeners + + onConfirmRestart = () => { + this.setState({ isRestartRequiredModalOpen: false }); + this.props.onConfirmRestart(); + } + + onCloseRestartRequiredModalOpen = () => { + this.setState({ isRestartRequiredModalOpen: false }); + } + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + isPopulated, + error, + settings, + hasSettings, + isResettingApiKey, + isMono, + isWindows, + mode, + onInputChange, + onConfirmResetApiKey, + ...otherProps + } = this.props; + + return ( + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && error && +
Unable to load General settings
+ } + + { + hasSettings && isPopulated && !error && +
+ + + + + + + + + + + + + + + } +
+ + +
+ ); + } + +} + +GeneralSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + settings: PropTypes.object.isRequired, + isResettingApiKey: PropTypes.bool.isRequired, + hasSettings: PropTypes.bool.isRequired, + isMono: PropTypes.bool.isRequired, + isWindows: PropTypes.bool.isRequired, + mode: PropTypes.string.isRequired, + onInputChange: PropTypes.func.isRequired, + onConfirmResetApiKey: PropTypes.func.isRequired, + onConfirmRestart: PropTypes.func.isRequired +}; + +export default GeneralSettings; diff --git a/frontend/src/Settings/General/GeneralSettingsConnector.js b/frontend/src/Settings/General/GeneralSettingsConnector.js new file mode 100644 index 000000000..804fdfde7 --- /dev/null +++ b/frontend/src/Settings/General/GeneralSettingsConnector.js @@ -0,0 +1,109 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { setGeneralSettingsValue, saveGeneralSettings, fetchGeneralSettings } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { restart } from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import GeneralSettings from './GeneralSettings'; + +const SECTION = 'general'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + createCommandExecutingSelector(commandNames.RESET_API_KEY), + createSystemStatusSelector(), + (advancedSettings, sectionSettings, isResettingApiKey, systemStatus) => { + return { + advancedSettings, + isResettingApiKey, + isMono: systemStatus.isMono, + isWindows: systemStatus.isWindows, + mode: systemStatus.mode, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + setGeneralSettingsValue, + saveGeneralSettings, + fetchGeneralSettings, + executeCommand, + restart, + clearPendingChanges +}; + +class GeneralSettingsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchGeneralSettings(); + } + + componentDidUpdate(prevProps) { + if (!this.props.isResettingApiKey && prevProps.isResettingApiKey) { + this.props.fetchGeneralSettings(); + } + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: SECTION }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setGeneralSettingsValue({ name, value }); + } + + onSavePress = () => { + this.props.saveGeneralSettings(); + } + + onConfirmResetApiKey = () => { + this.props.executeCommand({ name: commandNames.RESET_API_KEY }); + } + + onConfirmRestart = () => { + this.props.restart(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +GeneralSettingsConnector.propTypes = { + isResettingApiKey: PropTypes.bool.isRequired, + setGeneralSettingsValue: PropTypes.func.isRequired, + saveGeneralSettings: PropTypes.func.isRequired, + fetchGeneralSettings: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + restart: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(GeneralSettingsConnector); diff --git a/frontend/src/Settings/General/HostSettings.js b/frontend/src/Settings/General/HostSettings.js new file mode 100644 index 000000000..2d17dc09f --- /dev/null +++ b/frontend/src/Settings/General/HostSettings.js @@ -0,0 +1,154 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function HostSettings(props) { + const { + advancedSettings, + settings, + isWindows, + mode, + onInputChange + } = props; + + const { + bindAddress, + port, + urlBase, + enableSsl, + sslPort, + sslCertHash, + launchBrowser + } = settings; + + return ( +
+ + Bind Address + + + + + + Port Number + + + + + + URL Base + + + + + + Enable SSL + + + + + { + enableSsl.value && + + SSL Port + + + + } + + { + isWindows && enableSsl.value && + + SSL Cert Hash + + + + } + + { + mode !== 'service' && + + Open browser on start + + + + } + +
+ ); +} + +HostSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + isWindows: PropTypes.bool.isRequired, + mode: PropTypes.string.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default HostSettings; diff --git a/frontend/src/Settings/General/LoggingSettings.js b/frontend/src/Settings/General/LoggingSettings.js new file mode 100644 index 000000000..e7853328e --- /dev/null +++ b/frontend/src/Settings/General/LoggingSettings.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function LoggingSettings(props) { + const { + settings, + onInputChange + } = props; + + const { + logLevel + } = settings; + + const logLevelOptions = [ + { key: 'info', value: 'Info' }, + { key: 'debug', value: 'Debug' }, + { key: 'trace', value: 'Trace' } + ]; + + return ( +
+ + Log Level + + + +
+ ); +} + +LoggingSettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default LoggingSettings; diff --git a/frontend/src/Settings/General/ProxySettings.js b/frontend/src/Settings/General/ProxySettings.js new file mode 100644 index 000000000..238cf3a30 --- /dev/null +++ b/frontend/src/Settings/General/ProxySettings.js @@ -0,0 +1,142 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function ProxySettings(props) { + const { + settings, + onInputChange + } = props; + + const { + proxyEnabled, + proxyType, + proxyHostname, + proxyPort, + proxyUsername, + proxyPassword, + proxyBypassFilter, + proxyBypassLocalAddresses + } = settings; + + const proxyTypeOptions = [ + { key: 'http', value: 'HTTP(S)' }, + { key: 'socks4', value: 'Socks4' }, + { key: 'socks5', value: 'Socks5 (Support TOR)' } + ]; + + return ( +
+ + Use Proxy + + + + + { + proxyEnabled.value && +
+ + Proxy Type + + + + + + Hostname + + + + + + Port + + + + + + Username + + + + + + Password + + + + + + Ignored Addresses + + + + + + Bypass Proxy for Local Addresses + + + +
+ } +
+ ); +} + +ProxySettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default ProxySettings; diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js new file mode 100644 index 000000000..42c77a46f --- /dev/null +++ b/frontend/src/Settings/General/SecuritySettings.js @@ -0,0 +1,170 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import ClipboardButton from 'Components/Link/ClipboardButton'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormInputButton from 'Components/Form/FormInputButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; + +class SecuritySettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isConfirmApiKeyResetModalOpen: false + }; + } + + // + // Listeners + + onApikeyFocus = (event) => { + event.target.select(); + } + + onResetApiKeyPress = () => { + this.setState({ isConfirmApiKeyResetModalOpen: true }); + } + + onConfirmResetApiKey = () => { + this.setState({ isConfirmApiKeyResetModalOpen: false }); + this.props.onConfirmResetApiKey(); + } + + onCloseResetApiKeyModal = () => { + this.setState({ isConfirmApiKeyResetModalOpen: false }); + } + + // + // Render + + render() { + const { + settings, + isResettingApiKey, + onInputChange + } = this.props; + + const { + authenticationMethod, + username, + password, + apiKey + } = settings; + + const authenticationMethodOptions = [ + { key: 'none', value: 'None' }, + { key: 'basic', value: 'Basic (Browser Popup)' }, + { key: 'forms', value: 'Forms (Login Page)' } + ]; + + const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; + + return ( +
+ + Authentication + + + + + { + authenticationEnabled && + + Username + + + + } + + { + authenticationEnabled && + + Password + + + + } + + + API Key + + , + + + + + ]} + onChange={onInputChange} + onFocus={this.onApikeyFocus} + {...apiKey} + /> + + + +
+ ); + } +} + +SecuritySettings.propTypes = { + settings: PropTypes.object.isRequired, + isResettingApiKey: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onConfirmResetApiKey: PropTypes.func.isRequired +}; + +export default SecuritySettings; diff --git a/frontend/src/Settings/General/UpdateSettings.js b/frontend/src/Settings/General/UpdateSettings.js new file mode 100644 index 000000000..8a4a549f8 --- /dev/null +++ b/frontend/src/Settings/General/UpdateSettings.js @@ -0,0 +1,117 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function UpdateSettings(props) { + const { + advancedSettings, + settings, + isMono, + onInputChange + } = props; + + const { + branch, + updateAutomatically, + updateMechanism, + updateScriptPath + } = settings; + + if (!advancedSettings) { + return null; + } + + const updateOptions = [ + { key: 'builtIn', value: 'Built-In' }, + { key: 'script', value: 'Script' } + ]; + + return ( +
+ + Branch + + + + + { + isMono && +
+ + Automatic + + + + + + Mechanism + + + + + { + updateMechanism.value === 'script' && + + Script Path + + + + } +
+ } +
+ ); +} + +UpdateSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + isMono: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default UpdateSettings; diff --git a/frontend/src/Settings/Indexers/IndexerSettings.js b/frontend/src/Settings/Indexers/IndexerSettings.js new file mode 100644 index 000000000..2a8993c92 --- /dev/null +++ b/frontend/src/Settings/Indexers/IndexerSettings.js @@ -0,0 +1,97 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { icons } from 'Helpers/Props'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import IndexersConnector from './Indexers/IndexersConnector'; +import IndexerOptionsConnector from './Options/IndexerOptionsConnector'; + +class IndexerSettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // Render + // + + render() { + const { + isTestingAll, + dispatchTestAllIndexers + } = this.props; + + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + } + onSavePress={this.onSavePress} + /> + + + + + + + + ); + } +} + +IndexerSettings.propTypes = { + isTestingAll: PropTypes.bool.isRequired, + dispatchTestAllIndexers: PropTypes.func.isRequired +}; + +export default IndexerSettings; diff --git a/frontend/src/Settings/Indexers/IndexerSettingsConnector.js b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js new file mode 100644 index 000000000..1eaf098d7 --- /dev/null +++ b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { testAllIndexers } from 'Store/Actions/settingsActions'; +import IndexerSettings from './IndexerSettings'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers.isTestingAll, + (isTestingAll) => { + return { + isTestingAll + }; + } + ); +} + +const mapDispatchToProps = { + dispatchTestAllIndexers: testAllIndexers +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexerSettings); diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css new file mode 100644 index 000000000..d228b842b --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css @@ -0,0 +1,44 @@ +.indexer { + composes: card from 'Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from 'Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from 'Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js new file mode 100644 index 000000000..21db4ecf1 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import AddIndexerPresetMenuItem from './AddIndexerPresetMenuItem'; +import styles from './AddIndexerItem.css'; + +class AddIndexerItem extends Component { + + // + // Listeners + + onIndexerSelect = () => { + const { + implementation + } = this.props; + + this.props.onIndexerSelect({ implementation }); + } + + // + // Render + + render() { + const { + implementation, + implementationName, + infoLink, + presets, + onIndexerSelect + } = this.props; + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets && + + + + + + + + { + 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..5364bd9dd --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import AddIndexerItem from './AddIndexerItem'; +import styles from './AddIndexerModalContent.css'; + +class AddIndexerModalContent extends Component { + + // + // Render + + render() { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetIndexers, + torrentIndexers, + onIndexerSelect, + onModalClose + } = this.props; + + return ( + + + Add Indexer + + + + { + isSchemaFetching && + + } + + { + !isSchemaFetching && !!schemaError && +
Unable to add a new indexer, please try again.
+ } + + { + isSchemaPopulated && !schemaError && +
+ + +
Sonarr supports any indexer that uses the Newznab standard, as well as other indexers listed below.
+
For more information on the individual indexers, clink on the info buttons.
+
+ +
+
+ { + usenetIndexers.map((indexer) => { + return ( + + ); + }) + } +
+
+ +
+
+ { + torrentIndexers.map((indexer) => { + return ( + + ); + }) + } +
+
+
+ } +
+ + + +
+ ); + } +} + +AddIndexerModalContent.propTypes = { + isSchemaFetching: PropTypes.bool.isRequired, + isSchemaPopulated: PropTypes.bool.isRequired, + schemaError: PropTypes.object, + usenetIndexers: PropTypes.arrayOf(PropTypes.object).isRequired, + torrentIndexers: PropTypes.arrayOf(PropTypes.object).isRequired, + onIndexerSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddIndexerModalContent; diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js new file mode 100644 index 000000000..d79f028da --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js @@ -0,0 +1,75 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchIndexerSchema, selectIndexerSchema } from 'Store/Actions/settingsActions'; +import AddIndexerModalContent from './AddIndexerModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (indexers) => { + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = indexers; + + const usenetIndexers = _.filter(schema, { protocol: 'usenet' }); + const torrentIndexers = _.filter(schema, { protocol: 'torrent' }); + + return { + isSchemaFetching, + isSchemaPopulated, + schemaError, + usenetIndexers, + torrentIndexers + }; + } + ); +} + +const mapDispatchToProps = { + fetchIndexerSchema, + selectIndexerSchema +}; + +class AddIndexerModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchIndexerSchema(); + } + + // + // Listeners + + onIndexerSelect = ({ implementation, name }) => { + this.props.selectIndexerSchema({ implementation, presetName: name }); + this.props.onModalClose({ indexerSelected: true }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddIndexerModalContentConnector.propTypes = { + fetchIndexerSchema: PropTypes.func.isRequired, + selectIndexerSchema: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddIndexerModalContentConnector); diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js new file mode 100644 index 000000000..ddea8b043 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +class AddIndexerPresetMenuItem extends Component { + + // + // Listeners + + onPress = () => { + const { + name, + implementation + } = this.props; + + this.props.onPress({ + name, + implementation + }); + } + + // + // Render + + render() { + const { + name, + implementation, + ...otherProps + } = this.props; + + return ( + + {name} + + ); + } +} + +AddIndexerPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; + +export default AddIndexerPresetMenuItem; diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js new file mode 100644 index 000000000..d7401b95f --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditIndexerModalContentConnector from './EditIndexerModalContentConnector'; + +function EditIndexerModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditIndexerModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditIndexerModal; diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js new file mode 100644 index 000000000..ec0b7586e --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { cancelTestIndexer, cancelSaveIndexer } from 'Store/Actions/settingsActions'; +import EditIndexerModal from './EditIndexerModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.indexers'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + }, + + dispatchCancelTestIndexer() { + dispatch(cancelTestIndexer({ section })); + }, + + dispatchCancelSaveIndexer() { + dispatch(cancelSaveIndexer({ section })); + } + }; +} + +class EditIndexerModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges(); + this.props.dispatchCancelTestIndexer(); + this.props.dispatchCancelSaveIndexer(); + this.props.onModalClose(); + } + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchCancelTestIndexer, + dispatchCancelSaveIndexer, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +EditIndexerModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchCancelTestIndexer: PropTypes.func.isRequired, + dispatchCancelSaveIndexer: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditIndexerModalConnector); diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css new file mode 100644 index 000000000..a3c7f464c --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js new file mode 100644 index 000000000..6a250df61 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js @@ -0,0 +1,194 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import styles from './EditIndexerModalContent.css'; + +function EditIndexerModalContent(props) { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteIndexerPress, + ...otherProps + } = props; + + const { + id, + implementationName, + name, + enableRss, + enableAutomaticSearch, + enableInteractiveSearch, + supportsRss, + supportsSearch, + fields + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Indexer - ${implementationName}`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new indexer, please try again.
+ } + + { + !isFetching && !error && +
+ + Name + + + + + + Enable RSS + + + + + + Enable Automatic Search + + + + + + Enable Interactive 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..9269f8532 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexer.js @@ -0,0 +1,140 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditIndexerModalConnector from './EditIndexerModalConnector'; +import styles from './Indexer.css'; + +class Indexer extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditIndexerModalOpen: false, + isDeleteIndexerModalOpen: false + }; + } + + // + // Listeners + + onEditIndexerPress = () => { + this.setState({ isEditIndexerModalOpen: true }); + } + + onEditIndexerModalClose = () => { + this.setState({ isEditIndexerModalOpen: false }); + } + + onDeleteIndexerPress = () => { + this.setState({ + isEditIndexerModalOpen: false, + isDeleteIndexerModalOpen: true + }); + } + + onDeleteIndexerModalClose= () => { + this.setState({ isDeleteIndexerModalOpen: false }); + } + + onConfirmDeleteIndexer = () => { + this.props.onConfirmDeleteIndexer(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + enableRss, + enableAutomaticSearch, + enableInteractiveSearch, + supportsRss, + supportsSearch + } = this.props; + + return ( + +
+ {name} +
+ +
+ + { + supportsRss && enableRss && + + } + + { + supportsSearch && enableAutomaticSearch && + + } + + { + supportsSearch && enableInteractiveSearch && + + } + + { + !enableRss && !enableAutomaticSearch && !enableInteractiveSearch && + + } +
+ + + + +
+ ); + } +} + +Indexer.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + enableRss: PropTypes.bool.isRequired, + enableAutomaticSearch: PropTypes.bool.isRequired, + enableInteractiveSearch: PropTypes.bool.isRequired, + supportsRss: PropTypes.bool.isRequired, + supportsSearch: PropTypes.bool.isRequired, + onConfirmDeleteIndexer: PropTypes.func.isRequired +}; + +export default Indexer; diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.css b/frontend/src/Settings/Indexers/Indexers/Indexers.css new file mode 100644 index 000000000..ec8cb2891 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexers.css @@ -0,0 +1,20 @@ +.indexers { + display: flex; + flex-wrap: wrap; +} + +.addIndexer { + composes: indexer from './Indexer.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.js b/frontend/src/Settings/Indexers/Indexers/Indexers.js new file mode 100644 index 000000000..f5fea9aac --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Indexers.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import Indexer from './Indexer'; +import AddIndexerModal from './AddIndexerModal'; +import EditIndexerModalConnector from './EditIndexerModalConnector'; +import styles from './Indexers.css'; + +class Indexers extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddIndexerModalOpen: false, + isEditIndexerModalOpen: false + }; + } + + // + // Listeners + + onAddIndexerPress = () => { + this.setState({ isAddIndexerModalOpen: true }); + } + + onAddIndexerModalClose = ({ indexerSelected = false } = {}) => { + this.setState({ + isAddIndexerModalOpen: false, + isEditIndexerModalOpen: indexerSelected + }); + } + + onEditIndexerModalClose = () => { + this.setState({ isEditIndexerModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + onConfirmDeleteIndexer, + ...otherProps + } = this.props; + + const { + isAddIndexerModalOpen, + isEditIndexerModalOpen + } = this.state; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + + +
+
+ ); + } +} + +Indexers.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteIndexer: PropTypes.func.isRequired +}; + +export default Indexers; diff --git a/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js new file mode 100644 index 000000000..415dae32b --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchIndexers, deleteIndexer } from 'Store/Actions/settingsActions'; +import Indexers from './Indexers'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.indexers, + (indexers) => { + return { + ...indexers + }; + } + ); +} + +const mapDispatchToProps = { + fetchIndexers, + deleteIndexer +}; + +class IndexersConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchIndexers(); + } + + // + // Listeners + + onConfirmDeleteIndexer = (id) => { + this.props.deleteIndexer({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +IndexersConnector.propTypes = { + fetchIndexers: PropTypes.func.isRequired, + deleteIndexer: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(IndexersConnector); diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptions.js b/frontend/src/Settings/Indexers/Options/IndexerOptions.js new file mode 100644 index 000000000..30e2c39bc --- /dev/null +++ b/frontend/src/Settings/Indexers/Options/IndexerOptions.js @@ -0,0 +1,112 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function IndexerOptions(props) { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + onInputChange + } = props; + + return ( +
+ { + isFetching && + + } + + { + !isFetching && error && +
Unable to load indexer options
+ } + + { + hasSettings && !isFetching && !error && +
+ + 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/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js new file mode 100644 index 000000000..9e01323c7 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -0,0 +1,384 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import NamingConnector from './Naming/NamingConnector'; + +const rescanAfterRefreshOptions = [ + { key: 'always', value: 'Always' }, + { key: 'afterManual', value: 'After Manual Refresh' }, + { key: 'never', value: 'Never' } +]; + +const fileDateOptions = [ + { key: 'none', value: 'None' }, + { key: 'localAirDate', value: 'Local Air Date' }, + { key: 'utcAirDate', value: 'UTC Air Date' } +]; + +class MediaManagement extends Component { + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + isMono, + onInputChange, + onSavePress, + ...otherProps + } = this.props; + + return ( + + + + + { + isFetching && + + } + + { + !isFetching && error && +
Unable to load Media Management settings
+ } + + { + hasSettings && !isFetching && !error && +
+ + + { + advancedSettings && +
+ + Create empty series 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 Episodes + + + + + + Download Propers + + + + + + Analyse video files + + + + + + Rescan Series 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..d6ad03ad2 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js @@ -0,0 +1,86 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { fetchMediaManagementSettings, setMediaManagementSettingsValue, saveMediaManagementSettings, saveNamingSettings } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import MediaManagement from './MediaManagement'; + +const SECTION = 'mediaManagement'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + (state) => state.settings.naming, + createSettingsSectionSelector(SECTION), + createSystemStatusSelector(), + (advancedSettings, namingSettings, sectionSettings, systemStatus) => { + return { + advancedSettings, + ...sectionSettings, + hasPendingChanges: !_.isEmpty(namingSettings.pendingChanges) || sectionSettings.hasPendingChanges, + isMono: systemStatus.isMono + }; + } + ); +} + +const mapDispatchToProps = { + fetchMediaManagementSettings, + setMediaManagementSettingsValue, + saveMediaManagementSettings, + saveNamingSettings, + clearPendingChanges +}; + +class MediaManagementConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchMediaManagementSettings(); + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: SECTION }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setMediaManagementSettingsValue({ name, value }); + } + + onSavePress = () => { + this.props.saveMediaManagementSettings(); + this.props.saveNamingSettings(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MediaManagementConnector.propTypes = { + fetchMediaManagementSettings: PropTypes.func.isRequired, + setMediaManagementSettingsValue: PropTypes.func.isRequired, + saveMediaManagementSettings: PropTypes.func.isRequired, + saveNamingSettings: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MediaManagementConnector); diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.css b/frontend/src/Settings/MediaManagement/Naming/Naming.css new file mode 100644 index 000000000..da27e292e --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.css @@ -0,0 +1,5 @@ +.namingInput { + composes: input from 'Components/Form/TextInput.css'; + + font-family: $monoSpaceFontFamily; +} diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js new file mode 100644 index 000000000..d38d86e0a --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js @@ -0,0 +1,342 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FormInputButton from 'Components/Form/FormInputButton'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import NamingModal from './NamingModal'; +import styles from './Naming.css'; + +class Naming extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isNamingModalOpen: false, + namingModalOptions: null + }; + } + + // + // Listeners + + onStandardNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'standardEpisodeFormat', + season: true, + episode: true, + additional: true + } + }); + } + + onDailyNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'dailyEpisodeFormat', + season: true, + episode: true, + daily: true, + additional: true + } + }); + } + + onAnimeNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'animeEpisodeFormat', + season: true, + episode: true, + anime: true, + additional: true + } + }); + } + + onSeriesFolderNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'seriesFolderFormat' + } + }); + } + + onSeasonFolderNamingModalOpenClick = () => { + this.setState({ + isNamingModalOpen: true, + namingModalOptions: { + name: 'seasonFolderFormat', + season: true + } + }); + } + + onNamingModalClose = () => { + this.setState({ isNamingModalOpen: false }); + } + + // + // Render + + render() { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + examples, + examplesPopulated, + onInputChange + } = this.props; + + const { + isNamingModalOpen, + namingModalOptions + } = this.state; + + const renameEpisodes = hasSettings && settings.renameEpisodes.value; + + const multiEpisodeStyleOptions = [ + { key: 0, value: 'Extend' }, + { key: 1, value: 'Duplicate' }, + { key: 2, value: 'Repeat' }, + { key: 3, value: 'Scene' }, + { key: 4, value: 'Range' }, + { key: 5, value: 'Prefixed Range' } + ]; + + const standardEpisodeFormatHelpTexts = []; + const standardEpisodeFormatErrors = []; + const dailyEpisodeFormatHelpTexts = []; + const dailyEpisodeFormatErrors = []; + const animeEpisodeFormatHelpTexts = []; + const animeEpisodeFormatErrors = []; + const seriesFolderFormatHelpTexts = []; + const seriesFolderFormatErrors = []; + const seasonFolderFormatHelpTexts = []; + const seasonFolderFormatErrors = []; + + if (examplesPopulated) { + if (examples.singleEpisodeExample) { + standardEpisodeFormatHelpTexts.push(`Single Episode: ${examples.singleEpisodeExample}`); + } else { + standardEpisodeFormatErrors.push({ message: 'Single Episode: Invalid Format' }); + } + + if (examples.multiEpisodeExample) { + standardEpisodeFormatHelpTexts.push(`Multi Episode: ${examples.multiEpisodeExample}`); + } else { + standardEpisodeFormatErrors.push({ message: 'Multi Episode: Invalid Format' }); + } + + if (examples.dailyEpisodeExample) { + dailyEpisodeFormatHelpTexts.push(`Example: ${examples.dailyEpisodeExample}`); + } else { + dailyEpisodeFormatErrors.push({ message: 'Invalid Format' }); + } + + if (examples.animeEpisodeExample) { + animeEpisodeFormatHelpTexts.push(`Single Episode: ${examples.animeEpisodeExample}`); + } else { + animeEpisodeFormatErrors.push({ message: 'Single Episode: Invalid Format' }); + } + + if (examples.animeMultiEpisodeExample) { + animeEpisodeFormatHelpTexts.push(`Multi Episode: ${examples.animeMultiEpisodeExample}`); + } else { + animeEpisodeFormatErrors.push({ message: 'Multi Episode: Invalid Format' }); + } + + if (examples.seriesFolderExample) { + seriesFolderFormatHelpTexts.push(`Example: ${examples.seriesFolderExample}`); + } else { + seriesFolderFormatErrors.push({ message: 'Invalid Format' }); + } + + if (examples.seasonFolderExample) { + seasonFolderFormatHelpTexts.push(`Example: ${examples.seasonFolderExample}`); + } else { + seasonFolderFormatErrors.push({ message: 'Invalid Format' }); + } + } + + return ( +
+ { + isFetching && + + } + + { + !isFetching && error && +
Unable to load Naming settings
+ } + + { + hasSettings && !isFetching && !error && +
+ + Rename Episodes + + + + + + Replace Illegal Characters + + + + + { + renameEpisodes && +
+ + Standard Episode Format + + ?} + onChange={onInputChange} + {...settings.standardEpisodeFormat} + helpTexts={standardEpisodeFormatHelpTexts} + errors={[...standardEpisodeFormatErrors, ...settings.standardEpisodeFormat.errors]} + /> + + + + Daily Episode Format + + ?} + onChange={onInputChange} + {...settings.dailyEpisodeFormat} + helpTexts={dailyEpisodeFormatHelpTexts} + errors={[...dailyEpisodeFormatErrors, ...settings.dailyEpisodeFormat.errors]} + /> + + + + Anime Episode Format + + ?} + onChange={onInputChange} + {...settings.animeEpisodeFormat} + helpTexts={animeEpisodeFormatHelpTexts} + errors={[...animeEpisodeFormatErrors, ...settings.animeEpisodeFormat.errors]} + /> + +
+ } + + + Series Folder Format + + ?} + onChange={onInputChange} + {...settings.seriesFolderFormat} + helpTexts={['Used when adding a new series or moving series via the series editor', ...seriesFolderFormatHelpTexts]} + errors={[...seriesFolderFormatErrors, ...settings.seriesFolderFormat.errors]} + /> + + + + Season Folder Format + + ?} + onChange={onInputChange} + {...settings.seasonFolderFormat} + helpTexts={seasonFolderFormatHelpTexts} + errors={[...seasonFolderFormatErrors, ...settings.seasonFolderFormat.errors]} + /> + + + + Multi-Episode Style + + + + + { + 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..b7579c675 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js @@ -0,0 +1,551 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import SelectInput from 'Components/Form/SelectInput'; +import TextInput from 'Components/Form/TextInput'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import NamingOption from './NamingOption'; +import styles from './NamingModal.css'; + +const separatorOptions = [ + { key: ' ', value: 'Space ( )' }, + { key: '.', value: 'Period (.)' }, + { key: '_', value: 'Underscore (_)' }, + { key: '-', value: 'Dash (-)' } +]; + +const caseOptions = [ + { key: 'title', value: 'Default Case' }, + { key: 'lower', value: 'Lower Case' }, + { key: 'upper', value: 'Upper Case' } +]; + +const fileNameTokens = [ + { + token: '{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}', + example: 'Series Title (2010) - S01E01 - Episode Title HDTV-720p Proper' + }, + { + token: '{Series Title} - {season:0}x{episode:00} - {Episode Title} {Quality Full}', + example: 'Series Title (2010) - 1x01 - Episode Title HDTV-720p Proper' + }, + { + token: '{Series.Title}.S{season:00}E{episode:00}.{EpisodeClean.Title}.{Quality.Full}', + example: 'Series.Title.(2010).S01E01.Episode.Title.HDTV-720p' + } +]; + +const seriesTokens = [ + { token: '{Series Title}', example: 'Series Title!' }, + { token: '{Series CleanTitle}', example: 'Series Title' }, + { token: '{Series CleanTitleYear}', example: 'Series Title 2010' }, + { token: '{Series TitleThe}', example: 'Series Title, The' }, + { token: '{Series TitleTheYear}', example: 'Series Title, The (2010)' }, + { token: '{Series TitleYear}', example: 'Series Title (2010)' }, + { token: '{Series TitleFirstCharacter}', example: 'S' } +]; + +const seriesIdTokens = [ + { token: '{ImdbId}', example: 'tt12345' }, + { token: '{TvdbId}', example: '12345' }, + { token: '{TvMazeId}', example: '54321' } +]; + +const seasonTokens = [ + { token: '{season:0}', example: '1' }, + { token: '{season:00}', example: '01' } +]; + +const episodeTokens = [ + { token: '{episode:0}', example: '1' }, + { token: '{episode:00}', example: '01' } +]; + +const airDateTokens = [ + { token: '{Air-Date}', example: '2016-03-20' }, + { token: '{Air Date}', example: '2016 03 20' } +]; + +const absoluteTokens = [ + { token: '{absolute:0}', example: '1' }, + { token: '{absolute:00}', example: '01' }, + { token: '{absolute:000}', example: '001' } +]; + +const episodeTitleTokens = [ + { token: '{Episode Title}', example: 'Episode Title' }, + { token: '{Episode CleanTitle}', example: 'Episode Title' } +]; + +const qualityTokens = [ + { token: '{Quality Full}', example: 'HDTV 720p Proper' }, + { token: '{Quality Title}', example: 'HDTV 720p' } +]; + +const mediaInfoTokens = [ + { token: '{MediaInfo Simple}', example: 'x264 DTS' }, + { token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]' }, + { token: '{MediaInfo VideoCodec}', example: 'x264' }, + { token: '{MediaInfo AudioFormat}', example: 'DTS' }, + { token: '{MediaInfo AudioChannels}', example: '5.1' } +]; + +const otherTokens = [ + { token: '{Release Group}', example: 'Rls Grp' }, + { token: '{Preferred Words}', example: 'iNTERNAL' } +]; + +const originalTokens = [ + { token: '{Original Title}', example: 'Series.Title.S01E01.HDTV.x264-EVOLVE' }, + { token: '{Original Filename}', example: 'series.title.s01e01.hdtv.x264-EVOLVE' } +]; + +class NamingModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._selectionStart = null; + this._selectionEnd = null; + + this.state = { + separator: ' ', + case: 'title' + }; + } + + // + // Listeners + + onTokenSeparatorChange = (event) => { + this.setState({ separator: event.value }); + } + + onTokenCaseChange = (event) => { + this.setState({ case: event.value }); + } + + onInputSelectionChange = (selectionStart, selectionEnd) => { + this._selectionStart = selectionStart; + this._selectionEnd = selectionEnd; + } + + onOptionPress = ({ isFullFilename, tokenValue }) => { + const { + name, + value, + onInputChange + } = this.props; + + const selectionStart = this._selectionStart; + const selectionEnd = this._selectionEnd; + + if (isFullFilename) { + onInputChange({ name, value: tokenValue }); + } else if (selectionStart == null) { + onInputChange({ + name, + value: `${value}${tokenValue}` + }); + } else { + const start = value.substring(0, selectionStart); + const end = value.substring(selectionEnd); + const newValue = `${start}${tokenValue}${end}`; + + onInputChange({ name, value: newValue }); + this._selectionStart = newValue.length - 1; + this._selectionEnd = newValue.length - 1; + } + } + + // + // Render + + render() { + const { + name, + value, + isOpen, + advancedSettings, + season, + episode, + daily, + anime, + additional, + onInputChange, + onModalClose + } = this.props; + + const { + separator: tokenSeparator, + case: tokenCase + } = this.state; + + return ( + + + + File Name Tokens + + + +
+ + + +
+ + { + !advancedSettings && +
+
+ { + fileNameTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ } + +
+
+ { + seriesTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + seriesIdTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ + { + season && +
+
+ { + seasonTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ } + + { + episode && +
+
+
+ { + episodeTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ + { + daily && +
+
+ { + airDateTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ } + + { + anime && +
+
+ { + absoluteTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ } +
+ } + + { + additional && +
+
+
+ { + episodeTitleTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + qualityTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + mediaInfoTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + otherTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ +
+
+ { + originalTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+
+ } +
+ + + + + +
+
+ ); + } +} + +NamingModal.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + isOpen: PropTypes.bool.isRequired, + advancedSettings: PropTypes.bool.isRequired, + season: PropTypes.bool.isRequired, + episode: PropTypes.bool.isRequired, + daily: PropTypes.bool.isRequired, + anime: PropTypes.bool.isRequired, + additional: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +NamingModal.defaultProps = { + season: false, + episode: false, + daily: false, + anime: false, + additional: false +}; + +export default NamingModal; diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css new file mode 100644 index 000000000..d9f865936 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css @@ -0,0 +1,69 @@ +.option { + display: flex; + align-items: center; + flex-wrap: wrap; + margin: 3px; + border: 1px solid $borderColor; + + &:hover { + .token { + background-color: #ddd; + } + + .example { + background-color: #ccc; + } + } +} + +.small { + width: 460px; +} + +.large { + width: 100%; +} + +.token { + flex: 0 0 50%; + padding: 6px 16px; + background-color: #eee; + font-family: $monoSpaceFontFamily; +} + +.example { + display: flex; + align-items: center; + align-self: stretch; + flex: 0 0 50%; + padding: 6px 16px; + background-color: #ddd; +} + +.lower { + text-transform: lowercase; +} + +.upper { + text-transform: uppercase; +} + +.isFullFilename { + .token, + .example { + flex: 1 0 auto; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .option.small { + width: 100%; + } +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .token, + .example { + flex: 1 0 auto; + } +} diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.js b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js new file mode 100644 index 000000000..269266a5f --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { sizes } from 'Helpers/Props'; +import Link from 'Components/Link/Link'; +import styles from './NamingOption.css'; + +class NamingOption extends Component { + + // + // Listeners + + onPress = () => { + const { + token, + tokenSeparator, + tokenCase, + isFullFilename, + onPress + } = this.props; + + let tokenValue = token; + + tokenValue = tokenValue.replace(/ /g, tokenSeparator); + + if (tokenCase === 'lower') { + tokenValue = token.toLowerCase(); + } else if (tokenCase === 'upper') { + tokenValue = token.toUpperCase(); + } + + onPress({ isFullFilename, tokenValue }); + } + + // + // Render + render() { + const { + token, + tokenSeparator, + example, + tokenCase, + isFullFilename, + size + } = this.props; + + return ( + +
+ {token.replace(/ /g, tokenSeparator)} +
+ +
+ {example.replace(/ /g, tokenSeparator)} +
+ + ); + } +} + +NamingOption.propTypes = { + token: PropTypes.string.isRequired, + example: PropTypes.string.isRequired, + tokenSeparator: PropTypes.string.isRequired, + tokenCase: PropTypes.string.isRequired, + isFullFilename: PropTypes.bool.isRequired, + size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]), + onPress: PropTypes.func.isRequired +}; + +NamingOption.defaultProps = { + size: sizes.SMALL, + isFullFilename: false +}; + +export default NamingOption; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js new file mode 100644 index 000000000..24c0237cd --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditMetadataModalContentConnector from './EditMetadataModalContentConnector'; + +function EditMetadataModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditMetadataModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditMetadataModal; diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js new file mode 100644 index 000000000..1a38beb4a --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditMetadataModal from './EditMetadataModal'; + +function createMapDispatchToProps(dispatch, props) { + const section = 'settings.metadata'; + + return { + dispatchClearPendingChanges() { + dispatch(clearPendingChanges({ section })); + } + }; +} + +class EditMetadataModalConnector extends Component { + // + // Listeners + + onModalClose = () => { + this.props.dispatchClearPendingChanges({ section: 'metadata' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditMetadataModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + dispatchClearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(null, createMapDispatchToProps)(EditMetadataModalConnector); diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js new file mode 100644 index 000000000..1e7006068 --- /dev/null +++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js @@ -0,0 +1,103 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; + +function EditMetadataModalContent(props) { + const { + isSaving, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + ...otherProps + } = props; + + const { + name, + enable, + fields + } = item; + + return ( + + + Edit {name.value} Metadata + + + +
+ + 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/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..9c1d199c5 --- /dev/null +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js @@ -0,0 +1,237 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import styles from './EditNotificationModalContent.css'; + +function EditNotificationModalContent(props) { + const { + advancedSettings, + isFetching, + error, + isSaving, + isTesting, + saveError, + item, + onInputChange, + onFieldChange, + onModalClose, + onSavePress, + onTestPress, + onDeleteNotificationPress, + ...otherProps + } = props; + + const { + id, + implementationName, + name, + onGrab, + onDownload, + onUpgrade, + onRename, + supportsOnGrab, + supportsOnDownload, + supportsOnUpgrade, + supportsOnRename, + tags, + fields, + message + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Connection - ${implementationName}`} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new notification, please try again.
+ } + + { + !isFetching && !error && +
+ { + !!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..faa32ee32 --- /dev/null +++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js @@ -0,0 +1,188 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { boolSettingShape, numberSettingShape, tagSettingShape } from 'Helpers/Props/Shapes/settingShape'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Alert from 'Components/Alert'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditDelayProfileModalContent.css'; + +function EditDelayProfileModalContent(props) { + const { + id, + isFetching, + error, + isSaving, + saveError, + item, + protocol, + protocolOptions, + onInputChange, + onProtocolChange, + onSavePress, + onModalClose, + onDeleteDelayProfilePress, + ...otherProps + } = props; + + const { + enableUsenet, + enableTorrent, + usenetDelay, + torrentDelay, + tags + } = item; + + return ( + + + {id ? 'Edit Delay Profile' : 'Add Delay Profile'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new quality profile, please try again.
+ } + + { + !isFetching && !error && +
+ + 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/Language/EditLanguageProfileModal.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModal.js new file mode 100644 index 000000000..6a17fd1fc --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditLanguageProfileModalContentConnector from './EditLanguageProfileModalContentConnector'; + +function EditLanguageProfileModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditLanguageProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditLanguageProfileModal; diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalConnector.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalConnector.js new file mode 100644 index 000000000..8e112c9e1 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditLanguageProfileModal from './EditLanguageProfileModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditLanguageProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.languageProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditLanguageProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditLanguageProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.css b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.css new file mode 100644 index 000000000..74dd1c8b7 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.css @@ -0,0 +1,3 @@ +.deleteButtonContainer { + margin-right: auto; +} diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js new file mode 100644 index 000000000..ec4247c74 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js @@ -0,0 +1,167 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import LanguageProfileItems from './LanguageProfileItems'; +import styles from './EditLanguageProfileModalContent.css'; + +function EditLanguageProfileModalContent(props) { + const { + isFetching, + error, + isSaving, + saveError, + languages, + item, + isInUse, + onInputChange, + onCutoffChange, + onSavePress, + onModalClose, + onDeleteLanguageProfilePress, + ...otherProps + } = props; + + const { + id, + name, + upgradeAllowed, + cutoff, + languages: itemLanguages + } = item; + + return ( + + + {id ? 'Edit Language Profile' : 'Add Language Profile'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new language profile, please try again.
+ } + + { + !isFetching && !error && +
+ + Name + + + + + + + Upgrades Allowed + + + + + + { + upgradeAllowed.value && + + Upgrade Until + + + + } + + + + + } +
+ + { + id && +
+ +
+ } + + + + + Save + +
+
+ ); +} + +EditLanguageProfileModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + languages: PropTypes.arrayOf(PropTypes.object).isRequired, + item: PropTypes.object.isRequired, + isInUse: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onCutoffChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteLanguageProfilePress: PropTypes.func +}; + +export default EditLanguageProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js new file mode 100644 index 000000000..3cad2bb3c --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js @@ -0,0 +1,189 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { fetchLanguageProfileSchema, setLanguageProfileValue, saveLanguageProfile } from 'Store/Actions/settingsActions'; +import EditLanguageProfileModalContent from './EditLanguageProfileModalContent'; + +function createLanguagesSelector() { + return createSelector( + createProviderSettingsSelector('languageProfiles'), + (languageProfile) => { + const languages = languageProfile.item.languages; + if (!languages || !languages.value) { + return []; + } + + return _.reduceRight(languages.value, (result, { allowed, language }) => { + if (allowed) { + result.push({ + key: language.id, + value: language.name + }); + } + + return result; + }, []); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createProviderSettingsSelector('languageProfiles'), + createLanguagesSelector(), + createProfileInUseSelector('languageProfileId'), + (languageProfile, languages, isInUse) => { + return { + languages, + ...languageProfile, + isInUse + }; + } + ); +} + +const mapDispatchToProps = { + fetchLanguageProfileSchema, + setLanguageProfileValue, + saveLanguageProfile +}; + +class EditLanguageProfileModalContentConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + dragIndex: null, + dropIndex: null + }; + } + + componentDidMount() { + if (!this.props.id && !this.props.isPopulated) { + this.props.fetchLanguageProfileSchema(); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setLanguageProfileValue({ name, value }); + } + + onCutoffChange = ({ name, value }) => { + const id = parseInt(value); + const item = _.find(this.props.item.languages.value, (i) => i.language.id === id); + + this.props.setLanguageProfileValue({ name, value: item.language }); + } + + onSavePress = () => { + this.props.saveLanguageProfile({ id: this.props.id }); + } + + onLanguageProfileItemAllowedChange = (id, allowed) => { + const languageProfile = _.cloneDeep(this.props.item); + + const item = _.find(languageProfile.languages.value, (i) => i.language.id === id); + item.allowed = allowed; + + this.props.setLanguageProfileValue({ + name: 'languages', + value: languageProfile.languages.value + }); + + const cutoff = languageProfile.cutoff.value; + + // If the cutoff isn't allowed anymore or there isn't a cutoff set one + if (!cutoff || !_.find(languageProfile.languages.value, (i) => i.language.id === cutoff.id).allowed) { + const firstAllowed = _.find(languageProfile.languages.value, { allowed: true }); + + this.props.setLanguageProfileValue({ name: 'cutoff', value: firstAllowed ? firstAllowed.language : null }); + } + } + + onLanguageProfileItemDragMove = (dragIndex, dropIndex) => { + if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) { + this.setState({ + dragIndex, + dropIndex + }); + } + } + + onLanguageProfileItemDragEnd = ({ id }, didDrop) => { + const { + dragIndex, + dropIndex + } = this.state; + + if (didDrop && dropIndex !== null) { + const languageProfile = _.cloneDeep(this.props.item); + + const languages = languageProfile.languages.value.splice(dragIndex, 1); + languageProfile.languages.value.splice(dropIndex, 0, languages[0]); + + this.props.setLanguageProfileValue({ + name: 'languages', + value: languageProfile.languages.value + }); + } + + this.setState({ + dragIndex: null, + dropIndex: null + }); + } + + // + // Render + + render() { + if (_.isEmpty(this.props.item.languages) && !this.props.isFetching) { + return null; + } + + return ( + + ); + } +} + +EditLanguageProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setLanguageProfileValue: PropTypes.func.isRequired, + fetchLanguageProfileSchema: PropTypes.func.isRequired, + saveLanguageProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditLanguageProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfile.css b/frontend/src/Settings/Profiles/Language/LanguageProfile.css new file mode 100644 index 000000000..2ad9adfe9 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfile.css @@ -0,0 +1,31 @@ +.languageProfile { + composes: card from 'Components/Card.css'; + + width: 300px; +} + +.nameContainer { + display: flex; + justify-content: space-between; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.cloneButton { + composes: button from 'Components/Link/IconButton.css'; + + height: 36px; +} + +.languages { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfile.js b/frontend/src/Settings/Profiles/Language/LanguageProfile.js new file mode 100644 index 000000000..c9d042e45 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfile.js @@ -0,0 +1,147 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditLanguageProfileModalConnector from './EditLanguageProfileModalConnector'; +import styles from './LanguageProfile.css'; + +class LanguageProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditLanguageProfileModalOpen: false, + isDeleteLanguageProfileModalOpen: false + }; + } + + // + // Listeners + + onEditLanguageProfilePress = () => { + this.setState({ isEditLanguageProfileModalOpen: true }); + } + + onEditLanguageProfileModalClose = () => { + this.setState({ isEditLanguageProfileModalOpen: false }); + } + + onDeleteLanguageProfilePress = () => { + this.setState({ + isEditLanguageProfileModalOpen: false, + isDeleteLanguageProfileModalOpen: true + }); + } + + onDeleteLanguageProfileModalClose = () => { + this.setState({ isDeleteLanguageProfileModalOpen: false }); + } +onCloneLanguageProfilePress + onConfirmDeleteLanguageProfile = () => { + this.props.onConfirmDeleteLanguageProfile(this.props.id); + } + + onCloneLanguageProfilePress = () => { + const { + id, + onCloneLanguageProfilePress + } = this.props; + + onCloneLanguageProfilePress(id); + } + + // + // Render + + render() { + const { + id, + name, + upgradeAllowed, + cutoff, + languages, + isDeleting + } = this.props; + + return ( + +
+
+ {name} +
+ + +
+ +
+ { + languages.map((item) => { + if (!item.allowed) { + return null; + } + + const isCutoff = upgradeAllowed && item.language.id === cutoff.id; + + return ( + + ); + }) + } +
+ + + + +
+ ); + } +} + +LanguageProfile.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + upgradeAllowed: PropTypes.bool.isRequired, + cutoff: PropTypes.object.isRequired, + languages: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteLanguageProfile: PropTypes.func.isRequired, + onCloneLanguageProfilePress: PropTypes.func.isRequired +}; + +export default LanguageProfile; diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItem.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.css new file mode 100644 index 000000000..a10233929 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.css @@ -0,0 +1,44 @@ +.languageProfileItem { + display: flex; + align-items: stretch; + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; +} + +.checkContainer { + position: relative; + margin-right: 4px; + margin-bottom: 7px; + margin-left: 8px; +} + +.languageName { + display: flex; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; + line-height: 36px; + cursor: pointer; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItem.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.js new file mode 100644 index 000000000..2a3671268 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './LanguageProfileItem.css'; + +class LanguageProfileItem extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + languageId, + onLanguageProfileItemAllowedChange + } = this.props; + + onLanguageProfileItemAllowedChange(languageId, value); + } + + // + // Render + + render() { + const { + name, + allowed, + isDragging, + connectDragSource + } = this.props; + + return ( +
+ + + { + connectDragSource( +
+ +
+ ) + } +
+ ); + } +} + +LanguageProfileItem.propTypes = { + languageId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + sortIndex: PropTypes.number.isRequired, + isDragging: PropTypes.bool.isRequired, + connectDragSource: PropTypes.func, + onLanguageProfileItemAllowedChange: PropTypes.func +}; + +LanguageProfileItem.defaultProps = { + // The drag preview will not connect the drag handle. + connectDragSource: (node) => node +}; + +export default LanguageProfileItem; diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.css new file mode 100644 index 000000000..b927d9bce --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.css @@ -0,0 +1,4 @@ +.dragPreview { + width: 380px; + opacity: 0.75; +} diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.js new file mode 100644 index 000000000..ded14b39d --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { DragLayer } from 'react-dnd'; +import dimensions from 'Styles/Variables/dimensions.js'; +import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes'; +import DragPreviewLayer from 'Components/DragPreviewLayer'; +import LanguageProfileItem from './LanguageProfileItem'; +import styles from './LanguageProfileItemDragPreview.css'; + +const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth); +const formLabelLargeWidth = parseInt(dimensions.formLabelLargeWidth); +const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth); +const dragHandleWidth = parseInt(dimensions.dragHandleWidth); + +function collectDragLayer(monitor) { + return { + item: monitor.getItem(), + itemType: monitor.getItemType(), + currentOffset: monitor.getSourceClientOffset() + }; +} + +class LanguageProfileItemDragPreview extends Component { + + // + // Render + + render() { + const { + item, + itemType, + currentOffset + } = this.props; + + if (!currentOffset || itemType !== QUALITY_PROFILE_ITEM) { + return null; + } + + // The offset is shifted because the drag handle is on the right edge of the + // list item and the preview is wider than the drag handle. + + const { x, y } = currentOffset; + const handleOffset = formGroupSmallWidth - formLabelLargeWidth - formLabelRightMarginWidth - dragHandleWidth; + const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`; + + const style = { + position: 'absolute', + WebkitTransform: transform, + msTransform: transform, + transform + }; + + const { + languageId, + name, + allowed, + sortIndex + } = item; + + return ( + +
+ +
+
+ ); + } +} + +LanguageProfileItemDragPreview.propTypes = { + item: PropTypes.object, + itemType: PropTypes.string, + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired + }) +}; + +export default DragLayer(collectDragLayer)(LanguageProfileItemDragPreview); diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.css new file mode 100644 index 000000000..f59379129 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.css @@ -0,0 +1,18 @@ +.languageProfileItemDragSource { + padding: 4px 0; +} + +.languageProfileItemPlaceholder { + width: 100%; + height: 36px; + border: 1px dotted #aaa; + border-radius: 4px; +} + +.languageProfileItemPlaceholderBefore { + margin-bottom: 8px; +} + +.languageProfileItemPlaceholderAfter { + margin-top: 8px; +} diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.js new file mode 100644 index 000000000..304363726 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.js @@ -0,0 +1,157 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { findDOMNode } from 'react-dom'; +import { DragSource, DropTarget } from 'react-dnd'; +import classNames from 'classnames'; +import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes'; +import LanguageProfileItem from './LanguageProfileItem'; +import styles from './LanguageProfileItemDragSource.css'; + +const languageProfileItemDragSource = { + beginDrag({ languageId, name, allowed, sortIndex }) { + return { + languageId, + name, + allowed, + sortIndex + }; + }, + + endDrag(props, monitor, component) { + props.onLanguageProfileItemDragEnd(monitor.getItem(), monitor.didDrop()); + } +}; + +const languageProfileItemDropTarget = { + hover(props, monitor, component) { + const dragIndex = monitor.getItem().sortIndex; + const hoverIndex = props.sortIndex; + + const hoverBoundingRect = findDOMNode(component).getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + // Moving up, only trigger if drag position is above 50% + if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) { + return; + } + + // Moving down, only trigger if drag position is below 50% + if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) { + return; + } + + props.onLanguageProfileItemDragMove(dragIndex, hoverIndex); + } +}; + +function collectDragSource(connect, monitor) { + return { + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging() + }; +} + +function collectDropTarget(connect, monitor) { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver() + }; +} + +class LanguageProfileItemDragSource extends Component { + + // + // Render + + render() { + const { + languageId, + name, + allowed, + sortIndex, + isDragging, + isDraggingUp, + isDraggingDown, + isOver, + connectDragSource, + connectDropTarget, + onLanguageProfileItemAllowedChange + } = this.props; + + const isBefore = !isDragging && isDraggingUp && isOver; + const isAfter = !isDragging && isDraggingDown && isOver; + + // if (isDragging && !isOver) { + // return null; + // } + + return connectDropTarget( +
+ { + isBefore && +
+ } + + + + { + isAfter && +
+ } +
+ ); + } +} + +LanguageProfileItemDragSource.propTypes = { + languageId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + sortIndex: PropTypes.number.isRequired, + isDragging: PropTypes.bool, + isDraggingUp: PropTypes.bool, + isDraggingDown: PropTypes.bool, + isOver: PropTypes.bool, + connectDragSource: PropTypes.func, + connectDropTarget: PropTypes.func, + onLanguageProfileItemAllowedChange: PropTypes.func.isRequired, + onLanguageProfileItemDragMove: PropTypes.func.isRequired, + onLanguageProfileItemDragEnd: PropTypes.func.isRequired +}; + +export default DropTarget( + QUALITY_PROFILE_ITEM, + languageProfileItemDropTarget, + collectDropTarget +)(DragSource( + QUALITY_PROFILE_ITEM, + languageProfileItemDragSource, + collectDragSource +)(LanguageProfileItemDragSource)); diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItems.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.css new file mode 100644 index 000000000..48b30f326 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.css @@ -0,0 +1,6 @@ +.languages { + margin-top: 10px; + /* TODO: This should consider the number of languages in the list */ + min-height: 550px; + user-select: none; +} diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItems.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.js new file mode 100644 index 000000000..831743cbe --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.js @@ -0,0 +1,103 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import LanguageProfileItemDragSource from './LanguageProfileItemDragSource'; +import LanguageProfileItemDragPreview from './LanguageProfileItemDragPreview'; +import styles from './LanguageProfileItems.css'; + +class LanguageProfileItems extends Component { + + // + // Render + + render() { + const { + dragIndex, + dropIndex, + languageProfileItems, + errors, + warnings, + ...otherProps + } = this.props; + + const isDragging = dropIndex !== null; + const isDraggingUp = isDragging && dropIndex > dragIndex; + const isDraggingDown = isDragging && dropIndex < dragIndex; + + return ( + + Languages +
+ + + { + errors.map((error, index) => { + return ( + + ); + }) + } + + { + warnings.map((warning, index) => { + return ( + + ); + }) + } + +
+ { + languageProfileItems.map(({ allowed, language }, index) => { + return ( + + ); + }).reverse() + } + + +
+
+
+ ); + } +} + +LanguageProfileItems.propTypes = { + dragIndex: PropTypes.number, + dropIndex: PropTypes.number, + languageProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired, + errors: PropTypes.arrayOf(PropTypes.object), + warnings: PropTypes.arrayOf(PropTypes.object) +}; + +LanguageProfileItems.defaultProps = { + errors: [], + warnings: [] +}; + +export default LanguageProfileItems; diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileNameConnector.js b/frontend/src/Settings/Profiles/Language/LanguageProfileNameConnector.js new file mode 100644 index 000000000..61a7153b5 --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfileNameConnector.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector'; + +function createMapStateToProps() { + return createSelector( + createLanguageProfileSelector(), + (languageProfile) => { + return { + name: languageProfile.name + }; + } + ); +} + +function LanguageProfileNameConnector({ name, ...otherProps }) { + return ( + + {name} + + ); +} + +LanguageProfileNameConnector.propTypes = { + languageProfileId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired +}; + +export default connect(createMapStateToProps)(LanguageProfileNameConnector); diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfiles.css b/frontend/src/Settings/Profiles/Language/LanguageProfiles.css new file mode 100644 index 000000000..5a2fb73bd --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfiles.css @@ -0,0 +1,21 @@ +.languageProfiles { + display: flex; + flex-wrap: wrap; +} + +.addLanguageProfile { + composes: languageProfile from './LanguageProfile.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfiles.js b/frontend/src/Settings/Profiles/Language/LanguageProfiles.js new file mode 100644 index 000000000..eb8286dfc --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfiles.js @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import LanguageProfile from './LanguageProfile'; +import EditLanguageProfileModalConnector from './EditLanguageProfileModalConnector'; +import styles from './LanguageProfiles.css'; + +class LanguageProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isLanguageProfileModalOpen: false + }; + } + + // + // Listeners + + onCloneLanguageProfilePress = (id) => { + this.props.onCloneLanguageProfilePress(id); + this.setState({ isLanguageProfileModalOpen: true }); + } + + onEditLanguageProfilePress = () => { + this.setState({ isLanguageProfileModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isLanguageProfileModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + isDeleting, + onConfirmDeleteLanguageProfile, + onCloneLanguageProfilePress, + ...otherProps + } = this.props; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + +
+
+ ); + } +} + +LanguageProfiles.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteLanguageProfile: PropTypes.func.isRequired, + onCloneLanguageProfilePress: PropTypes.func.isRequired +}; + +export default LanguageProfiles; diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js b/frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js new file mode 100644 index 000000000..1b122ab0f --- /dev/null +++ b/frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js @@ -0,0 +1,67 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchLanguageProfiles, deleteLanguageProfile, cloneLanguageProfile } from 'Store/Actions/settingsActions'; +import LanguageProfiles from './LanguageProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + (state) => state.settings.languageProfiles, + (advancedSettings, languageProfiles) => { + return { + advancedSettings, + ...languageProfiles + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchLanguageProfiles: fetchLanguageProfiles, + dispatchDeleteLanguageProfile: deleteLanguageProfile, + dispatchCloneLanguageProfile: cloneLanguageProfile +}; + +class LanguageProfilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchLanguageProfiles(); + } + + // + // Listeners + + onConfirmDeleteLanguageProfile = (id) => { + this.props.dispatchDeleteLanguageProfile({ id }); + } + + onCloneLanguageProfilePress = (id) => { + this.props.dispatchCloneLanguageProfile({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +LanguageProfilesConnector.propTypes = { + dispatchFetchLanguageProfiles: PropTypes.func.isRequired, + dispatchDeleteLanguageProfile: PropTypes.func.isRequired, + dispatchCloneLanguageProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(LanguageProfilesConnector); diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js new file mode 100644 index 000000000..9f0130f27 --- /dev/null +++ b/frontend/src/Settings/Profiles/Profiles.js @@ -0,0 +1,38 @@ +import React, { Component } from 'react'; +import { DragDropContext } from 'react-dnd'; +import HTML5Backend from 'react-dnd-html5-backend'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import QualityProfilesConnector from './Quality/QualityProfilesConnector'; +import LanguageProfilesConnector from './Language/LanguageProfilesConnector'; +import DelayProfilesConnector from './Delay/DelayProfilesConnector'; +import ReleaseProfilesConnector from './Release/ReleaseProfilesConnector'; + +class Profiles extends Component { + + // + // Render + + render() { + return ( + + + + + + + + + + + ); + } +} + +// Only a single DragDropContext can exist so it's done here to allow editing +// quality profiles and reordering delay profiles to work. + +export default DragDropContext(HTML5Backend)(Profiles); diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js new file mode 100644 index 000000000..9ecbd1ca8 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector'; + +class EditQualityProfileModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + height: 'auto' + }; + } + + // + // Listeners + + onContentHeightChange = (height) => { + if (this.state.height === 'auto' || height > this.state.height) { + this.setState({ height }); + } + } + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +EditQualityProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditQualityProfileModal; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js new file mode 100644 index 000000000..942949cac --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditQualityProfileModal from './EditQualityProfileModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditQualityProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.qualityProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditQualityProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditQualityProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css new file mode 100644 index 000000000..2f6589933 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css @@ -0,0 +1,18 @@ +.formGroupsContainer { + display: flex; + flex-wrap: wrap; +} + +.formGroupWrapper { + flex: 0 0 calc($formGroupSmallWidth - 100px); +} + +.deleteButtonContainer { + margin-right: auto; +} + +@media only screen and (max-width: $breakpointLarge) { + .formGroupsContainer { + display: block; + } +} diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js new file mode 100644 index 000000000..7991f1a7e --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js @@ -0,0 +1,270 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import dimensions from 'Styles/Variables/dimensions'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Measure from 'Components/Measure'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import QualityProfileItems from './QualityProfileItems'; +import styles from './EditQualityProfileModalContent.css'; + +const MODAL_BODY_PADDING = parseInt(dimensions.modalBodyPadding); + +class EditQualityProfileModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + headerHeight: 0, + bodyHeight: 0, + footerHeight: 0 + }; + } + + componentDidUpdate(prevProps, prevState) { + const { + headerHeight, + bodyHeight, + footerHeight + } = this.state; + + if ( + headerHeight > 0 && + bodyHeight > 0 && + footerHeight > 0 && + ( + headerHeight !== prevState.headerHeight || + bodyHeight !== prevState.bodyHeight || + footerHeight !== prevState.footerHeight + ) + ) { + const padding = MODAL_BODY_PADDING * 2; + + this.props.onContentHeightChange( + headerHeight + bodyHeight + footerHeight + padding + ); + } + } + + // + // Listeners + + onHeaderMeasure = ({ height }) => { + if (height > this.state.headerHeight) { + this.setState({ headerHeight: height }); + } + } + + onBodyMeasure = ({ height }) => { + + if (height > this.state.bodyHeight) { + this.setState({ bodyHeight: height }); + } + } + + onFooterMeasure = ({ height }) => { + if (height > this.state.footerHeight) { + this.setState({ footerHeight: height }); + } + } + + // + // Render + + render() { + const { + editGroups, + isFetching, + error, + isSaving, + saveError, + qualities, + item, + isInUse, + onInputChange, + onCutoffChange, + onSavePress, + onModalClose, + onDeleteQualityProfilePress, + ...otherProps + } = this.props; + + const { + id, + name, + upgradeAllowed, + cutoff, + items + } = item; + + return ( + + + + {id ? 'Edit Quality Profile' : 'Add Quality Profile'} + + + + + +
+ { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new quality profile, please try again.
+ } + + { + !isFetching && !error && +
+
+
+ + + Name + + + + + + + + Upgrades Allowed + + + + + + { + upgradeAllowed.value && + + + Upgrade Until + + + + + } +
+ +
+ +
+
+
+ + } +
+
+
+ + + + { + 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..2decf2198 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js @@ -0,0 +1,442 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { fetchQualityProfileSchema, setQualityProfileValue, saveQualityProfile } from 'Store/Actions/settingsActions'; +import EditQualityProfileModalContent from './EditQualityProfileModalContent'; + +function getQualityItemGroupId(qualityProfile) { + // Get items with an `id` and filter out null/undefined values + const ids = _.filter(_.map(qualityProfile.items.value, 'id'), (i) => i != null); + + return Math.max(1000, ...ids) + 1; +} + +function parseIndex(index) { + const split = index.split('.'); + + if (split.length === 1) { + return [ + null, + parseInt(split[0]) - 1 + ]; + } + + return [ + parseInt(split[0]) - 1, + parseInt(split[1]) - 1 + ]; +} + +function createQualitiesSelector() { + return createSelector( + createProviderSettingsSelector('qualityProfiles'), + (qualityProfile) => { + const items = qualityProfile.item.items; + if (!items || !items.value) { + return []; + } + + return _.reduceRight(items.value, (result, { allowed, id, name, quality }) => { + if (allowed) { + if (id) { + result.push({ + key: id, + value: name + }); + } else { + result.push({ + key: quality.id, + value: quality.name + }); + } + } + + return result; + }, []); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createProviderSettingsSelector('qualityProfiles'), + createQualitiesSelector(), + createProfileInUseSelector('qualityProfileId'), + (qualityProfile, qualities, isInUse) => { + return { + qualities, + ...qualityProfile, + isInUse + }; + } + ); +} + +const mapDispatchToProps = { + fetchQualityProfileSchema, + setQualityProfileValue, + saveQualityProfile +}; + +class EditQualityProfileModalContentConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + dragQualityIndex: null, + dropQualityIndex: null, + dropPosition: null, + editGroups: false + }; + } + + componentDidMount() { + if (!this.props.id && !this.props.isPopulated) { + this.props.fetchQualityProfileSchema(); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Control + + ensureCutoff = (qualityProfile) => { + const cutoff = qualityProfile.cutoff.value; + + const cutoffItem = _.find(qualityProfile.items.value, (i) => { + if (!cutoff) { + return false; + } + + return i.id === cutoff || (i.quality && i.quality.id === cutoff); + }); + + // If the cutoff isn't allowed anymore or there isn't a cutoff set one + if (!cutoff || !cutoffItem || !cutoffItem.allowed) { + const firstAllowed = _.find(qualityProfile.items.value, { allowed: true }); + let cutoffId = null; + + if (firstAllowed) { + cutoffId = firstAllowed.quality ? firstAllowed.quality.id : firstAllowed.id; + } + + this.props.setQualityProfileValue({ name: 'cutoff', value: cutoffId }); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setQualityProfileValue({ name, value }); + } + + onCutoffChange = ({ name, value }) => { + const id = parseInt(value); + const item = _.find(this.props.item.items.value, (i) => { + if (i.quality) { + return i.quality.id === id; + } + + return i.id === id; + }); + + const cutoffId = item.quality ? item.quality.id : item.id; + + this.props.setQualityProfileValue({ name, value: cutoffId }); + } + + onSavePress = () => { + this.props.saveQualityProfile({ id: this.props.id }); + } + + onQualityProfileItemAllowedChange = (id, allowed) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const item = _.find(qualityProfile.items.value, (i) => i.quality && i.quality.id === id); + + item.allowed = allowed; + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onItemGroupAllowedChange = (id, allowed) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const item = _.find(qualityProfile.items.value, (i) => i.id === id); + + item.allowed = allowed; + + // Update each item in the group (for consistency only) + item.items.forEach((i) => { + i.allowed = allowed; + }); + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onItemGroupNameChange = (id, name) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const group = _.find(items, (i) => i.id === id); + + group.name = name; + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + } + + onCreateGroupPress = (id) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const item = _.find(items, (i) => i.quality && i.quality.id === id); + const index = items.indexOf(item); + const groupId = getQualityItemGroupId(qualityProfile); + + const group = { + id: groupId, + name: item.quality.name, + allowed: item.allowed, + items: [ + item + ] + }; + + // Add the group in the same location the quality item was in. + items.splice(index, 1, group); + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onDeleteGroupPress = (id) => { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const group = _.find(items, (i) => i.id === id); + const index = items.indexOf(group); + + // Add the items in the same location the group was in + items.splice(index, 1, ...group.items); + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + onQualityProfileItemDragMove = (options) => { + const { + dragQualityIndex, + dropQualityIndex, + dropPosition + } = options; + + const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex); + const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex); + + if ( + (dropPosition === 'below' && dropItemIndex - 1 === dragItemIndex) || + (dropPosition === 'above' && dropItemIndex + 1 === dragItemIndex) + ) { + if ( + this.state.dragQualityIndex != null && + this.state.dropQualityIndex != null && + this.state.dropPosition != null + ) { + this.setState({ + dragQualityIndex: null, + dropQualityIndex: null, + dropPosition: null + }); + } + + return; + } + + let adjustedDropQualityIndex = dropQualityIndex; + + // Correct dragging out of a group to the position above + if ( + dropPosition === 'above' && + dragGroupIndex !== dropGroupIndex && + dropGroupIndex != null + ) { + // Add 1 to the group index and 2 to the item index so it's inserted above in the correct group + adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex + 2}`; + } + + // Correct inserting above outside a group + if ( + dropPosition === 'above' && + dragGroupIndex !== dropGroupIndex && + dropGroupIndex == null + ) { + // Add 2 to the item index so it's entered in the correct place + adjustedDropQualityIndex = `${dropItemIndex + 2}`; + } + + // Correct inserting below a quality within the same group (when moving a lower item) + if ( + dropPosition === 'below' && + dragGroupIndex === dropGroupIndex && + dropGroupIndex != null && + dragItemIndex < dropItemIndex + ) { + // Add 1 to the group index leave the item index + adjustedDropQualityIndex = `${dropGroupIndex + 1}.${dropItemIndex}`; + } + + // Correct inserting below a quality outside a group (when moving a lower item) + if ( + dropPosition === 'below' && + dragGroupIndex === dropGroupIndex && + dropGroupIndex == null && + dragItemIndex < dropItemIndex + ) { + // Leave the item index so it's inserted below the item + adjustedDropQualityIndex = `${dropItemIndex}`; + } + + if ( + dragQualityIndex !== this.state.dragQualityIndex || + adjustedDropQualityIndex !== this.state.dropQualityIndex || + dropPosition !== this.state.dropPosition + ) { + this.setState({ + dragQualityIndex, + dropQualityIndex: adjustedDropQualityIndex, + dropPosition + }); + } + } + + onQualityProfileItemDragEnd = (didDrop) => { + const { + dragQualityIndex, + dropQualityIndex + } = this.state; + + if (didDrop && dropQualityIndex != null) { + const qualityProfile = _.cloneDeep(this.props.item); + const items = qualityProfile.items.value; + const [dragGroupIndex, dragItemIndex] = parseIndex(dragQualityIndex); + const [dropGroupIndex, dropItemIndex] = parseIndex(dropQualityIndex); + + let item = null; + let dropGroup = null; + + // Get the group before moving anything so we know the correct place to drop it. + if (dropGroupIndex != null) { + dropGroup = items[dropGroupIndex]; + } + + if (dragGroupIndex == null) { + item = items.splice(dragItemIndex, 1)[0]; + } else { + const group = items[dragGroupIndex]; + item = group.items.splice(dragItemIndex, 1)[0]; + + // If the group is now empty, destroy it. + if (!group.items.length) { + items.splice(dragGroupIndex, 1); + } + } + + if (dropGroupIndex == null) { + items.splice(dropItemIndex, 0, item); + } else { + dropGroup.items.splice(dropItemIndex, 0, item); + } + + this.props.setQualityProfileValue({ + name: 'items', + value: items + }); + + this.ensureCutoff(qualityProfile); + } + + this.setState({ + dragQualityIndex: null, + dropQualityIndex: null, + dropPosition: null + }); + } + + onToggleEditGroupsMode = () => { + this.setState({ editGroups: !this.state.editGroups }); + } + + // + // Render + + render() { + if (_.isEmpty(this.props.item.items) && !this.props.isFetching) { + return null; + } + + return ( + + ); + } +} + +EditQualityProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setQualityProfileValue: PropTypes.func.isRequired, + fetchQualityProfileSchema: PropTypes.func.isRequired, + saveQualityProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditQualityProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.css b/frontend/src/Settings/Profiles/Quality/QualityProfile.css new file mode 100644 index 000000000..341d75aa2 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.css @@ -0,0 +1,38 @@ +.qualityProfile { + composes: card from 'Components/Card.css'; + + width: 300px; +} + +.nameContainer { + display: flex; + justify-content: space-between; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.cloneButton { + composes: button from 'Components/Link/IconButton.css'; + + height: 36px; +} + +.qualities { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} + +.tooltipLabel { + composes: label from 'Components/Label.css'; + + margin: 0; + border: none; +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.js b/frontend/src/Settings/Profiles/Quality/QualityProfile.js new file mode 100644 index 000000000..f4a4ca414 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.js @@ -0,0 +1,186 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import EditQualityProfileModalConnector from './EditQualityProfileModalConnector'; +import styles from './QualityProfile.css'; + +class QualityProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditQualityProfileModalOpen: false, + isDeleteQualityProfileModalOpen: false + }; + } + + // + // Listeners + + onEditQualityProfilePress = () => { + this.setState({ isEditQualityProfileModalOpen: true }); + } + + onEditQualityProfileModalClose = () => { + this.setState({ isEditQualityProfileModalOpen: false }); + } + + onDeleteQualityProfilePress = () => { + this.setState({ + isEditQualityProfileModalOpen: false, + isDeleteQualityProfileModalOpen: true + }); + } + + onDeleteQualityProfileModalClose = () => { + this.setState({ isDeleteQualityProfileModalOpen: false }); + } + + onConfirmDeleteQualityProfile = () => { + this.props.onConfirmDeleteQualityProfile(this.props.id); + } + + onCloneQualityProfilePress = () => { + const { + id, + onCloneQualityProfilePress + } = this.props; + + onCloneQualityProfilePress(id); + } + + // + // Render + + render() { + const { + id, + name, + upgradeAllowed, + cutoff, + items, + isDeleting + } = this.props; + + return ( + +
+
+ {name} +
+ + +
+ +
+ { + items.map((item) => { + if (!item.allowed) { + return null; + } + + if (item.quality) { + const isCutoff = upgradeAllowed && item.quality.id === cutoff; + + return ( + + ); + } + + const isCutoff = upgradeAllowed && item.id === cutoff; + + 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, + upgradeAllowed: PropTypes.bool.isRequired, + cutoff: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteQualityProfile: PropTypes.func.isRequired, + onCloneQualityProfilePress: PropTypes.func.isRequired +}; + +export default QualityProfile; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css new file mode 100644 index 000000000..7e6370ff8 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css @@ -0,0 +1,85 @@ +.qualityProfileItem { + display: flex; + align-items: stretch; + width: 100%; + border: 1px solid #aaa; + border-radius: 4px; + background: #fafafa; + + &.isInGroup { + border-style: dashed; + } +} + +.checkInputContainer { + position: relative; + margin-right: 4px; + margin-bottom: 5px; + margin-left: 8px; +} + +.checkInput { + composes: input from 'Components/Form/CheckInput.css'; + + margin-top: 5px; +} + +.qualityNameContainer { + display: flex; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; + line-height: $qualityProfileItemHeight; + cursor: pointer; +} + +.qualityName { + &.isInGroup { + margin-left: 14px; + } + + &.notAllowed { + color: #c6c6c6; + } +} + +.createGroupButton { + composes: buton from 'Components/Link/IconButton.css'; + + display: flex; + justify-content: center; + flex-shrink: 0; + margin-right: 5px; + margin-left: 8px; + width: 20px; +} + +.dragHandle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + width: $dragHandleWidth; + text-align: center; + cursor: grab; +} + +.dragIcon { + top: 0; +} + +.isDragging { + opacity: 0.25; +} + +.isPreview { + .qualityName { + margin-left: 14px; + + &.isInGroup { + margin-left: 28px; + } + } +} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js new file mode 100644 index 000000000..8161e7061 --- /dev/null +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js @@ -0,0 +1,131 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './QualityProfileItem.css'; + +class QualityProfileItem extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + qualityId, + onQualityProfileItemAllowedChange + } = this.props; + + onQualityProfileItemAllowedChange(qualityId, value); + } + + onCreateGroupPress = () => { + const { + qualityId, + onCreateGroupPress + } = this.props; + + onCreateGroupPress(qualityId); + } + + // + // Render + + render() { + const { + editGroups, + isPreview, + groupId, + name, + allowed, + isDragging, + isOverCurrent, + connectDragSource + } = this.props; + + return ( +
+ + + { + 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/Profiles/Release/EditReleaseProfileModal.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js new file mode 100644 index 000000000..5d0e74287 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import EditReleaseProfileModalContentConnector from './EditReleaseProfileModalContentConnector'; + +function EditReleaseProfileModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditReleaseProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditReleaseProfileModal; diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js new file mode 100644 index 000000000..89b605652 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditReleaseProfileModal from './EditReleaseProfileModal'; + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditReleaseProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'settings.releaseProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditReleaseProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(null, mapDispatchToProps)(EditReleaseProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css new file mode 100644 index 000000000..a3c7f464c --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js new file mode 100644 index 000000000..81a93dd5a --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js @@ -0,0 +1,158 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import styles from './EditReleaseProfileModalContent.css'; + +function EditReleaseProfileModalContent(props) { + const { + isSaving, + saveError, + item, + onInputChange, + onModalClose, + onSavePress, + onDeleteReleaseProfilePress, + ...otherProps + } = props; + + const { + id, + required, + ignored, + preferred, + includePreferredWhenRenaming, + tags + } = item; + + return ( + + + {id ? 'Edit Release Profile' : 'Add Release Profile'} + + + +
+ + Must Contain + + + + + + Must Not Contain + + + + + + Preferred + + + + + + Include Preferred when Renaming + + + + + + Tags + + + +
+
+ + { + id && + + } + + + + + Save + + +
+ ); +} + +EditReleaseProfileModalContent.propTypes = { + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onDeleteReleaseProfilePress: PropTypes.func +}; + +export default EditReleaseProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js new file mode 100644 index 000000000..a5fbaa680 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContentConnector.js @@ -0,0 +1,113 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import { setReleaseProfileValue, saveReleaseProfile } from 'Store/Actions/settingsActions'; +import EditReleaseProfileModalContent from './EditReleaseProfileModalContent'; + +const newReleaseProfile = { + required: '', + ignored: '', + preferred: '', + includePreferredWhenRenaming: false, + tags: [] +}; + +function createMapStateToProps() { + return createSelector( + (state, { id }) => id, + (state) => state.settings.releaseProfiles, + (id, releaseProfiles) => { + const { + isFetching, + error, + isSaving, + saveError, + pendingChanges, + items + } = releaseProfiles; + + const profile = id ? _.find(items, { id }) : newReleaseProfile; + const settings = selectSettings(profile, pendingChanges, saveError); + + return { + id, + isFetching, + error, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + setReleaseProfileValue, + saveReleaseProfile +}; + +class EditReleaseProfileModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.id) { + Object.keys(newReleaseProfile).forEach((name) => { + this.props.setReleaseProfileValue({ + name, + value: newReleaseProfile[name] + }); + }); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setReleaseProfileValue({ name, value }); + } + + onSavePress = () => { + this.props.saveReleaseProfile({ id: this.props.id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditReleaseProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setReleaseProfileValue: PropTypes.func.isRequired, + saveReleaseProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditReleaseProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfile.css b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css new file mode 100644 index 000000000..8c703d283 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfile.css @@ -0,0 +1,11 @@ +.releaseProfile { + composes: card from 'Components/Card.css'; + + width: 290px; +} + +.enabled { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfile.js b/frontend/src/Settings/Profiles/Release/ReleaseProfile.js new file mode 100644 index 000000000..0455594c2 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfile.js @@ -0,0 +1,168 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import split from 'Utilities/String/split'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import TagList from 'Components/TagList'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector'; +import styles from './ReleaseProfile.css'; + +class ReleaseProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditReleaseProfileModalOpen: false, + isDeleteReleaseProfileModalOpen: false + }; + } + + // + // Listeners + + onEditReleaseProfilePress = () => { + this.setState({ isEditReleaseProfileModalOpen: true }); + } + + onEditReleaseProfileModalClose = () => { + this.setState({ isEditReleaseProfileModalOpen: false }); + } + + onDeleteReleaseProfilePress = () => { + this.setState({ + isEditReleaseProfileModalOpen: false, + isDeleteReleaseProfileModalOpen: true + }); + } + + onDeleteReleaseProfileModalClose= () => { + this.setState({ isDeleteReleaseProfileModalOpen: false }); + } + + onConfirmDeleteReleaseProfile = () => { + this.props.onConfirmDeleteReleaseProfile(this.props.id); + } + + // + // Render + + render() { + const { + id, + required, + ignored, + preferred, + tags, + tagList + } = this.props; + + return ( + +
+ { + split(required).map((item) => { + if (!item) { + return null; + } + + return ( + + ); + }) + } +
+ +
+ { + split(ignored).map((item) => { + if (!item) { + return null; + } + + return ( + + ); + }) + } +
+ +
+ { + preferred.map((item) => { + const isPreferred = item.value >= 0; + + return ( + + ); + }) + } +
+ + + + + + +
+ ); + } +} + +ReleaseProfile.propTypes = { + id: PropTypes.number.isRequired, + required: PropTypes.string.isRequired, + ignored: PropTypes.string.isRequired, + preferred: PropTypes.arrayOf(PropTypes.object).isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteReleaseProfile: PropTypes.func.isRequired +}; + +ReleaseProfile.defaultProps = { + required: '', + ignored: '', + preferred: [] +}; + +export default ReleaseProfile; diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css new file mode 100644 index 000000000..e3573452e --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.css @@ -0,0 +1,20 @@ +.releaseProfiles { + display: flex; + flex-wrap: wrap; +} + +.addReleaseProfile { + composes: releaseProfile from './ReleaseProfile.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js new file mode 100644 index 000000000..73c648a04 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfiles.js @@ -0,0 +1,98 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import ReleaseProfile from './ReleaseProfile'; +import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector'; +import styles from './ReleaseProfiles.css'; + +class ReleaseProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isAddReleaseProfileModalOpen: false + }; + } + + // + // Listeners + + onAddReleaseProfilePress = () => { + this.setState({ isAddReleaseProfileModalOpen: true }); + } + + onAddReleaseProfileModalClose = () => { + this.setState({ isAddReleaseProfileModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + tagList, + onConfirmDeleteReleaseProfile, + ...otherProps + } = this.props; + + return ( +
+ +
+ +
+ +
+
+ + { + items.map((item) => { + return ( + + ); + }) + } +
+ + +
+
+ ); + } +} + +ReleaseProfiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteReleaseProfile: PropTypes.func.isRequired +}; + +export default ReleaseProfiles; diff --git a/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js new file mode 100644 index 000000000..dd4b41171 --- /dev/null +++ b/frontend/src/Settings/Profiles/Release/ReleaseProfilesConnector.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchReleaseProfiles, deleteReleaseProfile } from 'Store/Actions/settingsActions'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import ReleaseProfiles from './ReleaseProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.releaseProfiles, + createTagsSelector(), + (releaseProfiles, tagList) => { + return { + ...releaseProfiles, + tagList + }; + } + ); +} + +const mapDispatchToProps = { + fetchReleaseProfiles, + deleteReleaseProfile +}; + +class ReleaseProfilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchReleaseProfiles(); + } + + // + // Listeners + + onConfirmDeleteReleaseProfile = (id) => { + this.props.deleteReleaseProfile({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ReleaseProfilesConnector.propTypes = { + fetchReleaseProfiles: PropTypes.func.isRequired, + deleteReleaseProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ReleaseProfilesConnector); diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css b/frontend/src/Settings/Quality/Definition/QualityDefinition.css new file mode 100644 index 000000000..ccfd00c7a --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css @@ -0,0 +1,93 @@ +.qualityDefinition { + display: flex; + align-content: stretch; + margin: 5px 0; + padding-top: 5px; + height: 45px; + border-top: 1px solid $borderColor; +} + +.quality, +.title { + flex: 0 1 250px; + padding-right: 20px; + line-height: 40px; +} + +.sizeLimit { + flex: 0 1 500px; + padding-right: 30px; +} + +.slider { + width: 100%; + height: 20px; +} + +.bar { + top: 9px; + margin: 0 5px; + height: 3px; + background-color: $sliderAccentColor; + box-shadow: 0 0 0 #000; + + &:nth-child(odd) { + background-color: #ddd; + } +} + +.handle { + top: 1px; + z-index: 0 !important; + width: 18px; + height: 18px; + border: 3px solid $sliderAccentColor; + border-radius: 50%; + background-color: $white; + text-align: center; + cursor: pointer; +} + +.sizes { + display: flex; + justify-content: space-between; +} + +.megabytesPerMinute { + display: flex; + justify-content: space-between; + flex: 0 0 250px; +} + +.sizeInput { + composes: input from 'Components/Form/TextInput.css'; + + display: inline-block; + margin-left: 5px; + padding: 6px; + width: 75px; +} + +@media only screen and (max-width: $breakpointSmall) { + .qualityDefinition { + flex-wrap: wrap; + height: auto; + + &:first-child { + border-top: none; + } + } + + .qualityDefinition:first-child { + border-top: none; + } + + .quality { + font-weight: bold; + line-height: inherit; + } + + .sizeLimit { + margin-top: 10px; + } +} diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.js b/frontend/src/Settings/Quality/Definition/QualityDefinition.js new file mode 100644 index 000000000..2ff558037 --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js @@ -0,0 +1,193 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import ReactSlider from 'react-slider'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { kinds } from 'Helpers/Props'; +import Label from 'Components/Label'; +import NumberInput from 'Components/Form/NumberInput'; +import TextInput from 'Components/Form/TextInput'; +import styles from './QualityDefinition.css'; + +const slider = { + min: 0, + max: 200, + step: 0.1 +}; + +function getValue(value) { + if (value < slider.min) { + return slider.min; + } + + if (value > slider.max) { + return slider.max; + } + + return value; +} + +class QualityDefinition extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._forceUpdateTimeout = null; + } + + componentDidMount() { + // A hack to deal with a bug in the slider component until a fix for it + // lands and an updated version is available. + // See: https://github.com/mpowaga/react-slider/issues/115 + + this._forceUpdateTimeout = setTimeout(() => this.forceUpdate(), 1); + } + + componentWillUnmount() { + if (this._forceUpdateTimeout) { + clearTimeout(this._forceUpdateTimeout); + } + } + + // + // Listeners + + onSizeChange = ([minSize, maxSize]) => { + maxSize = maxSize === slider.max ? null : maxSize; + + this.props.onSizeChange({ minSize, maxSize }); + } + + onMinSizeChange = ({ value }) => { + const minSize = getValue(value); + + this.props.onSizeChange({ + minSize, + maxSize: this.props.maxSize + }); + } + + onMaxSizeChange = ({ value }) => { + const maxSize = value === slider.max ? null : getValue(value); + + this.props.onSizeChange({ + minSize: this.props.minSize, + maxSize + }); + } + + // + // Render + + render() { + const { + id, + quality, + title, + minSize, + maxSize, + advancedSettings, + onTitleChange + } = this.props; + + const minBytes = minSize * 1024 * 1024; + const minThirty = formatBytes(minBytes * 30, 2); + const minSixty = formatBytes(minBytes * 60, 2); + + const maxBytes = maxSize && maxSize * 1024 * 1024; + const maxThirty = maxBytes ? formatBytes(maxBytes * 30, 2) : 'Unlimited'; + const maxSixty = maxBytes ? formatBytes(maxBytes * 60, 2) : 'Unlimited'; + + return ( +
+
+ {quality.name} +
+ +
+ +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+
+ + { + advancedSettings && +
+
+ Min + + +
+ +
+ Max + + +
+
+ } +
+ ); + } +} + +QualityDefinition.propTypes = { + id: PropTypes.number.isRequired, + quality: PropTypes.object.isRequired, + title: PropTypes.string.isRequired, + minSize: PropTypes.number, + maxSize: PropTypes.number, + advancedSettings: PropTypes.bool.isRequired, + onTitleChange: PropTypes.func.isRequired, + onSizeChange: PropTypes.func.isRequired +}; + +export default QualityDefinition; diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js new file mode 100644 index 000000000..9404dfd9f --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { setQualityDefinitionValue } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import QualityDefinition from './QualityDefinition'; + +function mapStateToProps(state) { + return { + advancedSettings: state.settings.advancedSettings + }; +} + +const mapDispatchToProps = { + setQualityDefinitionValue, + clearPendingChanges +}; + +class QualityDefinitionConnector extends Component { + + componentWillUnmount() { + this.props.clearPendingChanges({ section: 'settings.qualityDefinitions' }); + } + + // + // Listeners + + onTitleChange = ({ value }) => { + this.props.setQualityDefinitionValue({ id: this.props.id, name: 'title', value }); + } + + onSizeChange = ({ minSize, maxSize }) => { + const { + id, + minSize: currentMinSize, + maxSize: currentMaxSize + } = this.props; + + if (minSize !== currentMinSize) { + this.props.setQualityDefinitionValue({ id, name: 'minSize', value: minSize }); + } + + if (minSize !== currentMaxSize) { + this.props.setQualityDefinitionValue({ id, name: 'maxSize', value: maxSize }); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +QualityDefinitionConnector.propTypes = { + id: PropTypes.number.isRequired, + minSize: PropTypes.number, + maxSize: PropTypes.number, + setQualityDefinitionValue: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(QualityDefinitionConnector); diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.css b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css new file mode 100644 index 000000000..689017684 --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css @@ -0,0 +1,41 @@ +.header { + display: flex; + font-weight: bold; +} + +.quality, +.title { + flex: 0 1 250px; +} + +.sizeLimit { + flex: 0 1 500px; +} + +.megabytesPerMinute { + flex: 0 0 250px; +} + +.sizeLimitHelpTextContainer { + display: flex; + justify-content: flex-end; + margin-top: 20px; + max-width: 1000px; +} + +.sizeLimitHelpText { + max-width: 500px; + color: $helpTextColor; +} + +@media only screen and (max-width: $breakpointSmall) { + .header { + display: none; + } + + .definitions { + &:first-child { + border-top: none; + } + } +} diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.js b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js new file mode 100644 index 000000000..18db844f8 --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FieldSet from 'Components/FieldSet'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import QualityDefinitionConnector from './QualityDefinitionConnector'; +import styles from './QualityDefinitions.css'; + +class QualityDefinitions extends Component { + + // + // Render + + render() { + const { + items, + ...otherProps + } = this.props; + + return ( +
+ +
+
Quality
+
Title
+
Size Limit
+
Megabytes Per Minute
+
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+ +
+
+ Limits are automatically adjusted for the series runtime and number of episodes in the file. +
+
+
+
+ ); + } +} + +QualityDefinitions.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + defaultProfile: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default QualityDefinitions; diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js new file mode 100644 index 000000000..9a3e0a90c --- /dev/null +++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js @@ -0,0 +1,90 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchQualityDefinitions, saveQualityDefinitions } from 'Store/Actions/settingsActions'; +import QualityDefinitions from './QualityDefinitions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.qualityDefinitions, + (qualityDefinitions) => { + const items = qualityDefinitions.items.map((item) => { + const pendingChanges = qualityDefinitions.pendingChanges[item.id] || {}; + + return Object.assign({}, item, pendingChanges); + }); + + return { + ...qualityDefinitions, + items, + hasPendingChanges: !_.isEmpty(qualityDefinitions.pendingChanges) + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchQualityDefinitions: fetchQualityDefinitions, + dispatchSaveQualityDefinitions: saveQualityDefinitions +}; + +class QualityDefinitionsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchQualityDefinitions(); + + const { + dispatchFetchQualityDefinitions, + dispatchSaveQualityDefinitions, + onChildMounted + } = this.props; + + dispatchFetchQualityDefinitions(); + onChildMounted(dispatchSaveQualityDefinitions); + } + + componentDidUpdate(prevProps) { + const { + hasPendingChanges, + isSaving, + onChildStateChange + } = this.props; + + if ( + prevProps.isSaving !== isSaving || + prevProps.hasPendingChanges !== hasPendingChanges + ) { + onChildStateChange({ + isSaving, + hasPendingChanges + }); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +QualityDefinitionsConnector.propTypes = { + isSaving: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool.isRequired, + dispatchFetchQualityDefinitions: PropTypes.func.isRequired, + dispatchSaveQualityDefinitions: PropTypes.func.isRequired, + onChildMounted: PropTypes.func.isRequired, + onChildStateChange: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps, null, { withRef: true })(QualityDefinitionsConnector); diff --git a/frontend/src/Settings/Quality/Quality.js b/frontend/src/Settings/Quality/Quality.js new file mode 100644 index 000000000..dfd6a24d7 --- /dev/null +++ b/frontend/src/Settings/Quality/Quality.js @@ -0,0 +1,68 @@ +import React, { Component } from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import QualityDefinitionsConnector from './Definition/QualityDefinitionsConnector'; + +class Quality extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._saveCallback = null; + + this.state = { + isSaving: false, + hasPendingChanges: false + }; + } + + // + // Listeners + + onChildMounted = (saveCallback) => { + this._saveCallback = saveCallback; + } + + onChildStateChange = (payload) => { + this.setState(payload); + } + + onSavePress = () => { + if (this._saveCallback) { + this._saveCallback(); + } + } + + // + // Render + + render() { + const { + isSaving, + hasPendingChanges + } = this.state; + + return ( + + + + + + + + ); + } +} + +export default Quality; diff --git a/frontend/src/Settings/Settings.css b/frontend/src/Settings/Settings.css new file mode 100644 index 000000000..497ef47c0 --- /dev/null +++ b/frontend/src/Settings/Settings.css @@ -0,0 +1,18 @@ +.link { + composes: link from 'Components/Link/Link.css'; + + border-bottom: 1px solid #e5e5e5; + color: #3a3f51; + font-size: 21px; + + &:hover { + color: #616573; + text-decoration: none; + } +} + +.summary { + margin-top: 10px; + margin-bottom: 30px; + color: $dimColor; +} diff --git a/frontend/src/Settings/Settings.js b/frontend/src/Settings/Settings.js new file mode 100644 index 000000000..5222ec415 --- /dev/null +++ b/frontend/src/Settings/Settings.js @@ -0,0 +1,133 @@ +import React from 'react'; +import Link from 'Components/Link/Link'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from './SettingsToolbarConnector'; +import styles from './Settings.css'; + +function Settings() { + return ( + + + + + + Media Management + + +
+ Naming and file management settings +
+ + + Profiles + + +
+ Quality, Language, Delay and Release profiles +
+ + + Quality + + +
+ Quality sizes and naming +
+ + + Indexers + + +
+ Indexers and indexer options +
+ + + Download Clients + + +
+ Download clients, download handling and remote path mappings +
+ + + Connect + + +
+ Notifications, connections to media servers/players and custom scripts +
+ + + Metadata + + +
+ Create metadata files when episodes are imported or series are refreshed +
+ + + Tags + + +
+ See all tags and how they are used. Unused tags can be removed +
+ + + General + + +
+ Port, SSL, username/password, proxy, analytics and updates +
+ + + UI + + +
+ Calendar, date and color impaired options +
+
+
+ ); +} + +Settings.propTypes = { +}; + +export default Settings; diff --git a/frontend/src/Settings/SettingsToolbar.js b/frontend/src/Settings/SettingsToolbar.js new file mode 100644 index 000000000..8b70857d9 --- /dev/null +++ b/frontend/src/Settings/SettingsToolbar.js @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PendingChangesModal from './PendingChangesModal'; +import AdvancedSettingsButton from './AdvancedSettingsButton'; + +class SettingsToolbar extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.bindShortcut(shortcuts.SAVE_SETTINGS.key, this.saveSettings, { isGlobal: true }); + } + + // + // Control + + saveSettings = (event) => { + event.preventDefault(); + + const { + hasPendingChanges, + onSavePress + } = this.props; + + if (hasPendingChanges) { + onSavePress(); + } + } + + // + // Render + + render() { + const { + advancedSettings, + showSave, + isSaving, + hasPendingChanges, + hasPendingLocation, + additionalButtons, + onSavePress, + onConfirmNavigation, + onCancelNavigation, + onAdvancedSettingsPress + } = this.props; + + return ( + + + + + { + showSave && + + } + + { + additionalButtons + } + + + + + ); + } +} + +SettingsToolbar.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + showSave: PropTypes.bool.isRequired, + isSaving: PropTypes.bool, + hasPendingLocation: PropTypes.bool.isRequired, + hasPendingChanges: PropTypes.bool, + additionalButtons: PropTypes.node, + onSavePress: PropTypes.func, + onAdvancedSettingsPress: PropTypes.func.isRequired, + onConfirmNavigation: PropTypes.func.isRequired, + onCancelNavigation: PropTypes.func.isRequired, + bindShortcut: PropTypes.func.isRequired +}; + +SettingsToolbar.defaultProps = { + showSave: true +}; + +export default keyboardShortcuts(SettingsToolbar); diff --git a/frontend/src/Settings/SettingsToolbarConnector.js b/frontend/src/Settings/SettingsToolbarConnector.js new file mode 100644 index 000000000..8bfb3dad5 --- /dev/null +++ b/frontend/src/Settings/SettingsToolbarConnector.js @@ -0,0 +1,147 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router-dom'; +import { toggleAdvancedSettings } from 'Store/Actions/settingsActions'; +import SettingsToolbar from './SettingsToolbar'; + +function mapStateToProps(state) { + return { + advancedSettings: state.settings.advancedSettings + }; +} + +const mapDispatchToProps = { + toggleAdvancedSettings +}; + +class SettingsToolbarConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + nextLocation: null, + nextLocationAction: null, + confirmed: false + }; + + this._unblock = null; + } + + componentDidMount() { + this._unblock = this.props.history.block(this.routerWillLeave); + } + + componentWillUnmount() { + if (this._unblock) { + this._unblock(); + } + } + + // + // Control + + routerWillLeave = (nextLocation, nextLocationAction) => { + if (this.state.confirmed) { + this.setState({ + nextLocation: null, + nextLocationAction: null, + confirmed: false + }); + + return true; + } + + if (this.props.hasPendingChanges ) { + this.setState({ + nextLocation, + nextLocationAction + }); + + return false; + } + + return true; + } + + // + // Listeners + + onAdvancedSettingsPress = () => { + this.props.toggleAdvancedSettings(); + } + + onConfirmNavigation = () => { + const { + nextLocation, + nextLocationAction + } = this.state; + + const history = this.props.history; + + const path = `${nextLocation.pathname}${nextLocation.search}`; + + this.setState({ + confirmed: true + }, () => { + if (nextLocationAction === 'PUSH') { + history.push(path); + } else { + // Unfortunately back and forward both use POP, + // which means we don't actually know which direction + // the user wanted to go, assuming back. + + history.goBack(); + } + }); + } + + onCancelNavigation = () => { + this.setState({ + nextLocation: null, + nextLocationAction: null, + confirmed: false + }); + } + + // + // Render + + render() { + const hasPendingLocation = this.state.nextLocation !== null; + + return ( + + ); + } +} + +const historyShape = { + block: PropTypes.func.isRequired, + goBack: PropTypes.func.isRequired, + push: PropTypes.func.isRequired +}; + +SettingsToolbarConnector.propTypes = { + hasPendingChanges: PropTypes.bool.isRequired, + history: PropTypes.shape(historyShape).isRequired, + onSavePress: PropTypes.func, + toggleAdvancedSettings: PropTypes.func.isRequired +}; + +SettingsToolbarConnector.defaultProps = { + hasPendingChanges: false +}; + +export default withRouter(connect(mapStateToProps, mapDispatchToProps)(SettingsToolbarConnector)); diff --git a/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js new file mode 100644 index 000000000..ab670359b --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsDelayProfile.js @@ -0,0 +1,47 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import titleCase from 'Utilities/String/titleCase'; + +function TagDetailsDelayProfile(props) { + const { + preferredProtocol, + enableUsenet, + enableTorrent, + usenetDelay, + torrentDelay + } = props; + + return ( +
+
+ Protocol: {titleCase(preferredProtocol)} +
+ +
+ { + enableUsenet ? + `Usenet Delay: ${usenetDelay}` : + 'Usenet disabled' + } +
+ +
+ { + enableTorrent ? + `Torrent Delay: ${torrentDelay}` : + 'Torrents disabled' + } +
+
+ ); +} + +TagDetailsDelayProfile.propTypes = { + preferredProtocol: PropTypes.string.isRequired, + enableUsenet: PropTypes.bool.isRequired, + enableTorrent: PropTypes.bool.isRequired, + usenetDelay: PropTypes.number.isRequired, + torrentDelay: PropTypes.number.isRequired +}; + +export default TagDetailsDelayProfile; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModal.js b/frontend/src/Settings/Tags/Details/TagDetailsModal.js new file mode 100644 index 000000000..0fe1ec5d3 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModal.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { sizes } from 'Helpers/Props'; +import Modal from 'Components/Modal/Modal'; +import TagDetailsModalContentConnector from './TagDetailsModalContentConnector'; + +function TagDetailsModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +TagDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default TagDetailsModal; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css new file mode 100644 index 000000000..3488b0509 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.css @@ -0,0 +1,26 @@ +.items { + display: flex; + flex-wrap: wrap; +} + +.item { + flex: 0 0 100%; +} + +.restriction { + margin-bottom: 5px; + padding-bottom: 5px; + border-bottom: 1px solid $borderColor; + + &:last-child { + margin: 0; + padding: 0; + border-bottom: none; + } +} + +.deleteButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js new file mode 100644 index 000000000..eadc9f468 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js @@ -0,0 +1,178 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import split from 'Utilities/String/split'; +import { kinds } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Button from 'Components/Link/Button'; +import Label from 'Components/Label'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import TagDetailsDelayProfile from './TagDetailsDelayProfile'; +import styles from './TagDetailsModalContent.css'; + +function TagDetailsModalContent(props) { + const { + label, + isTagUsed, + series, + delayProfiles, + notifications, + releaseProfiles, + onModalClose, + onDeleteTagPress + } = props; + + return ( + + + Tag Details - {label} + + + + { + !isTagUsed && +
Tag is not used and can be deleted
+ } + + { + !!series.length && +
+ { + series.map((item) => { + return ( +
+ {item.title} +
+ ); + }) + } +
+ } + + { + !!delayProfiles.length && +
+ { + delayProfiles.map((item) => { + const { + id, + preferredProtocol, + enableUsenet, + enableTorrent, + usenetDelay, + torrentDelay + } = item; + + return ( + + ); + }) + } +
+ } + + { + !!notifications.length && +
+ { + notifications.map((item) => { + return ( +
+ {item.name} +
+ ); + }) + } +
+ } + + { + !!releaseProfiles.length && +
+ { + releaseProfiles.map((item) => { + return ( +
+
+ { + split(item.required).map((r) => { + return ( + + ); + }) + } +
+ +
+ { + split(item.ignored).map((i) => { + return ( + + ); + }) + } +
+
+ ); + }) + } +
+ } +
+ + + { + + } + + + +
+ ); +} + +TagDetailsModalContent.propTypes = { + label: PropTypes.string.isRequired, + isTagUsed: PropTypes.bool.isRequired, + series: PropTypes.arrayOf(PropTypes.object).isRequired, + delayProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + notifications: PropTypes.arrayOf(PropTypes.object).isRequired, + releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteTagPress: PropTypes.func.isRequired +}; + +export default TagDetailsModalContent; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js new file mode 100644 index 000000000..5a6c9e229 --- /dev/null +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js @@ -0,0 +1,61 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import TagDetailsModalContent from './TagDetailsModalContent'; + +function findMatchingItems(ids, items) { + return items.filter((s) => { + return ids.includes(s.id); + }); +} + +function createMatchingSeriesSelector() { + return createSelector( + (state, { seriesIds }) => seriesIds, + createAllSeriesSelector(), + findMatchingItems + ); +} + +function createMatchingDelayProfilesSelector() { + return createSelector( + (state, { delayProfileIds }) => delayProfileIds, + (state) => state.settings.delayProfiles.items, + findMatchingItems + ); +} + +function createMatchingNotificationsSelector() { + return createSelector( + (state, { notificationIds }) => notificationIds, + (state) => state.settings.notifications.items, + findMatchingItems + ); +} + +function createMatchingReleaseProfilesSelector() { + return createSelector( + (state, { restrictionIds }) => restrictionIds, + (state) => state.settings.releaseProfiles.items, + findMatchingItems + ); +} + +function createMapStateToProps() { + return createSelector( + createMatchingSeriesSelector(), + createMatchingDelayProfilesSelector(), + createMatchingNotificationsSelector(), + createMatchingReleaseProfilesSelector(), + (series, delayProfiles, notifications, releaseProfiles) => { + return { + series, + delayProfiles, + notifications, + releaseProfiles + }; + } + ); +} + +export default connect(createMapStateToProps)(TagDetailsModalContent); diff --git a/frontend/src/Settings/Tags/Tag.css b/frontend/src/Settings/Tags/Tag.css new file mode 100644 index 000000000..ee425e309 --- /dev/null +++ b/frontend/src/Settings/Tags/Tag.css @@ -0,0 +1,11 @@ +.tag { + composes: card from 'Components/Card.css'; + + width: 150px; +} + +.label { + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js new file mode 100644 index 000000000..0cb9ee208 --- /dev/null +++ b/frontend/src/Settings/Tags/Tag.js @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TagDetailsModal from './Details/TagDetailsModal'; +import styles from './Tag.css'; + +class Tag extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false, + isDeleteTagModalOpen: false + }; + } + + // + // Listeners + + onShowDetailsPress = () => { + this.setState({ isDetailsModalOpen: true }); + } + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + onDeleteTagPress = () => { + this.setState({ + isDetailsModalOpen: false, + isDeleteTagModalOpen: true + }); + } + + onDeleteTagModalClose= () => { + this.setState({ isDeleteTagModalOpen: false }); + } + + onConfirmDeleteTag = () => { + this.props.onConfirmDeleteTag({ id: this.props.id }); + } + + // + // Render + + render() { + const { + label, + delayProfileIds, + notificationIds, + restrictionIds, + seriesIds + } = this.props; + + const { + isDetailsModalOpen, + isDeleteTagModalOpen + } = this.state; + + const isTagUsed = !!( + delayProfileIds.length || + notificationIds.length || + restrictionIds.length || + seriesIds.length + ); + + return ( + +
+ {label} +
+ + { + isTagUsed && +
+ { + !!seriesIds.length && +
+ {seriesIds.length} series +
+ } + + { + !!delayProfileIds.length && +
+ {delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'} +
+ } + + { + !!notificationIds.length && +
+ {notificationIds.length} connection{notificationIds.length > 1 && 's'} +
+ } + + { + !!restrictionIds.length && +
+ {restrictionIds.length} restriction{restrictionIds.length > 1 && 's'} +
+ } +
+ } + + { + !isTagUsed && +
+ No links +
+ } + + + + +
+ ); + } +} + +Tag.propTypes = { + id: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, + delayProfileIds: PropTypes.arrayOf(PropTypes.number).isRequired, + notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired, + restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired, + seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired, + onConfirmDeleteTag: PropTypes.func.isRequired +}; + +Tag.defaultProps = { + delayProfileIds: [], + notificationIds: [], + restrictionIds: [], + seriesIds: [] +}; + +export default Tag; diff --git a/frontend/src/Settings/Tags/TagConnector.js b/frontend/src/Settings/Tags/TagConnector.js new file mode 100644 index 000000000..50f610153 --- /dev/null +++ b/frontend/src/Settings/Tags/TagConnector.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTagDetailsSelector from 'Store/Selectors/createTagDetailsSelector'; +import { deleteTag } from 'Store/Actions/tagActions'; +import Tag from './Tag'; + +function createMapStateToProps() { + return createSelector( + createTagDetailsSelector(), + (tagDetails) => { + return { + ...tagDetails + }; + } + ); +} + +const mapStateToProps = { + onConfirmDeleteTag: deleteTag +}; + +export default connect(createMapStateToProps, mapStateToProps)(Tag); diff --git a/frontend/src/Settings/Tags/TagSettings.js b/frontend/src/Settings/Tags/TagSettings.js new file mode 100644 index 000000000..56ef92b49 --- /dev/null +++ b/frontend/src/Settings/Tags/TagSettings.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import TagsConnector from './TagsConnector'; + +function TagSettings() { + return ( + + + + + + + + ); +} + +export default TagSettings; diff --git a/frontend/src/Settings/Tags/Tags.css b/frontend/src/Settings/Tags/Tags.css new file mode 100644 index 000000000..5a44f8331 --- /dev/null +++ b/frontend/src/Settings/Tags/Tags.css @@ -0,0 +1,4 @@ +.tags { + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Tags/Tags.js b/frontend/src/Settings/Tags/Tags.js new file mode 100644 index 000000000..e1375ba76 --- /dev/null +++ b/frontend/src/Settings/Tags/Tags.js @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import TagConnector from './TagConnector'; +import styles from './Tags.css'; + +function Tags(props) { + const { + items, + ...otherProps + } = props; + + if (!items.length) { + return ( +
No tags have been added yet
+ ); + } + + return ( +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } +
+
+
+ ); +} + +Tags.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default Tags; diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js new file mode 100644 index 000000000..3439f6d0d --- /dev/null +++ b/frontend/src/Settings/Tags/TagsConnector.js @@ -0,0 +1,72 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchTagDetails } from 'Store/Actions/tagActions'; +import { fetchDelayProfiles, fetchNotifications, fetchReleaseProfiles } from 'Store/Actions/settingsActions'; +import Tags from './Tags'; + +function createMapStateToProps() { + return createSelector( + (state) => state.tags, + (tags) => { + const isFetching = tags.isFetching || tags.details.isFetching; + const error = tags.error || tags.details.error; + const isPopulated = tags.isPopulated && tags.details.isPopulated; + + return { + ...tags, + isFetching, + error, + isPopulated + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchTagDetails: fetchTagDetails, + dispatchFetchDelayProfiles: fetchDelayProfiles, + dispatchFetchNotifications: fetchNotifications, + dispatchFetchReleaseProfiles: fetchReleaseProfiles +}; + +class MetadatasConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + dispatchFetchTagDetails, + dispatchFetchDelayProfiles, + dispatchFetchNotifications, + dispatchFetchReleaseProfiles + } = this.props; + + dispatchFetchTagDetails(); + dispatchFetchDelayProfiles(); + dispatchFetchNotifications(); + dispatchFetchReleaseProfiles(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MetadatasConnector.propTypes = { + dispatchFetchTagDetails: PropTypes.func.isRequired, + dispatchFetchDelayProfiles: PropTypes.func.isRequired, + dispatchFetchNotifications: PropTypes.func.isRequired, + dispatchFetchReleaseProfiles: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector); diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js new file mode 100644 index 000000000..71356a5f0 --- /dev/null +++ b/frontend/src/Settings/UI/UISettings.js @@ -0,0 +1,195 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +export const firstDayOfWeekOptions = [ + { key: 0, value: 'Sunday' }, + { key: 1, value: 'Monday' } +]; + +export const weekColumnOptions = [ + { key: 'ddd M/D', value: 'Tue 3/25' }, + { key: 'ddd MM/DD', value: 'Tue 03/25' }, + { key: 'ddd D/M', value: 'Tue 25/03' }, + { key: 'ddd DD/MM', value: 'Tue 25/03' } +]; + +const shortDateFormatOptions = [ + { key: 'MMM D YYYY', value: 'Mar 25 2014' }, + { key: 'DD MMM YYYY', value: '25 Mar 2014' }, + { key: 'MM/D/YYYY', value: '03/25/2014' }, + { key: 'MM/DD/YYYY', value: '03/25/2014' }, + { key: 'DD/MM/YYYY', value: '25/03/2014' }, + { key: 'YYYY-MM-DD', value: '2014-03-25' } +]; + +const longDateFormatOptions = [ + { key: 'dddd, MMMM D YYYY', value: 'Tuesday, March 25, 2014' }, + { key: 'dddd, D MMMM YYYY', value: 'Tuesday, 25 March, 2014' } +]; + +export const timeFormatOptions = [ + { key: 'h(:mm)a', value: '5pm/5:30pm' }, + { key: 'HH:mm', value: '17:00/17:30' } +]; + +class UISettings extends Component { + + // + // Render + + render() { + const { + isFetching, + error, + settings, + hasSettings, + onInputChange, + onSavePress, + ...otherProps + } = this.props; + + return ( + + + + + { + isFetching && + + } + + { + !isFetching && error && +
Unable to load UI settings
+ } + + { + hasSettings && !isFetching && !error && +
+
+ + 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..24b55b6f0 --- /dev/null +++ b/frontend/src/Settings/UI/UISettingsConnector.js @@ -0,0 +1,77 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import { setUISettingsValue, saveUISettings, fetchUISettings } from 'Store/Actions/settingsActions'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import UISettings from './UISettings'; + +const SECTION = 'ui'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createSettingsSectionSelector(SECTION), + (advancedSettings, sectionSettings) => { + return { + advancedSettings, + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + setUISettingsValue, + saveUISettings, + fetchUISettings, + clearPendingChanges +}; + +class UISettingsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchUISettings(); + } + + componentWillUnmount() { + this.props.clearPendingChanges({ section: SECTION }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setUISettingsValue({ name, value }); + } + + onSavePress = () => { + this.props.saveUISettings(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UISettingsConnector.propTypes = { + setUISettingsValue: PropTypes.func.isRequired, + saveUISettings: PropTypes.func.isRequired, + fetchUISettings: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(UISettingsConnector); diff --git a/frontend/src/Shared/piwikCheck.js b/frontend/src/Shared/piwikCheck.js new file mode 100644 index 000000000..14c98c8ba --- /dev/null +++ b/frontend/src/Shared/piwikCheck.js @@ -0,0 +1,11 @@ +if (window.Sonarr.analytics) { + const d = document; + const g = d.createElement('script'); + const s = d.getElementsByTagName('script')[0]; + + g.type = 'text/javascript'; + g.async = true; + g.defer = true; + g.src = '//piwik.sonarr.tv/piwik.js'; + s.parentNode.insertBefore(g, s); +} diff --git a/frontend/src/Shims/jquery.js b/frontend/src/Shims/jquery.js new file mode 100644 index 000000000..d0234889c --- /dev/null +++ b/frontend/src/Shims/jquery.js @@ -0,0 +1,10 @@ +import $ from 'jquery'; +import ajax from 'jQuery/jquery.ajax'; + +ajax($); + +const jquery = $; +window.$ = $; +window.jQuery = $; + +export default jquery; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js new file mode 100644 index 000000000..2952973a9 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createClearReducer.js @@ -0,0 +1,12 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createClearReducer(section, defaultState) { + return (state) => { + const newState = Object.assign(getSectionState(state, section), defaultState); + + return updateSectionState(state, section, newState); + }; +} + +export default createClearReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js new file mode 100644 index 000000000..d58bb1cd4 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionFilterReducer.js @@ -0,0 +1,14 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetClientSideCollectionFilterReducer(section) { + return (state, { payload }) => { + const newState = getSectionState(state, section); + + newState.selectedFilterKey = payload.selectedFilterKey; + + return updateSectionState(state, section, newState); + }; +} + +export default createSetClientSideCollectionFilterReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js new file mode 100644 index 000000000..1bc048a80 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer.js @@ -0,0 +1,29 @@ +import { sortDirections } from 'Helpers/Props'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetClientSideCollectionSortReducer(section) { + return (state, { payload }) => { + const newState = getSectionState(state, section); + + const sortKey = payload.sortKey || newState.sortKey; + let sortDirection = payload.sortDirection; + + if (!sortDirection) { + if (payload.sortKey === newState.sortKey) { + sortDirection = newState.sortDirection === sortDirections.ASCENDING ? + sortDirections.DESCENDING : + sortDirections.ASCENDING; + } else { + sortDirection = newState.sortDirection; + } + } + + newState.sortKey = sortKey; + newState.sortDirection = sortDirection; + + return updateSectionState(state, section, newState); + }; +} + +export default createSetClientSideCollectionSortReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js new file mode 100644 index 000000000..3af58dd3b --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer.js @@ -0,0 +1,23 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetProviderFieldValueReducer(section) { + return (state, { payload }) => { + if (section === payload.section) { + const { name, value } = payload; + const newState = getSectionState(state, section); + newState.pendingChanges = Object.assign({}, newState.pendingChanges); + const fields = Object.assign({}, newState.pendingChanges.fields || {}); + + fields[name] = value; + + newState.pendingChanges.fields = fields; + + return updateSectionState(state, section, newState); + } + + return state; + }; +} + +export default createSetProviderFieldValueReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js new file mode 100644 index 000000000..474eb7bb2 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetSettingValueReducer.js @@ -0,0 +1,36 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function createSetSettingValueReducer(section) { + return (state, { payload }) => { + if (section === payload.section) { + const { name, value } = payload; + const newState = getSectionState(state, section); + newState.pendingChanges = Object.assign({}, newState.pendingChanges); + + const currentValue = newState.item ? newState.item[name] : null; + const pendingState = newState.pendingChanges; + + let parsedValue = null; + + if (_.isNumber(currentValue) && value != null) { + parsedValue = parseInt(value); + } else { + parsedValue = value; + } + + if (currentValue === parsedValue) { + delete pendingState[name]; + } else { + pendingState[name] = parsedValue; + } + + return updateSectionState(state, section, newState); + } + + return state; + }; +} + +export default createSetSettingValueReducer; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js new file mode 100644 index 000000000..70b57446d --- /dev/null +++ b/frontend/src/Store/Actions/Creators/Reducers/createSetTableOptionReducer.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +const whitelistedProperties = [ + 'pageSize', + 'columns', + 'tableOptions' +]; + +function createSetTableOptionReducer(section) { + return (state, { payload }) => { + const newState = Object.assign( + getSectionState(state, section), + _.pick(payload, whitelistedProperties)); + + return updateSectionState(state, section, newState); + }; +} + +export default createSetTableOptionReducer; diff --git a/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js new file mode 100644 index 000000000..87be89828 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js @@ -0,0 +1,42 @@ +import $ from 'jquery'; +import updateEpisodes from 'Utilities/Episode/updateEpisodes'; +import getSectionState from 'Utilities/State/getSectionState'; + +function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) { + return function(getState, payload, dispatch) { + const { + episodeIds, + monitored + } = payload; + + const state = getSectionState(getState(), section, true); + + dispatch(updateEpisodes(section, state.items, episodeIds, { + isSaving: true + })); + + const promise = $.ajax({ + url: '/episode/monitor', + method: 'PUT', + data: JSON.stringify({ episodeIds, monitored }), + dataType: 'json' + }); + + promise.done(() => { + dispatch(updateEpisodes(section, state.items, episodeIds, { + isSaving: false, + monitored + })); + + dispatch(fetchHandler()); + }); + + promise.fail(() => { + dispatch(updateEpisodes(section, state.items, episodeIds, { + isSaving: false + })); + }); + }; +} + +export default createBatchToggleEpisodeMonitoredHandler; diff --git a/frontend/src/Store/Actions/Creators/createFetchHandler.js b/frontend/src/Store/Actions/Creators/createFetchHandler.js new file mode 100644 index 000000000..c9cd058bd --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createFetchHandler.js @@ -0,0 +1,44 @@ +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, update, updateItem } from '../baseActions'; + +export default function createFetchHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const { + id, + ...otherPayload + } = payload; + + const { request, abortRequest } = createAjaxRequest({ + url: id == null ? url : `${url}/${id}`, + data: otherPayload, + traditional: true + }); + + request.done((data) => { + dispatch(batchActions([ + id == null ? update({ section, data }) : updateItem({ section, ...data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr.aborted ? null : xhr + })); + }); + + return abortRequest; + }; +} diff --git a/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js new file mode 100644 index 000000000..2c02c8c20 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js @@ -0,0 +1,33 @@ +import $ from 'jquery'; +import { set } from '../baseActions'; + +function createFetchSchemaHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isSchemaFetching: true })); + + const promise = $.ajax({ + url + }); + + promise.done((data) => { + dispatch(set({ + section, + isSchemaFetching: false, + isSchemaPopulated: true, + schemaError: null, + schema: data + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSchemaFetching: false, + isSchemaPopulated: true, + schemaError: xhr + })); + }); + }; +} + +export default createFetchSchemaHandler; diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js new file mode 100644 index 000000000..e7f1d4b04 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js @@ -0,0 +1,67 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; +import getSectionState from 'Utilities/State/getSectionState'; +import { set, updateServerSideCollection } from '../baseActions'; + +function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const sectionState = getSectionState(getState(), section, true); + const page = payload.page || sectionState.page || 1; + + const data = Object.assign({ page }, + _.pick(sectionState, [ + 'pageSize', + 'sortDirection', + 'sortKey' + ])); + + if (fetchDataAugmenter) { + fetchDataAugmenter(getState, payload, data); + } + + const { + selectedFilterKey, + filters, + customFilters + } = sectionState; + + const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters); + + selectedFilters.forEach((filter) => { + data[filter.key] = filter.value; + }); + + const promise = $.ajax({ + url, + data + }); + + promise.done((response) => { + dispatch(batchActions([ + updateServerSideCollection({ section, data: response }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }; +} + +export default createFetchServerSideCollectionHandler; diff --git a/frontend/src/Store/Actions/Creators/createHandleActions.js b/frontend/src/Store/Actions/Creators/createHandleActions.js new file mode 100644 index 000000000..c3315ce94 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createHandleActions.js @@ -0,0 +1,143 @@ +import _ from 'lodash'; +import { handleActions } from 'redux-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { + SET, + UPDATE, + UPDATE_ITEM, + UPDATE_SERVER_SIDE_COLLECTION, + CLEAR_PENDING_CHANGES, + REMOVE_ITEM +} from 'Store/Actions/baseActions'; + +const blacklistedProperties = [ + 'section', + 'id' +]; + +export default function createHandleActions(handlers, defaultState, section) { + return handleActions({ + + [SET]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = Object.assign(getSectionState(state, payloadSection), + _.omit(payload, blacklistedProperties)); + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [UPDATE]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + + if (_.isArray(payload.data)) { + newState.items = payload.data; + } else { + newState.item = payload.data; + } + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [UPDATE_ITEM]: function(state, { payload }) { + const { + section: payloadSection, + updateOnly = false, + ...otherProps + } = payload; + + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + const items = newState.items; + const index = _.findIndex(items, { id: payload.id }); + + newState.items = [...items]; + + // TODO: Move adding to it's own reducer + if (index >= 0) { + const item = items[index]; + + newState.items.splice(index, 1, { ...item, ...otherProps }); + } else if (!updateOnly) { + newState.items.push({ ...otherProps }); + } + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [CLEAR_PENDING_CHANGES]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + newState.pendingChanges = {}; + + if (newState.hasOwnProperty('saveError')) { + newState.saveError = null; + } + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [REMOVE_ITEM]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const newState = getSectionState(state, payloadSection); + + newState.items = [...newState.items]; + _.remove(newState.items, { id: payload.id }); + + return updateSectionState(state, payloadSection, newState); + } + + return state; + }, + + [UPDATE_SERVER_SIDE_COLLECTION]: function(state, { payload }) { + const payloadSection = payload.section; + const [baseSection] = payloadSection.split('.'); + + if (section === baseSection) { + const data = payload.data; + const newState = getSectionState(state, payloadSection); + + const serverState = _.omit(data, ['records']); + const calculatedState = { + totalPages: Math.max(Math.ceil(data.totalRecords / data.pageSize), 1), + items: data.records + }; + + return updateSectionState(state, payloadSection, Object.assign(newState, serverState, calculatedState)); + } + + return state; + }, + + ...handlers + + }, defaultState); +} diff --git a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js new file mode 100644 index 000000000..6f353ed17 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js @@ -0,0 +1,45 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import { set, removeItem } from '../baseActions'; + +function createRemoveItemHandler(section, url) { + return function(getState, payload, dispatch) { + const { + id, + ...queryParams + } = payload; + + dispatch(set({ section, isDeleting: true })); + + const ajaxOptions = { + url: `${url}/${id}?${$.param(queryParams, true)}`, + method: 'DELETE' + }; + + const promise = $.ajax(ajaxOptions); + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isDeleting: false, + deleteError: null + }), + + removeItem({ section, id }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + + return promise; + }; +} + +export default createRemoveItemHandler; diff --git a/frontend/src/Store/Actions/Creators/createSaveHandler.js b/frontend/src/Store/Actions/Creators/createSaveHandler.js new file mode 100644 index 000000000..e63c9f993 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSaveHandler.js @@ -0,0 +1,43 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import { set, update } from '../baseActions'; + +function createSaveHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isSaving: true })); + + const state = getSectionState(getState(), section, true); + const saveData = Object.assign({}, state.item, state.pendingChanges, payload); + + const promise = $.ajax({ + url, + method: 'PUT', + dataType: 'json', + data: JSON.stringify(saveData) + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isSaving: false, + saveError: null, + pendingChanges: {} + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }; +} + +export default createSaveHandler; diff --git a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js new file mode 100644 index 000000000..6d555bf23 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js @@ -0,0 +1,70 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getProviderState from 'Utilities/State/getProviderState'; +import { set, updateItem } from '../baseActions'; + +const abortCurrentRequests = {}; + +export function createCancelSaveProviderHandler(section) { + return function(getState, payload, dispatch) { + if (abortCurrentRequests[section]) { + abortCurrentRequests[section](); + abortCurrentRequests[section] = null; + } + }; +} + +function createSaveProviderHandler(section, url, options = {}) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isSaving: true })); + + const { + id, + queryParams = {}, + ...otherPayload + } = payload; + + const saveData = getProviderState({ id, ...otherPayload }, getState, section); + + const ajaxOptions = { + url: `${url}?${$.param(queryParams, true)}`, + method: 'POST', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify(saveData) + }; + + if (id) { + ajaxOptions.url = `${url}/${id}?${$.param(queryParams, true)}`; + ajaxOptions.method = 'PUT'; + } + + const { request, abortRequest } = createAjaxRequest(ajaxOptions); + + abortCurrentRequests[section] = abortRequest; + + request.done((data) => { + dispatch(batchActions([ + updateItem({ section, ...data }), + + set({ + section, + isSaving: false, + saveError: null, + pendingChanges: {} + }) + ])); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr.aborted ? null : xhr + })); + }); + }; +} + +export default createSaveProviderHandler; diff --git a/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js new file mode 100644 index 000000000..f81723769 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js @@ -0,0 +1,52 @@ +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import pages from 'Utilities/pages'; +import createFetchServerSideCollectionHandler from './createFetchServerSideCollectionHandler'; +import createSetServerSideCollectionPageHandler from './createSetServerSideCollectionPageHandler'; +import createSetServerSideCollectionSortHandler from './createSetServerSideCollectionSortHandler'; +import createSetServerSideCollectionFilterHandler from './createSetServerSideCollectionFilterHandler'; + +function createServerSideCollectionHandlers(section, url, fetchThunk, handlers, fetchDataAugmenter) { + const actionHandlers = {}; + const fetchHandlerType = handlers[serverSideCollectionHandlers.FETCH]; + const fetchHandler = createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter); + actionHandlers[fetchHandlerType] = fetchHandler; + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.FIRST_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.FIRST_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.FIRST, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.PREVIOUS_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.PREVIOUS_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.PREVIOUS, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.NEXT_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.NEXT_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.NEXT, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.LAST_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.LAST_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.LAST, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.EXACT_PAGE)) { + const handlerType = handlers[serverSideCollectionHandlers.EXACT_PAGE]; + actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.EXACT, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.SORT)) { + const handlerType = handlers[serverSideCollectionHandlers.SORT]; + actionHandlers[handlerType] = createSetServerSideCollectionSortHandler(section, fetchThunk); + } + + if (handlers.hasOwnProperty(serverSideCollectionHandlers.FILTER)) { + const handlerType = handlers[serverSideCollectionHandlers.FILTER]; + actionHandlers[handlerType] = createSetServerSideCollectionFilterHandler(section, fetchThunk); + } + + return actionHandlers; +} + +export default createServerSideCollectionHandlers; diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js new file mode 100644 index 000000000..d7e476444 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js @@ -0,0 +1,10 @@ +import { set } from '../baseActions'; + +function createSetServerSideCollectionFilterHandler(section, fetchHandler) { + return function(getState, payload, dispatch) { + dispatch(set({ section, ...payload })); + dispatch(fetchHandler({ page: 1 })); + }; +} + +export default createSetServerSideCollectionFilterHandler; diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js new file mode 100644 index 000000000..12b21bb0d --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js @@ -0,0 +1,35 @@ +import pages from 'Utilities/pages'; +import getSectionState from 'Utilities/State/getSectionState'; + +function createSetServerSideCollectionPageHandler(section, page, fetchHandler) { + return function(getState, payload, dispatch) { + const sectionState = getSectionState(getState(), section, true); + const currentPage = sectionState.page || 1; + let nextPage = 0; + + switch (page) { + case pages.FIRST: + nextPage = 1; + break; + case pages.PREVIOUS: + nextPage = currentPage - 1; + break; + case pages.NEXT: + nextPage = currentPage + 1; + break; + case pages.LAST: + nextPage = sectionState.totalPages; + break; + default: + nextPage = payload.page; + } + + // If we prefer to update the page immediately we should + // set the page and not pass a page to the fetch handler. + + // dispatch(set({ section, page: nextPage })); + dispatch(fetchHandler({ page: nextPage })); + }; +} + +export default createSetServerSideCollectionPageHandler; diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js new file mode 100644 index 000000000..fbd66e83e --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js @@ -0,0 +1,26 @@ +import getSectionState from 'Utilities/State/getSectionState'; +import { sortDirections } from 'Helpers/Props'; +import { set } from '../baseActions'; + +function createSetServerSideCollectionSortHandler(section, fetchHandler) { + return function(getState, payload, dispatch) { + const sectionState = getSectionState(getState(), section, true); + const sortKey = payload.sortKey || sectionState.sortKey; + let sortDirection = payload.sortDirection; + + if (!sortDirection) { + if (payload.sortKey === sectionState.sortKey) { + sortDirection = sectionState.sortDirection === sortDirections.ASCENDING ? + sortDirections.DESCENDING : + sortDirections.ASCENDING; + } else { + sortDirection = sectionState.sortDirection; + } + } + + dispatch(set({ section, sortKey, sortDirection })); + dispatch(fetchHandler()); + }; +} + +export default createSetServerSideCollectionSortHandler; diff --git a/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js new file mode 100644 index 000000000..77deaec64 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createTestAllProvidersHandler.js @@ -0,0 +1,34 @@ +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set } from '../baseActions'; + +function createTestAllProvidersHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isTestingAll: true })); + + const ajaxOptions = { + url: `${url}/testall`, + method: 'POST', + contentType: 'application/json', + dataType: 'json' + }; + + const { request } = createAjaxRequest(ajaxOptions); + + request.done((data) => { + dispatch(set({ + section, + isTestingAll: false, + saveError: null + })); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isTestingAll: false + })); + }); + }; +} + +export default createTestAllProvidersHandler; diff --git a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js new file mode 100644 index 000000000..ca26883fb --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js @@ -0,0 +1,52 @@ +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getProviderState from 'Utilities/State/getProviderState'; +import { set } from '../baseActions'; + +const abortCurrentRequests = {}; + +export function createCancelTestProviderHandler(section) { + return function(getState, payload, dispatch) { + if (abortCurrentRequests[section]) { + abortCurrentRequests[section](); + abortCurrentRequests[section] = null; + } + }; +} + +function createTestProviderHandler(section, url) { + return function(getState, payload, dispatch) { + dispatch(set({ section, isTesting: true })); + + const testData = getProviderState(payload, getState, section); + + const ajaxOptions = { + url: `${url}/test`, + method: 'POST', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify(testData) + }; + + const { request, abortRequest } = createAjaxRequest(ajaxOptions); + + abortCurrentRequests[section] = abortRequest; + + request.done((data) => { + dispatch(set({ + section, + isTesting: false, + saveError: null + })); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isTesting: false, + saveError: xhr.aborted ? null : xhr + })); + }); + }; +} + +export default createTestProviderHandler; diff --git a/frontend/src/Store/Actions/Settings/delayProfiles.js b/frontend/src/Store/Actions/Settings/delayProfiles.js new file mode 100644 index 000000000..68232e510 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/delayProfiles.js @@ -0,0 +1,103 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; +import { update } from 'Store/Actions/baseActions'; + +// +// Variables + +const section = 'settings.delayProfiles'; + +// +// Actions Types + +export const FETCH_DELAY_PROFILES = 'settings/delayProfiles/fetchDelayProfiles'; +export const FETCH_DELAY_PROFILE_SCHEMA = 'settings/delayProfiles/fetchDelayProfileSchema'; +export const SAVE_DELAY_PROFILE = 'settings/delayProfiles/saveDelayProfile'; +export const DELETE_DELAY_PROFILE = 'settings/delayProfiles/deleteDelayProfile'; +export const REORDER_DELAY_PROFILE = 'settings/delayProfiles/reorderDelayProfile'; +export const SET_DELAY_PROFILE_VALUE = 'settings/delayProfiles/setDelayProfileValue'; + +// +// Action Creators + +export const fetchDelayProfiles = createThunk(FETCH_DELAY_PROFILES); +export const fetchDelayProfileSchema = createThunk(FETCH_DELAY_PROFILE_SCHEMA); +export const saveDelayProfile = createThunk(SAVE_DELAY_PROFILE); +export const deleteDelayProfile = createThunk(DELETE_DELAY_PROFILE); +export const reorderDelayProfile = createThunk(REORDER_DELAY_PROFILE); + +export const setDelayProfileValue = createAction(SET_DELAY_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_DELAY_PROFILES]: createFetchHandler(section, '/delayprofile'), + [FETCH_DELAY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/delayprofile/schema'), + + [SAVE_DELAY_PROFILE]: createSaveProviderHandler(section, '/delayprofile'), + [DELETE_DELAY_PROFILE]: createRemoveItemHandler(section, '/delayprofile'), + + [REORDER_DELAY_PROFILE]: (getState, payload, dispatch) => { + const { id, moveIndex } = payload; + const moveOrder = moveIndex + 1; + const delayProfiles = getState().settings.delayProfiles.items; + const moving = _.find(delayProfiles, { id }); + + // Don't move if the order hasn't changed + if (moving.order === moveOrder) { + return; + } + + const after = moveIndex > 0 ? _.find(delayProfiles, { order: moveIndex }) : null; + const afterQueryParam = after ? `after=${after.id}` : ''; + + const promise = $.ajax({ + method: 'PUT', + url: `/delayprofile/reorder/${id}?${afterQueryParam}` + }); + + promise.done((data) => { + dispatch(update({ section, data })); + }); + } + }, + + // + // Reducers + + reducers: { + [SET_DELAY_PROFILE_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/downloadClientOptions.js b/frontend/src/Store/Actions/Settings/downloadClientOptions.js new file mode 100644 index 000000000..6d4a3954d --- /dev/null +++ b/frontend/src/Store/Actions/Settings/downloadClientOptions.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.downloadClientOptions'; + +// +// Actions Types + +export const FETCH_DOWNLOAD_CLIENT_OPTIONS = 'FETCH_DOWNLOAD_CLIENT_OPTIONS'; +export const SET_DOWNLOAD_CLIENT_OPTIONS_VALUE = 'SET_DOWNLOAD_CLIENT_OPTIONS_VALUE'; +export const SAVE_DOWNLOAD_CLIENT_OPTIONS = 'SAVE_DOWNLOAD_CLIENT_OPTIONS'; + +// +// Action Creators + +export const fetchDownloadClientOptions = createThunk(FETCH_DOWNLOAD_CLIENT_OPTIONS); +export const saveDownloadClientOptions = createThunk(SAVE_DOWNLOAD_CLIENT_OPTIONS); +export const setDownloadClientOptionsValue = createAction(SET_DOWNLOAD_CLIENT_OPTIONS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_DOWNLOAD_CLIENT_OPTIONS]: createFetchHandler(section, '/config/downloadclient'), + [SAVE_DOWNLOAD_CLIENT_OPTIONS]: createSaveHandler(section, '/config/downloadclient') + }, + + // + // Reducers + + reducers: { + [SET_DOWNLOAD_CLIENT_OPTIONS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js new file mode 100644 index 000000000..a268053f7 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -0,0 +1,117 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.downloadClients'; + +// +// Actions Types + +export const FETCH_DOWNLOAD_CLIENTS = 'settings/downloadClients/fetchDownloadClients'; +export const FETCH_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/fetchDownloadClientSchema'; +export const SELECT_DOWNLOAD_CLIENT_SCHEMA = 'settings/downloadClients/selectDownloadClientSchema'; +export const SET_DOWNLOAD_CLIENT_VALUE = 'settings/downloadClients/setDownloadClientValue'; +export const SET_DOWNLOAD_CLIENT_FIELD_VALUE = 'settings/downloadClients/setDownloadClientFieldValue'; +export const SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/saveDownloadClient'; +export const CANCEL_SAVE_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelSaveDownloadClient'; +export const DELETE_DOWNLOAD_CLIENT = 'settings/downloadClients/deleteDownloadClient'; +export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient'; +export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient'; +export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients'; + +// +// Action Creators + +export const fetchDownloadClients = createThunk(FETCH_DOWNLOAD_CLIENTS); +export const fetchDownloadClientSchema = createThunk(FETCH_DOWNLOAD_CLIENT_SCHEMA); +export const selectDownloadClientSchema = createAction(SELECT_DOWNLOAD_CLIENT_SCHEMA); + +export const saveDownloadClient = createThunk(SAVE_DOWNLOAD_CLIENT); +export const cancelSaveDownloadClient = createThunk(CANCEL_SAVE_DOWNLOAD_CLIENT); +export const deleteDownloadClient = createThunk(DELETE_DOWNLOAD_CLIENT); +export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT); +export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT); +export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS); + +export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setDownloadClientFieldValue = createAction(SET_DOWNLOAD_CLIENT_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + isTestingAll: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_DOWNLOAD_CLIENTS]: createFetchHandler(section, '/downloadclient'), + [FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler(section, '/downloadclient/schema'), + + [SAVE_DOWNLOAD_CLIENT]: createSaveProviderHandler(section, '/downloadclient'), + [CANCEL_SAVE_DOWNLOAD_CLIENT]: createCancelSaveProviderHandler(section), + [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'), + [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'), + [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), + [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient') + }, + + // + // Reducers + + reducers: { + [SET_DOWNLOAD_CLIENT_VALUE]: createSetSettingValueReducer(section), + [SET_DOWNLOAD_CLIENT_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_DOWNLOAD_CLIENT_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.enable = true; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/general.js b/frontend/src/Store/Actions/Settings/general.js new file mode 100644 index 000000000..f5e8c277e --- /dev/null +++ b/frontend/src/Store/Actions/Settings/general.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.general'; + +// +// Actions Types + +export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings'; +export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue'; +export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings'; + +// +// Action Creators + +export const fetchGeneralSettings = createThunk(FETCH_GENERAL_SETTINGS); +export const saveGeneralSettings = createThunk(SAVE_GENERAL_SETTINGS); +export const setGeneralSettingsValue = createAction(SET_GENERAL_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_GENERAL_SETTINGS]: createFetchHandler(section, '/config/host'), + [SAVE_GENERAL_SETTINGS]: createSaveHandler(section, '/config/host') + }, + + // + // Reducers + + reducers: { + [SET_GENERAL_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/indexerOptions.js b/frontend/src/Store/Actions/Settings/indexerOptions.js new file mode 100644 index 000000000..53fb21651 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/indexerOptions.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.indexerOptions'; + +// +// Actions Types + +export const FETCH_INDEXER_OPTIONS = 'settings/indexerOptions/fetchIndexerOptions'; +export const SAVE_INDEXER_OPTIONS = 'settings/indexerOptions/saveIndexerOptions'; +export const SET_INDEXER_OPTIONS_VALUE = 'settings/indexerOptions/setIndexerOptionsValue'; + +// +// Action Creators + +export const fetchIndexerOptions = createThunk(FETCH_INDEXER_OPTIONS); +export const saveIndexerOptions = createThunk(SAVE_INDEXER_OPTIONS); +export const setIndexerOptionsValue = createAction(SET_INDEXER_OPTIONS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_INDEXER_OPTIONS]: createFetchHandler(section, '/config/indexer'), + [SAVE_INDEXER_OPTIONS]: createSaveHandler(section, '/config/indexer') + }, + + // + // Reducers + + reducers: { + [SET_INDEXER_OPTIONS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js new file mode 100644 index 000000000..ddab7c154 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/indexers.js @@ -0,0 +1,119 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.indexers'; + +// +// Actions Types + +export const FETCH_INDEXERS = 'settings/indexers/fetchIndexers'; +export const FETCH_INDEXER_SCHEMA = 'settings/indexers/fetchIndexerSchema'; +export const SELECT_INDEXER_SCHEMA = 'settings/indexers/selectIndexerSchema'; +export const SET_INDEXER_VALUE = 'settings/indexers/setIndexerValue'; +export const SET_INDEXER_FIELD_VALUE = 'settings/indexers/setIndexerFieldValue'; +export const SAVE_INDEXER = 'settings/indexers/saveIndexer'; +export const CANCEL_SAVE_INDEXER = 'settings/indexers/cancelSaveIndexer'; +export const DELETE_INDEXER = 'settings/indexers/deleteIndexer'; +export const TEST_INDEXER = 'settings/indexers/testIndexer'; +export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; +export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; + +// +// Action Creators + +export const fetchIndexers = createThunk(FETCH_INDEXERS); +export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA); +export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA); + +export const saveIndexer = createThunk(SAVE_INDEXER); +export const cancelSaveIndexer = createThunk(CANCEL_SAVE_INDEXER); +export const deleteIndexer = createThunk(DELETE_INDEXER); +export const testIndexer = createThunk(TEST_INDEXER); +export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); +export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); + +export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setIndexerFieldValue = createAction(SET_INDEXER_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + isTestingAll: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_INDEXERS]: createFetchHandler(section, '/indexer'), + [FETCH_INDEXER_SCHEMA]: createFetchSchemaHandler(section, '/indexer/schema'), + + [SAVE_INDEXER]: createSaveProviderHandler(section, '/indexer'), + [CANCEL_SAVE_INDEXER]: createCancelSaveProviderHandler(section), + [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), + [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), + [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), + [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer') + }, + + // + // Reducers + + reducers: { + [SET_INDEXER_VALUE]: createSetSettingValueReducer(section), + [SET_INDEXER_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_INDEXER_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.enableRss = selectedSchema.supportsRss; + selectedSchema.enableAutomaticSearch = selectedSchema.supportsSearch; + selectedSchema.enableInteractiveSearch = selectedSchema.supportsSearch; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/languageProfiles.js b/frontend/src/Store/Actions/Settings/languageProfiles.js new file mode 100644 index 000000000..49fe9825b --- /dev/null +++ b/frontend/src/Store/Actions/Settings/languageProfiles.js @@ -0,0 +1,97 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.languageProfiles'; + +// +// Actions Types + +export const FETCH_LANGUAGE_PROFILES = 'settings/languageProfiles/fetchLanguageProfiles'; +export const FETCH_LANGUAGE_PROFILE_SCHEMA = 'settings/languageProfiles/fetchLanguageProfileSchema'; +export const SAVE_LANGUAGE_PROFILE = 'settings/languageProfiles/saveLanguageProfile'; +export const DELETE_LANGUAGE_PROFILE = 'settings/languageProfiles/deleteLanguageProfile'; +export const SET_LANGUAGE_PROFILE_VALUE = 'settings/languageProfiles/setLanguageProfileValue'; +export const CLONE_LANGUAGE_PROFILE = 'settings/languageProfiles/cloneLanguageProfile'; + +// +// Action Creators + +export const fetchLanguageProfiles = createThunk(FETCH_LANGUAGE_PROFILES); +export const fetchLanguageProfileSchema = createThunk(FETCH_LANGUAGE_PROFILE_SCHEMA); +export const saveLanguageProfile = createThunk(SAVE_LANGUAGE_PROFILE); +export const deleteLanguageProfile = createThunk(DELETE_LANGUAGE_PROFILE); + +export const setLanguageProfileValue = createAction(SET_LANGUAGE_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const cloneLanguageProfile = createAction(CLONE_LANGUAGE_PROFILE); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isDeleting: false, + deleteError: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: {}, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_LANGUAGE_PROFILES]: createFetchHandler(section, '/languageprofile'), + [FETCH_LANGUAGE_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/languageprofile/schema'), + [SAVE_LANGUAGE_PROFILE]: createSaveProviderHandler(section, '/languageprofile'), + [DELETE_LANGUAGE_PROFILE]: createRemoveItemHandler(section, '/languageprofile') + }, + + // + // Reducers + + reducers: { + [SET_LANGUAGE_PROFILE_VALUE]: createSetSettingValueReducer(section), + + [CLONE_LANGUAGE_PROFILE]: function(state, { payload }) { + const id = payload.id; + const newState = getSectionState(state, section); + const item = newState.items.find((i) => i.id === id); + const pendingChanges = { ...item, id: 0 }; + delete pendingChanges.id; + + pendingChanges.name = `${pendingChanges.name} - Copy`; + newState.pendingChanges = pendingChanges; + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/mediaManagement.js b/frontend/src/Store/Actions/Settings/mediaManagement.js new file mode 100644 index 000000000..4ae9eba0c --- /dev/null +++ b/frontend/src/Store/Actions/Settings/mediaManagement.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.mediaManagement'; + +// +// Actions Types + +export const FETCH_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/fetchMediaManagementSettings'; +export const SAVE_MEDIA_MANAGEMENT_SETTINGS = 'settings/mediaManagement/saveMediaManagementSettings'; +export const SET_MEDIA_MANAGEMENT_SETTINGS_VALUE = 'settings/mediaManagement/setMediaManagementSettingsValue'; + +// +// Action Creators + +export const fetchMediaManagementSettings = createThunk(FETCH_MEDIA_MANAGEMENT_SETTINGS); +export const saveMediaManagementSettings = createThunk(SAVE_MEDIA_MANAGEMENT_SETTINGS); +export const setMediaManagementSettingsValue = createAction(SET_MEDIA_MANAGEMENT_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_MEDIA_MANAGEMENT_SETTINGS]: createFetchHandler(section, '/config/mediamanagement'), + [SAVE_MEDIA_MANAGEMENT_SETTINGS]: createSaveHandler(section, '/config/mediamanagement') + }, + + // + // Reducers + + reducers: { + [SET_MEDIA_MANAGEMENT_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/metadata.js b/frontend/src/Store/Actions/Settings/metadata.js new file mode 100644 index 000000000..ed5e0aa86 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/metadata.js @@ -0,0 +1,75 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; + +// +// Variables + +const section = 'settings.metadata'; + +// +// Actions Types + +export const FETCH_METADATA = 'settings/metadata/fetchMetadata'; +export const SET_METADATA_VALUE = 'settings/metadata/setMetadataValue'; +export const SET_METADATA_FIELD_VALUE = 'settings/metadata/setMetadataFieldValue'; +export const SAVE_METADATA = 'settings/metadata/saveMetadata'; + +// +// Action Creators + +export const fetchMetadata = createThunk(FETCH_METADATA); +export const saveMetadata = createThunk(SAVE_METADATA); + +export const setMetadataValue = createAction(SET_METADATA_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setMetadataFieldValue = createAction(SET_METADATA_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_METADATA]: createFetchHandler(section, '/metadata'), + [SAVE_METADATA]: createSaveProviderHandler(section, '/metadata') + }, + + // + // Reducers + + reducers: { + [SET_METADATA_VALUE]: createSetSettingValueReducer(section), + [SET_METADATA_FIELD_VALUE]: createSetProviderFieldValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/naming.js b/frontend/src/Store/Actions/Settings/naming.js new file mode 100644 index 000000000..27add8309 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/naming.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.naming'; + +// +// Actions Types + +export const FETCH_NAMING_SETTINGS = 'settings/naming/fetchNamingSettings'; +export const SAVE_NAMING_SETTINGS = 'settings/naming/saveNamingSettings'; +export const SET_NAMING_SETTINGS_VALUE = 'settings/naming/setNamingSettingsValue'; + +// +// Action Creators + +export const fetchNamingSettings = createThunk(FETCH_NAMING_SETTINGS); +export const saveNamingSettings = createThunk(SAVE_NAMING_SETTINGS); +export const setNamingSettingsValue = createAction(SET_NAMING_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NAMING_SETTINGS]: createFetchHandler(section, '/config/naming'), + [SAVE_NAMING_SETTINGS]: createSaveHandler(section, '/config/naming') + }, + + // + // Reducers + + reducers: { + [SET_NAMING_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/namingExamples.js b/frontend/src/Store/Actions/Settings/namingExamples.js new file mode 100644 index 000000000..e3f2ae01c --- /dev/null +++ b/frontend/src/Store/Actions/Settings/namingExamples.js @@ -0,0 +1,79 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk } from 'Store/thunks'; +import { set, update } from 'Store/Actions/baseActions'; + +// +// Variables + +const section = 'settings.namingExamples'; + +// +// Actions Types + +export const FETCH_NAMING_EXAMPLES = 'settings/namingExamples/fetchNamingExamples'; + +// +// Action Creators + +export const fetchNamingExamples = createThunk(FETCH_NAMING_EXAMPLES); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NAMING_EXAMPLES]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const naming = getState().settings.naming; + + const promise = $.ajax({ + url: '/config/naming/examples', + data: Object.assign({}, naming.item, naming.pendingChanges) + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } + }, + + // + // Reducers + + reducers: {} + +}; diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js new file mode 100644 index 000000000..b2c28dac9 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/notifications.js @@ -0,0 +1,115 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.notifications'; + +// +// Actions Types + +export const FETCH_NOTIFICATIONS = 'settings/notifications/fetchNotifications'; +export const FETCH_NOTIFICATION_SCHEMA = 'settings/notifications/fetchNotificationSchema'; +export const SELECT_NOTIFICATION_SCHEMA = 'settings/notifications/selectNotificationSchema'; +export const SET_NOTIFICATION_VALUE = 'settings/notifications/setNotificationValue'; +export const SET_NOTIFICATION_FIELD_VALUE = 'settings/notifications/setNotificationFieldValue'; +export const SAVE_NOTIFICATION = 'settings/notifications/saveNotification'; +export const CANCEL_SAVE_NOTIFICATION = 'settings/notifications/cancelSaveNotification'; +export const DELETE_NOTIFICATION = 'settings/notifications/deleteNotification'; +export const TEST_NOTIFICATION = 'settings/notifications/testNotification'; +export const CANCEL_TEST_NOTIFICATION = 'settings/notifications/cancelTestNotification'; + +// +// Action Creators + +export const fetchNotifications = createThunk(FETCH_NOTIFICATIONS); +export const fetchNotificationSchema = createThunk(FETCH_NOTIFICATION_SCHEMA); +export const selectNotificationSchema = createAction(SELECT_NOTIFICATION_SCHEMA); + +export const saveNotification = createThunk(SAVE_NOTIFICATION); +export const cancelSaveNotification = createThunk(CANCEL_SAVE_NOTIFICATION); +export const deleteNotification = createThunk(DELETE_NOTIFICATION); +export const testNotification = createThunk(TEST_NOTIFICATION); +export const cancelTestNotification = createThunk(CANCEL_TEST_NOTIFICATION); + +export const setNotificationValue = createAction(SET_NOTIFICATION_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setNotificationFieldValue = createAction(SET_NOTIFICATION_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_NOTIFICATIONS]: createFetchHandler(section, '/notification'), + [FETCH_NOTIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/notification/schema'), + + [SAVE_NOTIFICATION]: createSaveProviderHandler(section, '/notification'), + [CANCEL_SAVE_NOTIFICATION]: createCancelSaveProviderHandler(section), + [DELETE_NOTIFICATION]: createRemoveItemHandler(section, '/notification'), + [TEST_NOTIFICATION]: createTestProviderHandler(section, '/notification'), + [CANCEL_TEST_NOTIFICATION]: createCancelTestProviderHandler(section) + }, + + // + // Reducers + + reducers: { + [SET_NOTIFICATION_VALUE]: createSetSettingValueReducer(section), + [SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_NOTIFICATION_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.onGrab = selectedSchema.supportsOnGrab; + selectedSchema.onDownload = selectedSchema.supportsOnDownload; + selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade; + selectedSchema.onRename = selectedSchema.supportsOnRename; + + return selectedSchema; + }); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/qualityDefinitions.js b/frontend/src/Store/Actions/Settings/qualityDefinitions.js new file mode 100644 index 000000000..fb6572f45 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/qualityDefinitions.js @@ -0,0 +1,135 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk } from 'Store/thunks'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; +import { clearPendingChanges, set, update } from 'Store/Actions/baseActions'; + +// +// Variables + +const section = 'settings.qualityDefinitions'; + +// +// Actions Types + +export const FETCH_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/fetchQualityDefinitions'; +export const SAVE_QUALITY_DEFINITIONS = 'settings/qualityDefinitions/saveQualityDefinitions'; +export const SET_QUALITY_DEFINITION_VALUE = 'settings/qualityDefinitions/setQualityDefinitionValue'; + +// +// Action Creators + +export const fetchQualityDefinitions = createThunk(FETCH_QUALITY_DEFINITIONS); +export const saveQualityDefinitions = createThunk(SAVE_QUALITY_DEFINITIONS); + +export const setQualityDefinitionValue = createAction(SET_QUALITY_DEFINITION_VALUE); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_QUALITY_DEFINITIONS]: createFetchHandler(section, '/qualitydefinition'), + [SAVE_QUALITY_DEFINITIONS]: createSaveHandler(section, '/qualitydefinition'), + + [SAVE_QUALITY_DEFINITIONS]: function(getState, payload, dispatch) { + const qualityDefinitions = getState().settings.qualityDefinitions; + + const upatedDefinitions = Object.keys(qualityDefinitions.pendingChanges).map((key) => { + const id = parseInt(key); + const pendingChanges = qualityDefinitions.pendingChanges[id] || {}; + const item = _.find(qualityDefinitions.items, { id }); + + return Object.assign({}, item, pendingChanges); + }); + + // If there is nothing to save don't bother isSaving + if (!upatedDefinitions || !upatedDefinitions.length) { + return; + } + + dispatch(set({ + section, + isSaving: true + })); + + const promise = $.ajax({ + method: 'PUT', + url: '/qualityDefinition/update', + data: JSON.stringify(upatedDefinitions) + }); + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isSaving: false, + saveError: null + }), + + update({ section, data }), + clearPendingChanges({ section }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } + }, + + // + // Reducers + + reducers: { + [SET_QUALITY_DEFINITION_VALUE]: function(state, { payload }) { + const { id, name, value } = payload; + const newState = getSectionState(state, section); + newState.pendingChanges = _.cloneDeep(newState.pendingChanges); + + const pendingState = newState.pendingChanges[id] || {}; + const currentValue = _.find(newState.items, { id })[name]; + + if (currentValue === value) { + delete pendingState[name]; + } else { + pendingState[name] = value; + } + + if (_.isEmpty(pendingState)) { + delete newState.pendingChanges[id]; + } else { + newState.pendingChanges[id] = pendingState; + } + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/qualityProfiles.js b/frontend/src/Store/Actions/Settings/qualityProfiles.js new file mode 100644 index 000000000..6fdc204a0 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/qualityProfiles.js @@ -0,0 +1,97 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.qualityProfiles'; + +// +// Actions Types + +export const FETCH_QUALITY_PROFILES = 'settings/qualityProfiles/fetchQualityProfiles'; +export const FETCH_QUALITY_PROFILE_SCHEMA = 'settings/qualityProfiles/fetchQualityProfileSchema'; +export const SAVE_QUALITY_PROFILE = 'settings/qualityProfiles/saveQualityProfile'; +export const DELETE_QUALITY_PROFILE = 'settings/qualityProfiles/deleteQualityProfile'; +export const SET_QUALITY_PROFILE_VALUE = 'settings/qualityProfiles/setQualityProfileValue'; +export const CLONE_QUALITY_PROFILE = 'settings/qualityProfiles/cloneQualityProfile'; + +// +// Action Creators + +export const fetchQualityProfiles = createThunk(FETCH_QUALITY_PROFILES); +export const fetchQualityProfileSchema = createThunk(FETCH_QUALITY_PROFILE_SCHEMA); +export const saveQualityProfile = createThunk(SAVE_QUALITY_PROFILE); +export const deleteQualityProfile = createThunk(DELETE_QUALITY_PROFILE); + +export const setQualityProfileValue = createAction(SET_QUALITY_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const cloneQualityProfile = createAction(CLONE_QUALITY_PROFILE); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isDeleting: false, + deleteError: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: {}, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_QUALITY_PROFILES]: createFetchHandler(section, '/qualityprofile'), + [FETCH_QUALITY_PROFILE_SCHEMA]: createFetchSchemaHandler(section, '/qualityprofile/schema'), + [SAVE_QUALITY_PROFILE]: createSaveProviderHandler(section, '/qualityprofile'), + [DELETE_QUALITY_PROFILE]: createRemoveItemHandler(section, '/qualityprofile') + }, + + // + // Reducers + + reducers: { + [SET_QUALITY_PROFILE_VALUE]: createSetSettingValueReducer(section), + + [CLONE_QUALITY_PROFILE]: function(state, { payload }) { + const id = payload.id; + const newState = getSectionState(state, section); + const item = newState.items.find((i) => i.id === id); + const pendingChanges = { ...item, id: 0 }; + delete pendingChanges.id; + + pendingChanges.name = `${pendingChanges.name} - Copy`; + newState.pendingChanges = pendingChanges; + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/releaseProfiles.js b/frontend/src/Store/Actions/Settings/releaseProfiles.js new file mode 100644 index 000000000..339e732f6 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/releaseProfiles.js @@ -0,0 +1,71 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.releaseProfiles'; + +// +// Actions Types + +export const FETCH_RELEASE_PROFILES = 'settings/releaseProfiles/fetchReleaseProfiles'; +export const SAVE_RELEASE_PROFILE = 'settings/releaseProfiles/saveReleaseProfile'; +export const DELETE_RELEASE_PROFILE = 'settings/releaseProfiles/deleteReleaseProfile'; +export const SET_RELEASE_PROFILE_VALUE = 'settings/releaseProfiles/setReleaseProfileValue'; + +// +// Action Creators + +export const fetchReleaseProfiles = createThunk(FETCH_RELEASE_PROFILES); +export const saveReleaseProfile = createThunk(SAVE_RELEASE_PROFILE); +export const deleteReleaseProfile = createThunk(DELETE_RELEASE_PROFILE); + +export const setReleaseProfileValue = createAction(SET_RELEASE_PROFILE_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_RELEASE_PROFILES]: createFetchHandler(section, '/releaseprofile'), + + [SAVE_RELEASE_PROFILE]: createSaveProviderHandler(section, '/releaseprofile'), + + [DELETE_RELEASE_PROFILE]: createRemoveItemHandler(section, '/releaseprofile') + }, + + // + // Reducers + + reducers: { + [SET_RELEASE_PROFILE_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/remotePathMappings.js b/frontend/src/Store/Actions/Settings/remotePathMappings.js new file mode 100644 index 000000000..3cfcc7f1f --- /dev/null +++ b/frontend/src/Store/Actions/Settings/remotePathMappings.js @@ -0,0 +1,69 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; + +// +// Variables + +const section = 'settings.remotePathMappings'; + +// +// Actions Types + +export const FETCH_REMOTE_PATH_MAPPINGS = 'settings/remotePathMappings/fetchRemotePathMappings'; +export const SAVE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/saveRemotePathMapping'; +export const DELETE_REMOTE_PATH_MAPPING = 'settings/remotePathMappings/deleteRemotePathMapping'; +export const SET_REMOTE_PATH_MAPPING_VALUE = 'settings/remotePathMappings/setRemotePathMappingValue'; + +// +// Action Creators + +export const fetchRemotePathMappings = createThunk(FETCH_REMOTE_PATH_MAPPINGS); +export const saveRemotePathMapping = createThunk(SAVE_REMOTE_PATH_MAPPING); +export const deleteRemotePathMapping = createThunk(DELETE_REMOTE_PATH_MAPPING); + +export const setRemotePathMappingValue = createAction(SET_REMOTE_PATH_MAPPING_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_REMOTE_PATH_MAPPINGS]: createFetchHandler(section, '/remotepathmapping'), + [SAVE_REMOTE_PATH_MAPPING]: createSaveProviderHandler(section, '/remotepathmapping'), + [DELETE_REMOTE_PATH_MAPPING]: createRemoveItemHandler(section, '/remotepathmapping') + }, + + // + // Reducers + + reducers: { + [SET_REMOTE_PATH_MAPPING_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/Settings/ui.js b/frontend/src/Store/Actions/Settings/ui.js new file mode 100644 index 000000000..97d7223fd --- /dev/null +++ b/frontend/src/Store/Actions/Settings/ui.js @@ -0,0 +1,64 @@ +import { createAction } from 'redux-actions'; +import { createThunk } from 'Store/thunks'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createSaveHandler from 'Store/Actions/Creators/createSaveHandler'; + +// +// Variables + +const section = 'settings.ui'; + +// +// Actions Types + +export const FETCH_UI_SETTINGS = 'settings/ui/fetchUiSettings'; +export const SET_UI_SETTINGS_VALUE = 'SET_UI_SETTINGS_VALUE'; +export const SAVE_UI_SETTINGS = 'SAVE_UI_SETTINGS'; + +// +// Action Creators + +export const fetchUISettings = createThunk(FETCH_UI_SETTINGS); +export const saveUISettings = createThunk(SAVE_UI_SETTINGS); +export const setUISettingsValue = createAction(SET_UI_SETTINGS_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + pendingChanges: {}, + isSaving: false, + saveError: null, + item: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_UI_SETTINGS]: createFetchHandler(section, '/config/ui'), + [SAVE_UI_SETTINGS]: createSaveHandler(section, '/config/ui') + }, + + // + // Reducers + + reducers: { + [SET_UI_SETTINGS_VALUE]: createSetSettingValueReducer(section) + } + +}; diff --git a/frontend/src/Store/Actions/actionTypes.js b/frontend/src/Store/Actions/actionTypes.js new file mode 100644 index 000000000..a0c784a7d --- /dev/null +++ b/frontend/src/Store/Actions/actionTypes.js @@ -0,0 +1,21 @@ +// +// App + +export const SHOW_MESSAGE = 'SHOW_MESSAGE'; +export const HIDE_MESSAGE = 'HIDE_MESSAGE'; +export const SAVE_DIMENSIONS = 'SAVE_DIMENSIONS'; +export const SET_VERSION = 'SET_VERSION'; +export const SET_APP_VALUE = 'SET_APP_VALUE'; +export const SET_IS_SIDEBAR_VISIBLE = 'SET_IS_SIDEBAR_VISIBLE'; + +// +// Settings + +export const FETCH_GENERAL_SETTINGS = 'settings/general/fetchGeneralSettings'; +export const SET_GENERAL_SETTINGS_VALUE = 'settings/general/setGeneralSettingsValue'; +export const SAVE_GENERAL_SETTINGS = 'settings/general/saveGeneralSettings'; + +// +// Languages + +export const FETCH_LANGUAGES = 'FETCH_LANGUAGES'; diff --git a/frontend/src/Store/Actions/addSeriesActions.js b/frontend/src/Store/Actions/addSeriesActions.js new file mode 100644 index 000000000..469061820 --- /dev/null +++ b/frontend/src/Store/Actions/addSeriesActions.js @@ -0,0 +1,181 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import monitorOptions from 'Utilities/Series/monitorOptions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getNewSeries from 'Utilities/Series/getNewSeries'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'addSeries'; +let abortCurrentRequest = null; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isAdding: false, + isAdded: false, + addError: null, + items: [], + + defaults: { + rootFolderPath: '', + monitor: monitorOptions[0].key, + qualityProfileId: 0, + languageProfileId: 0, + seriesType: 'standard', + seasonFolder: true, + tags: [] + } +}; + +export const persistState = [ + 'addSeries.defaults' +]; + +// +// Actions Types + +export const LOOKUP_SERIES = 'addSeries/lookupSeries'; +export const ADD_SERIES = 'addSeries/addSeries'; +export const SET_ADD_SERIES_VALUE = 'addSeries/setAddSeriesValue'; +export const CLEAR_ADD_SERIES = 'addSeries/clearAddSeries'; +export const SET_ADD_SERIES_DEFAULT = 'addSeries/setAddSeriesDefault'; + +// +// Action Creators + +export const lookupSeries = createThunk(LOOKUP_SERIES); +export const addSeries = createThunk(ADD_SERIES); +export const clearAddSeries = createAction(CLEAR_ADD_SERIES); +export const setAddSeriesDefault = createAction(SET_ADD_SERIES_DEFAULT); + +export const setAddSeriesValue = createAction(SET_ADD_SERIES_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [LOOKUP_SERIES]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + if (abortCurrentRequest) { + abortCurrentRequest(); + } + + const { request, abortRequest } = createAjaxRequest({ + url: '/series/lookup', + data: { + term: payload.term + } + }); + + abortCurrentRequest = abortRequest; + + request.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + request.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr.aborted ? null : xhr + })); + }); + }, + + [ADD_SERIES]: function(getState, payload, dispatch) { + dispatch(set({ section, isAdding: true })); + + const tvdbId = payload.tvdbId; + const items = getState().addSeries.items; + const newSeries = getNewSeries(_.cloneDeep(_.find(items, { tvdbId })), payload); + + const promise = $.ajax({ + url: '/series', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(newSeries) + }); + + promise.done((data) => { + dispatch(batchActions([ + updateItem({ section: 'series', ...data }), + + set({ + section, + isAdding: false, + isAdded: true, + addError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isAdding: false, + isAdded: false, + addError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_ADD_SERIES_VALUE]: createSetSettingValueReducer(section), + + [SET_ADD_SERIES_DEFAULT]: function(state, { payload }) { + const newState = getSectionState(state, section); + + newState.defaults = { + ...newState.defaults, + ...payload + }; + + return updateSectionState(state, section, newState); + }, + + [CLEAR_ADD_SERIES]: function(state) { + const { + defaults, + ...otherDefaultState + } = defaultState; + + return Object.assign({}, state, otherDefaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js new file mode 100644 index 000000000..81c61ca80 --- /dev/null +++ b/frontend/src/Store/Actions/appActions.js @@ -0,0 +1,135 @@ +import _ from 'lodash'; +import { createAction } from 'redux-actions'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createHandleActions from './Creators/createHandleActions'; + +function getDimensions(width, height) { + const dimensions = { + width, + height, + isExtraSmallScreen: width <= 480, + isSmallScreen: width <= 768, + isMediumScreen: width <= 992, + isLargeScreen: width <= 1200 + }; + + return dimensions; +} + +// +// Variables + +export const section = 'app'; +const messagesSection = 'app.messages'; + +// +// State + +export const defaultState = { + dimensions: getDimensions(window.innerWidth, window.innerHeight), + messages: { + items: [] + }, + version: window.Sonarr.version, + isUpdated: false, + isConnected: true, + isReconnecting: false, + isDisconnected: false, + isRestarting: false, + isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen +}; + +// +// Action Types + +export const SHOW_MESSAGE = 'app/showMessage'; +export const HIDE_MESSAGE = 'app/hideMessage'; +export const SAVE_DIMENSIONS = 'app/saveDimensions'; +export const SET_VERSION = 'app/setVersion'; +export const SET_APP_VALUE = 'app/setAppValue'; +export const SET_IS_SIDEBAR_VISIBLE = 'app/setIsSidebarVisible'; + +// +// Action Creators + +export const saveDimensions = createAction(SAVE_DIMENSIONS); +export const setVersion = createAction(SET_VERSION); +export const setIsSidebarVisible = createAction(SET_IS_SIDEBAR_VISIBLE); +export const setAppValue = createAction(SET_APP_VALUE); +export const showMessage = createAction(SHOW_MESSAGE); +export const hideMessage = createAction(HIDE_MESSAGE); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SAVE_DIMENSIONS]: function(state, { payload }) { + const { + width, + height + } = payload; + + const dimensions = getDimensions(width, height); + + return Object.assign({}, state, { dimensions }); + }, + + [SHOW_MESSAGE]: function(state, { payload }) { + const newState = getSectionState(state, messagesSection); + const items = newState.items; + const index = _.findIndex(items, { id: payload.id }); + + newState.items = [...items]; + + if (index >= 0) { + const item = items[index]; + + newState.items.splice(index, 1, { ...item, ...payload }); + } else { + newState.items.push({ ...payload }); + } + + return updateSectionState(state, messagesSection, newState); + }, + + [HIDE_MESSAGE]: function(state, { payload }) { + const newState = getSectionState(state, messagesSection); + + newState.items = [...newState.items]; + _.remove(newState.items, { id: payload.id }); + + return updateSectionState(state, messagesSection, newState); + }, + + [SET_APP_VALUE]: function(state, { payload }) { + const newState = Object.assign(getSectionState(state, section), payload); + + return updateSectionState(state, section, newState); + }, + + [SET_VERSION]: function(state, { payload }) { + const version = payload.version; + + const newState = { + version + }; + + if (state.version !== version) { + newState.isUpdated = true; + } + + return Object.assign({}, state, newState); + }, + + [SET_IS_SIDEBAR_VISIBLE]: function(state, { payload }) { + const newState = { + isSidebarVisible: payload.isSidebarVisible + }; + + return Object.assign({}, state, newState); + } + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/baseActions.js b/frontend/src/Store/Actions/baseActions.js new file mode 100644 index 000000000..37be3e0d2 --- /dev/null +++ b/frontend/src/Store/Actions/baseActions.js @@ -0,0 +1,29 @@ +import { createAction } from 'redux-actions'; + +// +// Action Types + +export const SET = 'base/set'; + +export const UPDATE = 'base/update'; +export const UPDATE_ITEM = 'base/updateItem'; +export const UPDATE_SERVER_SIDE_COLLECTION = 'base/updateServerSideCollection'; + +export const SET_SETTING_VALUE = 'base/setSettingValue'; +export const CLEAR_PENDING_CHANGES = 'base/clearPendingChanges'; + +export const REMOVE_ITEM = 'base/removeItem'; + +// +// Action Creators + +export const set = createAction(SET); + +export const update = createAction(UPDATE); +export const updateItem = createAction(UPDATE_ITEM); +export const updateServerSideCollection = createAction(UPDATE_SERVER_SIDE_COLLECTION); + +export const setSettingValue = createAction(SET_SETTING_VALUE); +export const clearPendingChanges = createAction(CLEAR_PENDING_CHANGES); + +export const removeItem = createAction(REMOVE_ITEM); diff --git a/frontend/src/Store/Actions/blacklistActions.js b/frontend/src/Store/Actions/blacklistActions.js new file mode 100644 index 000000000..d6fbfb99b --- /dev/null +++ b/frontend/src/Store/Actions/blacklistActions.js @@ -0,0 +1,144 @@ +import { createAction } from 'redux-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { sortDirections } from 'Helpers/Props'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; + +// +// Variables + +export const section = 'blacklist'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'date', + sortDirection: sortDirections.DESCENDING, + error: null, + items: [], + + columns: [ + { + name: 'series.sortTitle', + label: 'Series Title', + isSortable: true, + isVisible: true + }, + { + name: 'sourceTitle', + label: 'Source Title', + isSortable: true, + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: false + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isSortable: true, + isVisible: true + }, + { + name: 'indexer', + label: 'Indexer', + isSortable: true, + isVisible: false + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ] +}; + +export const persistState = [ + 'blacklist.pageSize', + 'blacklist.sortKey', + 'blacklist.sortDirection', + 'blacklist.columns' +]; + +// +// Action Types + +export const FETCH_BLACKLIST = 'blacklist/fetchBlacklist'; +export const GOTO_FIRST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistFirstPage'; +export const GOTO_PREVIOUS_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPreviousPage'; +export const GOTO_NEXT_BLACKLIST_PAGE = 'blacklist/gotoBlacklistNextPage'; +export const GOTO_LAST_BLACKLIST_PAGE = 'blacklist/gotoBlacklistLastPage'; +export const GOTO_BLACKLIST_PAGE = 'blacklist/gotoBlacklistPage'; +export const SET_BLACKLIST_SORT = 'blacklist/setBlacklistSort'; +export const SET_BLACKLIST_TABLE_OPTION = 'blacklist/setBlacklistTableOption'; +export const REMOVE_FROM_BLACKLIST = 'blacklist/removeFromBlacklist'; +export const CLEAR_BLACKLIST = 'blacklist/clearBlacklist'; + +// +// Action Creators + +export const fetchBlacklist = createThunk(FETCH_BLACKLIST); +export const gotoBlacklistFirstPage = createThunk(GOTO_FIRST_BLACKLIST_PAGE); +export const gotoBlacklistPreviousPage = createThunk(GOTO_PREVIOUS_BLACKLIST_PAGE); +export const gotoBlacklistNextPage = createThunk(GOTO_NEXT_BLACKLIST_PAGE); +export const gotoBlacklistLastPage = createThunk(GOTO_LAST_BLACKLIST_PAGE); +export const gotoBlacklistPage = createThunk(GOTO_BLACKLIST_PAGE); +export const setBlacklistSort = createThunk(SET_BLACKLIST_SORT); +export const setBlacklistTableOption = createAction(SET_BLACKLIST_TABLE_OPTION); +export const removeFromBlacklist = createThunk(REMOVE_FROM_BLACKLIST); +export const clearBlacklist = createAction(CLEAR_BLACKLIST); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + ...createServerSideCollectionHandlers( + section, + '/blacklist', + fetchBlacklist, + { + [serverSideCollectionHandlers.FETCH]: FETCH_BLACKLIST, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_BLACKLIST_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_BLACKLIST_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_BLACKLIST_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_BLACKLIST_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_BLACKLIST_PAGE, + [serverSideCollectionHandlers.SORT]: SET_BLACKLIST_SORT + }), + + [REMOVE_FROM_BLACKLIST]: createRemoveItemHandler(section, '/blacklist') +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_BLACKLIST_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_BLACKLIST]: createClearReducer('history', { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js new file mode 100644 index 000000000..83c28036a --- /dev/null +++ b/frontend/src/Store/Actions/calendarActions.js @@ -0,0 +1,391 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import moment from 'moment'; +import { filterTypes } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import * as calendarViews from 'Calendar/calendarViews'; +import * as commandNames from 'Commands/commandNames'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; +import { executeCommandHelper } from './commandActions'; + +// +// Variables + +export const section = 'calendar'; + +const viewRanges = { + [calendarViews.DAY]: 'day', + [calendarViews.WEEK]: 'week', + [calendarViews.MONTH]: 'month', + [calendarViews.FORECAST]: 'day' +}; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + start: null, + end: null, + dates: [], + dayCount: 7, + view: window.innerWidth > 768 ? 'week' : 'day', + error: null, + items: [], + searchMissingCommandId: null, + + options: { + collapseMultipleEpisodes: false, + showEpisodeInformation: true, + showFinaleIcon: false, + showSpecialIcon: false, + showCutoffUnmetIcon: false + }, + + selectedFilterKey: 'monitored', + + filters: [ + { + key: 'all', + label: 'All', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'monitored', + label: 'Monitored Only', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + } + + ] +}; + +export const persistState = [ + 'calendar.view', + 'calendar.selectedFilterKey', + 'calendar.options' +]; + +// +// Actions Types + +export const FETCH_CALENDAR = 'calendar/fetchCalendar'; +export const SET_CALENDAR_DAYS_COUNT = 'calendar/setCalendarDaysCount'; +export const SET_CALENDAR_FILTER = 'calendar/setCalendarFilter'; +export const SET_CALENDAR_VIEW = 'calendar/setCalendarView'; +export const GOTO_CALENDAR_TODAY = 'calendar/gotoCalendarToday'; +export const GOTO_CALENDAR_NEXT_RANGE = 'calendar/gotoCalendarNextRange'; +export const CLEAR_CALENDAR = 'calendar/clearCalendar'; +export const SET_CALENDAR_OPTION = 'calendar/setCalendarOption'; +export const SEARCH_MISSING = 'calendar/searchMissing'; +export const GOTO_CALENDAR_PREVIOUS_RANGE = 'calendar/gotoCalendarPreviousRange'; + +// +// Helpers + +function getDays(start, end) { + const startTime = moment(start); + const endTime = moment(end); + const difference = endTime.diff(startTime, 'days'); + + // Difference is one less than the number of days we need to account for. + return _.times(difference + 1, (i) => { + return startTime.clone().add(i, 'days').toISOString(); + }); +} + +function getDates(time, view, firstDayOfWeek, dayCount) { + const weekName = firstDayOfWeek === 0 ? 'week' : 'isoWeek'; + + let start = time.clone().startOf('day'); + let end = time.clone().endOf('day'); + + if (view === calendarViews.WEEK) { + start = time.clone().startOf(weekName); + end = time.clone().endOf(weekName); + } + + if (view === calendarViews.FORECAST) { + start = time.clone().subtract(1, 'day').startOf('day'); + end = time.clone().add(dayCount - 2, 'days').endOf('day'); + } + + if (view === calendarViews.MONTH) { + start = time.clone().startOf('month').startOf(weekName); + end = time.clone().endOf('month').endOf(weekName); + } + + if (view === calendarViews.AGENDA) { + start = time.clone().subtract(1, 'day').startOf('day'); + end = time.clone().add(1, 'month').endOf('day'); + } + + return { + start: start.toISOString(), + end: end.toISOString(), + time: time.toISOString(), + dates: getDays(start, end) + }; +} + +function getPopulatableRange(startDate, endDate, view) { + switch (view) { + case calendarViews.DAY: + return { + start: moment(startDate).subtract(1, 'day').toISOString(), + end: moment(endDate).add(1, 'day').toISOString() + }; + case calendarViews.WEEK: + case calendarViews.FORECAST: + return { + start: moment(startDate).subtract(1, 'week').toISOString(), + end: moment(endDate).add(1, 'week').toISOString() + }; + default: + return { + start: startDate, + end: endDate + }; + } +} + +function isRangePopulated(start, end, state) { + const { + start: currentStart, + end: currentEnd, + view: currentView + } = state; + + if (!currentStart || !currentEnd) { + return false; + } + + const { + start: currentPopulatedStart, + end: currentPopulatedEnd + } = getPopulatableRange(currentStart, currentEnd, currentView); + + if ( + moment(start).isAfter(currentPopulatedStart) && + moment(start).isBefore(currentPopulatedEnd) + ) { + return true; + } + + return false; +} + +// +// Action Creators + +export const fetchCalendar = createThunk(FETCH_CALENDAR); +export const setCalendarDaysCount = createThunk(SET_CALENDAR_DAYS_COUNT); +export const setCalendarFilter = createThunk(SET_CALENDAR_FILTER); +export const setCalendarView = createThunk(SET_CALENDAR_VIEW); +export const gotoCalendarToday = createThunk(GOTO_CALENDAR_TODAY); +export const gotoCalendarPreviousRange = createThunk(GOTO_CALENDAR_PREVIOUS_RANGE); +export const gotoCalendarNextRange = createThunk(GOTO_CALENDAR_NEXT_RANGE); +export const clearCalendar = createAction(CLEAR_CALENDAR); +export const setCalendarOption = createAction(SET_CALENDAR_OPTION); +export const searchMissing = createThunk(SEARCH_MISSING); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_CALENDAR]: function(getState, payload, dispatch) { + const state = getState(); + const calendar = state.calendar; + const unmonitored = calendar.selectedFilterKey === 'all'; + + const { + time = calendar.time, + view = calendar.view + } = payload; + + const dayCount = state.calendar.dayCount; + const dates = getDates(moment(time), view, state.settings.ui.item.firstDayOfWeek, dayCount); + const { start, end } = getPopulatableRange(dates.start, dates.end, view); + const isPrePopulated = isRangePopulated(start, end, state.calendar); + + const basesAttrs = { + section, + isFetching: true + }; + + const attrs = isPrePopulated ? + { + view, + ...basesAttrs, + ...dates + } : + basesAttrs; + + dispatch(set(attrs)); + + const promise = $.ajax({ + url: '/calendar', + data: { + unmonitored, + start, + end + } + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + view, + ...dates, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [SET_CALENDAR_DAYS_COUNT]: function(getState, payload, dispatch) { + if (payload.dayCount === getState().calendar.dayCount) { + return; + } + + dispatch(set({ + section, + dayCount: payload.dayCount + })); + + const state = getState(); + const { time, view } = state.calendar; + + dispatch(fetchCalendar({ time, view })); + }, + + [SET_CALENDAR_FILTER]: function(getState, payload, dispatch) { + dispatch(set({ + section, + selectedFilterKey: payload.selectedFilterKey + })); + + const state = getState(); + const { time, view } = state.calendar; + + dispatch(fetchCalendar({ time, view })); + }, + + [SET_CALENDAR_VIEW]: function(getState, payload, dispatch) { + const state = getState(); + const view = payload.view; + const time = view === calendarViews.FORECAST || calendarViews.AGENDA ? + moment() : + state.calendar.time; + + dispatch(fetchCalendar({ time, view })); + }, + + [GOTO_CALENDAR_TODAY]: function(getState, payload, dispatch) { + const state = getState(); + const view = state.calendar.view; + const time = moment(); + + dispatch(fetchCalendar({ time, view })); + }, + + [GOTO_CALENDAR_PREVIOUS_RANGE]: function(getState, payload, dispatch) { + const state = getState(); + + const { + view, + dayCount + } = state.calendar; + + const amount = view === calendarViews.FORECAST ? dayCount : 1; + const time = moment(state.calendar.time).subtract(amount, viewRanges[view]); + + dispatch(fetchCalendar({ time, view })); + }, + + [GOTO_CALENDAR_NEXT_RANGE]: function(getState, payload, dispatch) { + const state = getState(); + + const { + view, + dayCount + } = state.calendar; + + const amount = view === calendarViews.FORECAST ? dayCount : 1; + const time = moment(state.calendar.time).add(amount, viewRanges[view]); + + dispatch(fetchCalendar({ time, view })); + }, + + [SEARCH_MISSING]: function(getState, payload, dispatch) { + const { episodeIds } = payload; + + const commandPayload = { + name: commandNames.EPISODE_SEARCH, + episodeIds + }; + + executeCommandHelper(commandPayload, dispatch).then((data) => { + dispatch(set({ + section, + searchMissingCommandId: data.id + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_CALENDAR]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }), + + [SET_CALENDAR_OPTION]: function(state, { payload }) { + const options = state.options; + + return { + ...state, + options: { + ...options, + ...payload + } + }; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/captchaActions.js b/frontend/src/Store/Actions/captchaActions.js new file mode 100644 index 000000000..d506566f7 --- /dev/null +++ b/frontend/src/Store/Actions/captchaActions.js @@ -0,0 +1,119 @@ +import { createAction } from 'redux-actions'; +import requestAction from 'Utilities/requestAction'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'captcha'; + +// +// State + +export const defaultState = { + refreshing: false, + token: null, + siteKey: null, + secretToken: null, + ray: null, + stoken: null, + responseUrl: null +}; + +// +// Actions Types + +export const REFRESH_CAPTCHA = 'captcha/refreshCaptcha'; +export const GET_CAPTCHA_COOKIE = 'captcha/getCaptchaCookie'; +export const SET_CAPTCHA_VALUE = 'captcha/setCaptchaValue'; +export const RESET_CAPTCHA = 'captcha/resetCaptcha'; + +// +// Action Creators + +export const refreshCaptcha = createThunk(REFRESH_CAPTCHA); +export const getCaptchaCookie = createThunk(GET_CAPTCHA_COOKIE); +export const setCaptchaValue = createAction(SET_CAPTCHA_VALUE); +export const resetCaptcha = createAction(RESET_CAPTCHA); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [REFRESH_CAPTCHA]: function(getState, payload, dispatch) { + const actionPayload = { + action: 'checkCaptcha', + ...payload + }; + + dispatch(setCaptchaValue({ + refreshing: true + })); + + const promise = requestAction(actionPayload); + + promise.done((data) => { + if (!data.captchaRequest) { + dispatch(setCaptchaValue({ + refreshing: false + })); + } + + dispatch(setCaptchaValue({ + refreshing: false, + ...data.captchaRequest + })); + }); + + promise.fail(() => { + dispatch(setCaptchaValue({ + refreshing: false + })); + }); + }, + + [GET_CAPTCHA_COOKIE]: function(getState, payload, dispatch) { + const state = getState().captcha; + + const queryParams = { + responseUrl: state.responseUrl, + ray: state.ray, + captchaResponse: payload.captchaResponse + }; + + const actionPayload = { + action: 'getCaptchaCookie', + queryParams, + ...payload + }; + + const promise = requestAction(actionPayload); + + promise.done((data) => { + dispatch(setCaptchaValue({ + token: data.captchaToken + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_CAPTCHA_VALUE]: function(state, { payload }) { + const newState = Object.assign(getSectionState(state, section), payload); + + return updateSectionState(state, section, newState); + }, + + [RESET_CAPTCHA]: function(state) { + return updateSectionState(state, section, defaultState); + } + +}, defaultState); diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js new file mode 100644 index 000000000..5a7baf58a --- /dev/null +++ b/frontend/src/Store/Actions/commandActions.js @@ -0,0 +1,215 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { isSameCommand } from 'Utilities/Command'; +import { messageTypes } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import { showMessage, hideMessage } from './appActions'; +import { updateItem } from './baseActions'; + +// +// Variables + +export const section = 'commands'; + +let lastCommand = null; +let lastCommandTimeout = null; +const removeCommandTimeoutIds = {}; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + handlers: {} +}; + +// +// Actions Types + +export const FETCH_COMMANDS = 'commands/fetchCommands'; +export const EXECUTE_COMMAND = 'commands/executeCommand'; +export const CANCEL_COMMAND = 'commands/cancelCommand'; +export const ADD_COMMAND = 'commands/updateCommand'; +export const UPDATE_COMMAND = 'commands/finishCommand'; +export const FINISH_COMMAND = 'commands/addCommand'; +export const REMOVE_COMMAND = 'commands/removeCommand'; + +// +// Action Creators + +export const fetchCommands = createThunk(FETCH_COMMANDS); +export const executeCommand = createThunk(EXECUTE_COMMAND); +export const cancelCommand = createThunk(CANCEL_COMMAND); +export const updateCommand = createThunk(UPDATE_COMMAND); +export const finishCommand = createThunk(FINISH_COMMAND); +export const addCommand = createAction(ADD_COMMAND); +export const removeCommand = createAction(REMOVE_COMMAND); + +// +// Helpers + +function showCommandMessage(payload, dispatch) { + const { + id, + name, + trigger, + message, + body = {}, + status + } = payload; + + const { + sendUpdatesToClient, + suppressMessages + } = body; + + if (!message || !body || !sendUpdatesToClient || suppressMessages) { + return; + } + + let type = messageTypes.INFO; + let hideAfter = 0; + + if (status === 'completed') { + type = messageTypes.SUCCESS; + hideAfter = 4; + } else if (status === 'failed') { + type = messageTypes.ERROR; + hideAfter = trigger === 'manual' ? 10 : 4; + } + + dispatch(showMessage({ + id, + name, + message, + type, + hideAfter + })); +} + +function scheduleRemoveCommand(command, dispatch) { + const { + id, + status + } = command; + + if (status === 'queued') { + return; + } + + const timeoutId = removeCommandTimeoutIds[id]; + + if (timeoutId) { + clearTimeout(timeoutId); + } + + removeCommandTimeoutIds[id] = setTimeout(() => { + dispatch(batchActions([ + removeCommand({ section: 'commands', id }), + hideMessage({ id }) + ])); + + delete removeCommandTimeoutIds[id]; + }, 60000 * 5); +} + +export function executeCommandHelper( payload, dispatch) { + // TODO: show a message for the user + if (lastCommand && isSameCommand(lastCommand, payload)) { + console.warn('Please wait at least 5 seconds before running this command again'); + } + + lastCommand = payload; + + // clear last command after 5 seconds. + if (lastCommandTimeout) { + clearTimeout(lastCommandTimeout); + } + + lastCommandTimeout = setTimeout(() => { + lastCommand = null; + }, 5000); + + const promise = $.ajax({ + url: '/command', + method: 'POST', + data: JSON.stringify(payload) + }); + + return promise.then((data) => { + dispatch(addCommand(data)); + }); +} + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_COMMANDS]: createFetchHandler('commands', '/command'), + + [EXECUTE_COMMAND]: function(getState, payload, dispatch) { + executeCommandHelper(payload, dispatch); + }, + + [CANCEL_COMMAND]: createRemoveItemHandler(section, '/command'), + + [UPDATE_COMMAND]: function(getState, payload, dispatch) { + dispatch(updateItem({ section: 'commands', ...payload })); + + showCommandMessage(payload, dispatch); + scheduleRemoveCommand(payload, dispatch); + }, + + [FINISH_COMMAND]: function(getState, payload, dispatch) { + const state = getState(); + const handlers = state.commands.handlers; + + Object.keys(handlers).forEach((key) => { + const handler = handlers[key]; + + if (handler.name === payload.name) { + dispatch(handler.handler(payload)); + } + }); + + dispatch(updateItem({ section: 'commands', ...payload })); + scheduleRemoveCommand(payload, dispatch); + showCommandMessage(payload, dispatch); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [ADD_COMMAND]: (state, { payload }) => { + const newState = Object.assign({}, state); + newState.items = [...state.items, payload]; + + return newState; + }, + + [REMOVE_COMMAND]: (state, { payload }) => { + const newState = Object.assign({}, state); + newState.items = [...state.items]; + + const index = _.findIndex(newState.items, { id: payload.id }); + + if (index > -1) { + newState.items.splice(index, 1); + } + + return newState; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/customFilterActions.js b/frontend/src/Store/Actions/customFilterActions.js new file mode 100644 index 000000000..750c3ef6f --- /dev/null +++ b/frontend/src/Store/Actions/customFilterActions.js @@ -0,0 +1,55 @@ +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createSaveProviderHandler from './Creators/createSaveProviderHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'customFilters'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + isDeleting: false, + deleteError: null, + items: [], + pendingChanges: {} +}; + +// +// Actions Types + +export const FETCH_CUSTOM_FILTERS = 'customFilters/fetchCustomFilters'; +export const SAVE_CUSTOM_FILTER = 'customFilters/saveCustomFilter'; +export const DELETE_CUSTOM_FILTER = 'customFilters/deleteCustomFilter'; + +// +// Action Creators + +export const fetchCustomFilters = createThunk(FETCH_CUSTOM_FILTERS); +export const saveCustomFilter = createThunk(SAVE_CUSTOM_FILTER); +export const deleteCustomFilter = createThunk(DELETE_CUSTOM_FILTER); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_CUSTOM_FILTERS]: createFetchHandler(section, '/customFilter'), + + [SAVE_CUSTOM_FILTER]: createSaveProviderHandler(section, '/customFilter'), + + [DELETE_CUSTOM_FILTER]: createRemoveItemHandler(section, '/customFilter') + +}); + +// +// Reducers +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/deviceActions.js b/frontend/src/Store/Actions/deviceActions.js new file mode 100644 index 000000000..089d49bf3 --- /dev/null +++ b/frontend/src/Store/Actions/deviceActions.js @@ -0,0 +1,83 @@ +import { createAction } from 'redux-actions'; +import requestAction from 'Utilities/requestAction'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set } from './baseActions'; + +// +// Variables + +export const section = 'devices'; + +// +// State + +export const defaultState = { + items: [], + isFetching: false, + isPopulated: false, + error: false +}; + +// +// Actions Types + +export const FETCH_DEVICES = 'devices/fetchDevices'; +export const CLEAR_DEVICES = 'devices/clearDevices'; + +// +// Action Creators + +export const fetchDevices = createThunk(FETCH_DEVICES); +export const clearDevices = createAction(CLEAR_DEVICES); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_DEVICES]: function(getState, payload, dispatch) { + const actionPayload = { + action: 'getDevices', + ...payload + }; + + dispatch(set({ + section, + isFetching: true + })); + + const promise = requestAction(actionPayload); + + promise.done((data) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: true, + error: null, + items: data.devices || [] + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_DEVICES]: function(state) { + return updateSectionState(state, section, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/episodeActions.js b/frontend/src/Store/Actions/episodeActions.js new file mode 100644 index 000000000..2c367d343 --- /dev/null +++ b/frontend/src/Store/Actions/episodeActions.js @@ -0,0 +1,235 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import episodeEntities from 'Episode/episodeEntities'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { updateItem } from './baseActions'; + +// +// Variables + +export const section = 'episodes'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + sortKey: 'episodeNumber', + sortDirection: sortDirections.DESCENDING, + items: [], + + columns: [ + { + name: 'monitored', + columnLabel: 'Monitored', + isVisible: true, + isModifiable: false + }, + { + name: 'episodeNumber', + label: '#', + isVisible: true + }, + { + name: 'title', + label: 'Title', + isVisible: true + }, + { + name: 'path', + label: 'Path', + isVisible: false + }, + { + name: 'relativePath', + label: 'Relative Path', + isVisible: false + }, + { + name: 'airDateUtc', + label: 'Air Date', + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: false + }, + { + name: 'audioInfo', + label: 'Audio Info', + isVisible: false + }, + { + name: 'videoCodec', + label: 'Video Codec', + isVisible: false + }, + { + name: 'size', + label: 'Size', + isVisible: false + }, + { + name: 'status', + label: 'Status', + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ] +}; + +export const persistState = [ + 'episodes.columns' +]; + +// +// Actions Types + +export const FETCH_EPISODES = 'episodes/fetchEpisodes'; +export const SET_EPISODES_SORT = 'episodes/setEpisodesSort'; +export const SET_EPISODES_TABLE_OPTION = 'episodes/setEpisodesTableOption'; +export const CLEAR_EPISODES = 'episodes/clearEpisodes'; +export const TOGGLE_EPISODE_MONITORED = 'episodes/toggleEpisodeMonitored'; +export const TOGGLE_EPISODES_MONITORED = 'episodes/toggleEpisodesMonitored'; + +// +// Action Creators + +export const fetchEpisodes = createThunk(FETCH_EPISODES); +export const setEpisodesSort = createAction(SET_EPISODES_SORT); +export const setEpisodesTableOption = createAction(SET_EPISODES_TABLE_OPTION); +export const clearEpisodes = createAction(CLEAR_EPISODES); +export const toggleEpisodeMonitored = createThunk(TOGGLE_EPISODE_MONITORED); +export const toggleEpisodesMonitored = createThunk(TOGGLE_EPISODES_MONITORED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_EPISODES]: createFetchHandler(section, '/episode'), + + [TOGGLE_EPISODE_MONITORED]: function(getState, payload, dispatch) { + const { + episodeId: id, + episodeEntity = episodeEntities.EPISODES, + monitored + } = payload; + + dispatch(updateItem({ + id, + section: episodeEntity, + isSaving: true + })); + + const promise = $.ajax({ + url: `/episode/${id}`, + method: 'PUT', + data: JSON.stringify({ monitored }), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(updateItem({ + id, + section: episodeEntity, + isSaving: false, + monitored + })); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + id, + section: episodeEntity, + isSaving: false + })); + }); + }, + + [TOGGLE_EPISODES_MONITORED]: function(getState, payload, dispatch) { + const { + episodeIds, + episodeEntity = episodeEntities.EPISODES, + monitored + } = payload; + + const episodeSection = _.last(episodeEntity.split('.')); + + dispatch(batchActions( + episodeIds.map((episodeId) => { + return updateItem({ + id: episodeId, + section: episodeSection, + isSaving: true + }); + }) + )); + + const promise = $.ajax({ + url: '/episode/monitor', + method: 'PUT', + data: JSON.stringify({ episodeIds, monitored }), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(batchActions( + episodeIds.map((episodeId) => { + return updateItem({ + id: episodeId, + section: episodeSection, + isSaving: false, + monitored + }); + }) + )); + }); + + promise.fail((xhr) => { + dispatch(batchActions( + episodeIds.map((episodeId) => { + return updateItem({ + id: episodeId, + section: episodeSection, + isSaving: false + }); + }) + )); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_EPISODES_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_EPISODES]: (state) => { + return Object.assign({}, state, { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }); + }, + + [SET_EPISODES_SORT]: createSetClientSideCollectionSortReducer(section) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/episodeFileActions.js b/frontend/src/Store/Actions/episodeFileActions.js new file mode 100644 index 000000000..cb5329355 --- /dev/null +++ b/frontend/src/Store/Actions/episodeFileActions.js @@ -0,0 +1,210 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import episodeEntities from 'Episode/episodeEntities'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import { set, removeItem, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'episodeFiles'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isDeleting: false, + deleteError: null, + isSaving: false, + saveError: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_EPISODE_FILES = 'episodeFiles/fetchEpisodeFiles'; +export const DELETE_EPISODE_FILE = 'episodeFiles/deleteEpisodeFile'; +export const DELETE_EPISODE_FILES = 'episodeFiles/deleteEpisodeFiles'; +export const UPDATE_EPISODE_FILES = 'episodeFiles/updateEpisodeFiles'; +export const CLEAR_EPISODE_FILES = 'episodeFiles/clearEpisodeFiles'; + +// +// Action Creators + +export const fetchEpisodeFiles = createThunk(FETCH_EPISODE_FILES); +export const deleteEpisodeFile = createThunk(DELETE_EPISODE_FILE); +export const deleteEpisodeFiles = createThunk(DELETE_EPISODE_FILES); +export const updateEpisodeFiles = createThunk(UPDATE_EPISODE_FILES); +export const clearEpisodeFiles = createAction(CLEAR_EPISODE_FILES); + +// +// Helpers + +const deleteEpisodeFileHelper = createRemoveItemHandler(section, '/episodeFile'); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_EPISODE_FILES]: createFetchHandler(section, '/episodeFile'), + + [DELETE_EPISODE_FILE]: function(getState, payload, dispatch) { + const { + id: episodeFileId, + episodeEntity = episodeEntities.EPISODES + } = payload; + + const episodeSection = _.last(episodeEntity.split('.')); + const deletePromise = deleteEpisodeFileHelper(getState, payload, dispatch); + + deletePromise.done(() => { + const episodes = getState().episodes.items; + const episodesWithRemovedFiles = _.filter(episodes, { episodeFileId }); + + dispatch(batchActions([ + ...episodesWithRemovedFiles.map((episode) => { + return updateItem({ + section: episodeSection, + ...episode, + episodeFileId: 0, + hasFile: false + }); + }) + ])); + }); + }, + + [DELETE_EPISODE_FILES]: function(getState, payload, dispatch) { + const { + episodeFileIds + } = payload; + + dispatch(set({ section, isDeleting: true })); + + const promise = $.ajax({ + url: '/episodeFile/bulk', + method: 'DELETE', + dataType: 'json', + data: JSON.stringify({ episodeFileIds }) + }); + + promise.done(() => { + const episodes = getState().episodes.items; + const episodesWithRemovedFiles = episodeFileIds.reduce((acc, episodeFileId) => { + acc.push(..._.filter(episodes, { episodeFileId })); + + return acc; + }, []); + + dispatch(batchActions([ + ...episodeFileIds.map((id) => { + return removeItem({ section, id }); + }), + + ...episodesWithRemovedFiles.map((episode) => { + return updateItem({ + section: 'episodes', + ...episode, + episodeFileId: 0, + hasFile: false + }); + }), + + set({ + section, + isDeleting: false, + deleteError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + }, + + [UPDATE_EPISODE_FILES]: function(getState, payload, dispatch) { + const { + episodeFileIds, + language, + quality + } = payload; + + dispatch(set({ section, isSaving: true })); + + const data = { + episodeFileIds + }; + + if (language) { + data.language = language; + } + + if (quality) { + data.quality = quality; + } + + const promise = $.ajax({ + url: '/episodeFile/editor', + method: 'PUT', + dataType: 'json', + data: JSON.stringify(data) + }); + + promise.done(() => { + dispatch(batchActions([ + ...episodeFileIds.map((id) => { + const props = {}; + + if (language) { + props.language = language; + } + + if (quality) { + props.quality = quality; + } + + return updateItem({ section, id, ...props }); + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_EPISODE_FILES]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/episodeHistoryActions.js b/frontend/src/Store/Actions/episodeHistoryActions.js new file mode 100644 index 000000000..35f6ea8b7 --- /dev/null +++ b/frontend/src/Store/Actions/episodeHistoryActions.js @@ -0,0 +1,112 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { sortDirections } from 'Helpers/Props'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; + +// +// Variables + +export const section = 'episodeHistory'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_EPISODE_HISTORY = 'episodeHistory/fetchEpisodeHistory'; +export const CLEAR_EPISODE_HISTORY = 'episodeHistory/clearEpisodeHistory'; +export const EPISODE_HISTORY_MARK_AS_FAILED = 'episodeHistory/episodeHistoryMarkAsFailed'; + +// +// Action Creators + +export const fetchEpisodeHistory = createThunk(FETCH_EPISODE_HISTORY); +export const clearEpisodeHistory = createAction(CLEAR_EPISODE_HISTORY); +export const episodeHistoryMarkAsFailed = createThunk(EPISODE_HISTORY_MARK_AS_FAILED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_EPISODE_HISTORY]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const queryParams = { + pageSize: 1000, + page: 1, + sortKey: 'date', + sortDirection: sortDirections.DESCENDING, + episodeId: payload.episodeId + }; + + const promise = $.ajax({ + url: '/history', + data: queryParams + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data: data.records }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [EPISODE_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) { + const { + historyId, + episodeId + } = payload; + + const promise = $.ajax({ + url: '/history/failed', + method: 'POST', + data: { + id: historyId + } + }); + + promise.done(() => { + dispatch(fetchEpisodeHistory({ episodeId })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_EPISODE_HISTORY]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js new file mode 100644 index 000000000..9eafd6704 --- /dev/null +++ b/frontend/src/Store/Actions/historyActions.js @@ -0,0 +1,267 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createHandleActions from './Creators/createHandleActions'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import { updateItem } from './baseActions'; + +// +// Variables + +export const section = 'history'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + pageSize: 20, + sortKey: 'date', + sortDirection: sortDirections.DESCENDING, + items: [], + + columns: [ + { + name: 'eventType', + columnLabel: 'Event Type', + isVisible: true, + isModifiable: false + }, + { + name: 'series.sortTitle', + label: 'Series', + isSortable: true, + isVisible: true + }, + { + name: 'episode', + label: 'Episode', + isVisible: true + }, + { + name: 'episodeTitle', + label: 'Episode Title', + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: false + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isSortable: true, + isVisible: true + }, + { + name: 'downloadClient', + label: 'Download Client', + isVisible: false + }, + { + name: 'indexer', + label: 'Indexer', + isVisible: false + }, + { + name: 'releaseGroup', + label: 'Release Group', + isVisible: false + }, + { + name: 'details', + columnLabel: 'Details', + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'grabbed', + label: 'Grabbed', + filters: [ + { + key: 'eventType', + value: '1', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'imported', + label: 'Imported', + filters: [ + { + key: 'eventType', + value: '3', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'failed', + label: 'Failed', + filters: [ + { + key: 'eventType', + value: '4', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'deleted', + label: 'Deleted', + filters: [ + { + key: 'eventType', + value: '5', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'renamed', + label: 'Renamed', + filters: [ + { + key: 'eventType', + value: '6', + type: filterTypes.EQUAL + } + ] + } + ] + +}; + +export const persistState = [ + 'history.pageSize', + 'history.sortKey', + 'history.sortDirection', + 'history.selectedFilterKey' +]; + +// +// Actions Types + +export const FETCH_HISTORY = 'history/fetchHistory'; +export const GOTO_FIRST_HISTORY_PAGE = 'history/gotoHistoryFirstPage'; +export const GOTO_PREVIOUS_HISTORY_PAGE = 'history/gotoHistoryPreviousPage'; +export const GOTO_NEXT_HISTORY_PAGE = 'history/gotoHistoryNextPage'; +export const GOTO_LAST_HISTORY_PAGE = 'history/gotoHistoryLastPage'; +export const GOTO_HISTORY_PAGE = 'history/gotoHistoryPage'; +export const SET_HISTORY_SORT = 'history/setHistorySort'; +export const SET_HISTORY_FILTER = 'history/setHistoryFilter'; +export const SET_HISTORY_TABLE_OPTION = 'history/setHistoryTableOption'; +export const CLEAR_HISTORY = 'history/clearHistory'; +export const MARK_AS_FAILED = 'history/markAsFailed'; + +// +// Action Creators + +export const fetchHistory = createThunk(FETCH_HISTORY); +export const gotoHistoryFirstPage = createThunk(GOTO_FIRST_HISTORY_PAGE); +export const gotoHistoryPreviousPage = createThunk(GOTO_PREVIOUS_HISTORY_PAGE); +export const gotoHistoryNextPage = createThunk(GOTO_NEXT_HISTORY_PAGE); +export const gotoHistoryLastPage = createThunk(GOTO_LAST_HISTORY_PAGE); +export const gotoHistoryPage = createThunk(GOTO_HISTORY_PAGE); +export const setHistorySort = createThunk(SET_HISTORY_SORT); +export const setHistoryFilter = createThunk(SET_HISTORY_FILTER); +export const setHistoryTableOption = createAction(SET_HISTORY_TABLE_OPTION); +export const clearHistory = createAction(CLEAR_HISTORY); +export const markAsFailed = createThunk(MARK_AS_FAILED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + ...createServerSideCollectionHandlers( + section, + '/history', + fetchHistory, + { + [serverSideCollectionHandlers.FETCH]: FETCH_HISTORY, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_HISTORY_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_HISTORY_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_HISTORY_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_HISTORY_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_HISTORY_PAGE, + [serverSideCollectionHandlers.SORT]: SET_HISTORY_SORT, + [serverSideCollectionHandlers.FILTER]: SET_HISTORY_FILTER + }), + + [MARK_AS_FAILED]: function(getState, payload, dispatch) { + const id = payload.id; + + dispatch(updateItem({ + section, + id, + isMarkingAsFailed: true + })); + + const promise = $.ajax({ + url: '/history/failed', + method: 'POST', + data: { + id + } + }); + + promise.done(() => { + dispatch(updateItem({ + section, + id, + isMarkingAsFailed: false, + markAsFailedError: null + })); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + section, + id, + isMarkingAsFailed: false, + markAsFailedError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(section), + + [CLEAR_HISTORY]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/importSeriesActions.js b/frontend/src/Store/Actions/importSeriesActions.js new file mode 100644 index 000000000..74d68f87e --- /dev/null +++ b/frontend/src/Store/Actions/importSeriesActions.js @@ -0,0 +1,328 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import getNewSeries from 'Utilities/Series/getNewSeries'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set, removeItem, updateItem } from './baseActions'; +import { fetchRootFolders } from './rootFolderActions'; + +// +// Variables + +export const section = 'importSeries'; +let concurrentLookups = 0; +let abortCurrentLookup = null; +const queue = []; + +// +// State + +export const defaultState = { + isLookingUpSeries: false, + isImporting: false, + isImported: false, + importError: null, + items: [] +}; + +// +// Actions Types + +export const QUEUE_LOOKUP_SERIES = 'importSeries/queueLookupSeries'; +export const START_LOOKUP_SERIES = 'importSeries/startLookupSeries'; +export const CANCEL_LOOKUP_SERIES = 'importSeries/cancelLookupSeries'; +export const LOOKUP_UNSEARCHED_SERIES = 'importSeries/lookupUnsearchedSeries'; +export const CLEAR_IMPORT_SERIES = 'importSeries/clearImportSeries'; +export const SET_IMPORT_SERIES_VALUE = 'importSeries/setImportSeriesValue'; +export const IMPORT_SERIES = 'importSeries/importSeries'; + +// +// Action Creators + +export const queueLookupSeries = createThunk(QUEUE_LOOKUP_SERIES); +export const startLookupSeries = createThunk(START_LOOKUP_SERIES); +export const importSeries = createThunk(IMPORT_SERIES); +export const lookupUnsearchedSeries = createThunk(LOOKUP_UNSEARCHED_SERIES); +export const clearImportSeries = createAction(CLEAR_IMPORT_SERIES); +export const cancelLookupSeries = createAction(CANCEL_LOOKUP_SERIES); + +export const setImportSeriesValue = createAction(SET_IMPORT_SERIES_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [QUEUE_LOOKUP_SERIES]: function(getState, payload, dispatch) { + const { + name, + path, + term, + topOfQueue = false + } = payload; + + const state = getState().importSeries; + const item = _.find(state.items, { id: name }) || { + id: name, + term, + path, + isFetching: false, + isPopulated: false, + error: null + }; + + dispatch(updateItem({ + section, + ...item, + term, + isQueued: true, + items: [] + })); + + const itemIndex = queue.indexOf(item.id); + + if (itemIndex >= 0) { + queue.splice(itemIndex, 1); + } + + if (topOfQueue) { + queue.unshift(item.id); + } else { + queue.push(item.id); + } + + if (term && term.length > 2) { + dispatch(startLookupSeries({ start: true })); + } + }, + + [START_LOOKUP_SERIES]: function(getState, payload, dispatch) { + if (concurrentLookups >= 1) { + return; + } + + const state = getState().importSeries; + + const { + isLookingUpSeries, + items + } = state; + + const queueId = queue[0]; + + if (payload.start && !isLookingUpSeries) { + dispatch(set({ section, isLookingUpSeries: true })); + } else if (!isLookingUpSeries) { + return; + } else if (!queueId) { + dispatch(set({ section, isLookingUpSeries: false })); + return; + } + + concurrentLookups++; + queue.splice(0, 1); + + const queued = items.find((i) => i.id === queueId); + + dispatch(updateItem({ + section, + id: queued.id, + isFetching: true + })); + + const { request, abortRequest } = createAjaxRequest({ + url: '/series/lookup', + data: { + term: queued.term + } + }); + + abortCurrentLookup = abortRequest; + + request.done((data) => { + dispatch(updateItem({ + section, + id: queued.id, + isFetching: false, + isPopulated: true, + error: null, + items: data, + isQueued: false, + selectedSeries: queued.selectedSeries || data[0], + updateOnly: true + })); + }); + + request.fail((xhr) => { + dispatch(updateItem({ + section, + id: queued.id, + isFetching: false, + isPopulated: false, + error: xhr, + isQueued: false, + updateOnly: true + })); + }); + + request.always(() => { + concurrentLookups--; + + dispatch(startLookupSeries()); + }); + }, + + [LOOKUP_UNSEARCHED_SERIES]: function(getState, payload, dispatch) { + const state = getState().importSeries; + + if (state.isLookingUpSeries) { + return; + } + + state.items.forEach((item) => { + const id = item.id; + + if ( + !item.isPopulated && + !queue.includes(id) + ) { + queue.push(item.id); + } + }); + + if (queue.length) { + dispatch(startLookupSeries({ start: true })); + } + }, + + [IMPORT_SERIES]: function(getState, payload, dispatch) { + dispatch(set({ section, isImporting: true })); + + const ids = payload.ids; + const items = getState().importSeries.items; + const addedIds = []; + + const allNewSeries = ids.reduce((acc, id) => { + const item = _.find(items, { id }); + const selectedSeries = item.selectedSeries; + + // Make sure we have a selected series and + // the same series hasn't been added yet. + if (selectedSeries && !_.some(acc, { tvdbId: selectedSeries.tvdbId })) { + const newSeries = getNewSeries(_.cloneDeep(selectedSeries), item); + newSeries.path = item.path; + + addedIds.push(id); + acc.push(newSeries); + } + + return acc; + }, []); + + const promise = $.ajax({ + url: '/series/import', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(allNewSeries) + }); + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isImporting: false, + isImported: true + }), + + ...data.map((series) => updateItem({ section: 'series', ...series })), + + ...addedIds.map((id) => removeItem({ section, id })) + ])); + + dispatch(fetchRootFolders()); + }); + + promise.fail((xhr) => { + dispatch(batchActions( + set({ + section, + isImporting: false, + isImported: true + }), + + addedIds.map((id) => updateItem({ + section, + id, + importError: xhr + })) + )); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CANCEL_LOOKUP_SERIES]: function(state) { + queue.splice(0, queue.length); + + const items = state.items.map((item) => { + if (item.isQueued) { + return { + ...item, + isQueued: false + }; + } + + return item; + }); + + return Object.assign({}, state, { + isLookingUpSeries: false, + items + }); + }, + + [CLEAR_IMPORT_SERIES]: function(state) { + if (abortCurrentLookup) { + abortCurrentLookup(); + + abortCurrentLookup = null; + } + + queue.splice(0, queue.length); + + return Object.assign({}, state, defaultState); + }, + + [SET_IMPORT_SERIES_VALUE]: function(state, { payload }) { + const newState = getSectionState(state, section); + const items = newState.items; + const index = _.findIndex(items, { id: payload.id }); + + newState.items = [...items]; + + if (index >= 0) { + const item = items[index]; + + newState.items.splice(index, 1, { ...item, ...payload }); + } else { + newState.items.push({ ...payload }); + } + + return updateSectionState(state, section, newState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js new file mode 100644 index 000000000..015be478f --- /dev/null +++ b/frontend/src/Store/Actions/index.js @@ -0,0 +1,61 @@ +import * as addSeries from './addSeriesActions'; +import * as app from './appActions'; +import * as blacklist from './blacklistActions'; +import * as calendar from './calendarActions'; +import * as captcha from './captchaActions'; +import * as customFilters from './customFilterActions'; +import * as devices from './deviceActions'; +import * as commands from './commandActions'; +import * as episodes from './episodeActions'; +import * as episodeFiles from './episodeFileActions'; +import * as episodeHistory from './episodeHistoryActions'; +import * as history from './historyActions'; +import * as importSeries from './importSeriesActions'; +import * as interactiveImportActions from './interactiveImportActions'; +import * as oAuth from './oAuthActions'; +import * as organizePreview from './organizePreviewActions'; +import * as paths from './pathActions'; +import * as queue from './queueActions'; +import * as releases from './releaseActions'; +import * as rootFolders from './rootFolderActions'; +import * as seasonPass from './seasonPassActions'; +import * as series from './seriesActions'; +import * as seriesEditor from './seriesEditorActions'; +import * as seriesHistory from './seriesHistoryActions'; +import * as seriesIndex from './seriesIndexActions'; +import * as settings from './settingsActions'; +import * as system from './systemActions'; +import * as tags from './tagActions'; +import * as wanted from './wantedActions'; + +export default [ + addSeries, + app, + blacklist, + calendar, + captcha, + commands, + customFilters, + devices, + episodes, + episodeFiles, + episodeHistory, + history, + importSeries, + interactiveImportActions, + oAuth, + organizePreview, + paths, + queue, + releases, + rootFolders, + seasonPass, + series, + seriesEditor, + seriesHistory, + seriesIndex, + settings, + system, + tags, + wanted +]; diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js new file mode 100644 index 000000000..538790756 --- /dev/null +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -0,0 +1,204 @@ +import $ from 'jquery'; +import moment from 'moment'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { sortDirections } from 'Helpers/Props'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; + +// +// Variables + +export const section = 'interactiveImport'; + +const episodesSection = `${section}.episodes`; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + sortKey: 'quality', + sortDirection: sortDirections.DESCENDING, + recentFolders: [], + importMode: 'move', + sortPredicates: { + relativePath: function(item, direction) { + const relativePath = item.relativePath; + + return relativePath.toLowerCase(); + }, + + series: function(item, direction) { + const series = item.series; + + return series ? series.sortTitle : ''; + }, + + quality: function(item, direction) { + return item.quality ? item.quality.qualityWeight : 0; + } + }, + + episodes: { + isFetching: false, + isPopulated: false, + error: null, + sortKey: 'episodeNumber', + sortDirection: sortDirections.DESCENDING, + items: [] + } +}; + +export const persistState = [ + 'interactiveImport.recentFolders', + 'interactiveImport.importMode' +]; + +// +// Actions Types + +export const FETCH_INTERACTIVE_IMPORT_ITEMS = 'interactiveImport/fetchInteractiveImportItems'; +export const SET_INTERACTIVE_IMPORT_SORT = 'interactiveImport/setInteractiveImportSort'; +export const UPDATE_INTERACTIVE_IMPORT_ITEM = 'interactiveImport/updateInteractiveImportItem'; +export const CLEAR_INTERACTIVE_IMPORT = 'interactiveImport/clearInteractiveImport'; +export const ADD_RECENT_FOLDER = 'interactiveImport/addRecentFolder'; +export const REMOVE_RECENT_FOLDER = 'interactiveImport/removeRecentFolder'; +export const SET_INTERACTIVE_IMPORT_MODE = 'interactiveImport/setInteractiveImportMode'; + +export const FETCH_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/fetchInteractiveImportEpisodes'; +export const SET_INTERACTIVE_IMPORT_EPISODES_SORT = 'interactiveImport/setInteractiveImportEpisodesSort'; +export const CLEAR_INTERACTIVE_IMPORT_EPISODES = 'interactiveImport/clearInteractiveImportEpisodes'; + +// +// Action Creators + +export const fetchInteractiveImportItems = createThunk(FETCH_INTERACTIVE_IMPORT_ITEMS); +export const setInteractiveImportSort = createAction(SET_INTERACTIVE_IMPORT_SORT); +export const updateInteractiveImportItem = createAction(UPDATE_INTERACTIVE_IMPORT_ITEM); +export const clearInteractiveImport = createAction(CLEAR_INTERACTIVE_IMPORT); +export const addRecentFolder = createAction(ADD_RECENT_FOLDER); +export const removeRecentFolder = createAction(REMOVE_RECENT_FOLDER); +export const setInteractiveImportMode = createAction(SET_INTERACTIVE_IMPORT_MODE); + +export const fetchInteractiveImportEpisodes = createThunk(FETCH_INTERACTIVE_IMPORT_EPISODES); +export const setInteractiveImportEpisodesSort = createAction(SET_INTERACTIVE_IMPORT_EPISODES_SORT); +export const clearInteractiveImportEpisodes = createAction(CLEAR_INTERACTIVE_IMPORT_EPISODES); + +// +// Action Handlers +export const actionHandlers = handleThunks({ + [FETCH_INTERACTIVE_IMPORT_ITEMS]: function(getState, payload, dispatch) { + if (!payload.downloadId && !payload.folder) { + dispatch(set({ section, error: { message: '`downloadId` or `folder` is required.' } })); + return; + } + + dispatch(set({ section, isFetching: true })); + + const promise = $.ajax({ + url: '/manualimport', + data: payload + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [FETCH_INTERACTIVE_IMPORT_EPISODES]: createFetchHandler('interactiveImport.episodes', '/episode') +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [UPDATE_INTERACTIVE_IMPORT_ITEM]: (state, { payload }) => { + const id = payload.id; + const newState = Object.assign({}, state); + const items = newState.items; + const index = items.findIndex((item) => item.id === id); + const item = Object.assign({}, items[index], payload); + + newState.items = [...items]; + newState.items.splice(index, 1, item); + + return newState; + }, + + [ADD_RECENT_FOLDER]: function(state, { payload }) { + const folder = payload.folder; + const recentFolder = { folder, lastUsed: moment().toISOString() }; + const recentFolders = [...state.recentFolders]; + const index = recentFolders.findIndex((r) => r.folder === folder); + + if (index > -1) { + recentFolders.splice(index, 1, recentFolder); + } else { + recentFolders.push(recentFolder); + } + + return Object.assign({}, state, { recentFolders }); + }, + + [REMOVE_RECENT_FOLDER]: function(state, { payload }) { + const folder = payload.folder; + const recentFolders = [...state.recentFolders]; + const index = recentFolders.findIndex((r) => r.folder === folder); + + recentFolders.splice(index, 1); + + return Object.assign({}, state, { recentFolders }); + }, + + [CLEAR_INTERACTIVE_IMPORT]: function(state) { + const newState = { + ...defaultState, + recentFolders: state.recentFolders, + importMode: state.importMode + }; + + return newState; + }, + + [SET_INTERACTIVE_IMPORT_SORT]: createSetClientSideCollectionSortReducer(section), + + [SET_INTERACTIVE_IMPORT_MODE]: function(state, { payload }) { + return Object.assign({}, state, { importMode: payload.importMode }); + }, + + [SET_INTERACTIVE_IMPORT_EPISODES_SORT]: createSetClientSideCollectionSortReducer(episodesSection), + + [CLEAR_INTERACTIVE_IMPORT_EPISODES]: (state) => { + return updateSectionState(state, episodesSection, { + ...defaultState.episodes + }); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js new file mode 100644 index 000000000..e1d5cd124 --- /dev/null +++ b/frontend/src/Store/Actions/oAuthActions.js @@ -0,0 +1,205 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import requestAction from 'Utilities/requestAction'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { set } from 'Store/Actions/baseActions'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'oAuth'; +const callbackUrl = `${window.location.origin}${window.Sonarr.urlBase}/oauth.html`; + +// +// State + +export const defaultState = { + authorizing: false, + result: null, + error: null +}; + +// +// Actions Types + +export const START_OAUTH = 'oAuth/startOAuth'; +export const SET_OAUTH_VALUE = 'oAuth/setOAuthValue'; +export const RESET_OAUTH = 'oAuth/resetOAuth'; + +// +// Action Creators + +export const startOAuth = createThunk(START_OAUTH); +export const setOAuthValue = createAction(SET_OAUTH_VALUE); +export const resetOAuth = createAction(RESET_OAUTH); + +// +// Helpers + +function showOAuthWindow(url, payload) { + const deferred = $.Deferred(); + const selfWindow = window; + + const newWindow = window.open(url); + + if ( + !newWindow || + newWindow.closed || + typeof newWindow.closed == 'undefined' + ) { + + // A fake validation error to mimic a 400 response from the API. + const error = { + status: 400, + responseJSON: [ + { + propertyName: payload.name, + errorMessage: 'Pop-ups are being blocked by your browser' + } + ] + }; + + return deferred.reject(error).promise(); + } + + selfWindow.onCompleteOauth = function(query, onComplete) { + delete selfWindow.onCompleteOauth; + + const queryParams = {}; + const splitQuery = query.substring(1).split('&'); + + splitQuery.forEach((param) => { + if (param) { + const paramSplit = param.split('='); + + queryParams[paramSplit[0]] = paramSplit[1]; + } + }); + + onComplete(); + deferred.resolve(queryParams); + }; + + return deferred.promise(); +} + +function executeIntermediateRequest(payload, ajaxOptions) { + return $.ajax(ajaxOptions).then((data) => { + return requestAction({ + action: 'continueOAuth', + queryParams: { + ...data, + callbackUrl + }, + ...payload + }); + }); +} + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [START_OAUTH]: function(getState, payload, dispatch) { + const { + name, + section: actionSection, + ...otherPayload + } = payload; + + const actionPayload = { + action: 'startOAuth', + queryParams: { callbackUrl }, + ...otherPayload + }; + + dispatch(setOAuthValue({ + authorizing: true + })); + + let startResponse = {}; + + const promise = requestAction(actionPayload) + .then((response) => { + startResponse = response; + + if (response.oauthUrl) { + return showOAuthWindow(response.oauthUrl, payload); + } + + return executeIntermediateRequest(otherPayload, response).then((intermediateResponse) => { + startResponse = intermediateResponse; + + return showOAuthWindow(intermediateResponse.oauthUrl, payload); + }); + }) + .then((queryParams) => { + return requestAction({ + action: 'getOAuthToken', + queryParams: { + ...startResponse, + ...queryParams + }, + ...otherPayload + }); + }) + .then((response) => { + dispatch(setOAuthValue({ + authorizing: false, + result: response, + error: null + })); + }); + + promise.done(() => { + // Clear any previously set save error. + dispatch(set({ + section: actionSection, + saveError: null + })); + }); + + promise.fail((xhr) => { + const actions = [ + setOAuthValue({ + authorizing: false, + result: null, + error: xhr + }) + ]; + + if (xhr.status === 400) { + // Set a save error so the UI can display validation errors to the user. + actions.splice(0, 0, set({ + section: actionSection, + saveError: xhr + })); + } + + dispatch(batchActions(actions)); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_OAUTH_VALUE]: function(state, { payload }) { + const newState = Object.assign(getSectionState(state, section), payload); + + return updateSectionState(state, section, newState); + }, + + [RESET_OAUTH]: function(state) { + return updateSectionState(state, section, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/organizePreviewActions.js b/frontend/src/Store/Actions/organizePreviewActions.js new file mode 100644 index 000000000..78f943f32 --- /dev/null +++ b/frontend/src/Store/Actions/organizePreviewActions.js @@ -0,0 +1,51 @@ +import { createAction } from 'redux-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'organizePreview'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_ORGANIZE_PREVIEW = 'organizePreview/fetchOrganizePreview'; +export const CLEAR_ORGANIZE_PREVIEW = 'organizePreview/clearOrganizePreview'; + +// +// Action Creators + +export const fetchOrganizePreview = createThunk(FETCH_ORGANIZE_PREVIEW); +export const clearOrganizePreview = createAction(CLEAR_ORGANIZE_PREVIEW); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_ORGANIZE_PREVIEW]: createFetchHandler('organizePreview', '/rename') + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_ORGANIZE_PREVIEW]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/pathActions.js b/frontend/src/Store/Actions/pathActions.js new file mode 100644 index 000000000..129a9cb4d --- /dev/null +++ b/frontend/src/Store/Actions/pathActions.js @@ -0,0 +1,110 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set } from './baseActions'; + +// +// Variables + +export const section = 'paths'; + +// +// State + +export const defaultState = { + currentPath: '', + isPopulated: false, + isFetching: false, + error: null, + directories: [], + files: [], + parent: null +}; + +// +// Actions Types + +export const FETCH_PATHS = 'paths/fetchPaths'; +export const UPDATE_PATHS = 'paths/updatePaths'; +export const CLEAR_PATHS = 'paths/clearPaths'; + +// +// Action Creators + +export const fetchPaths = createThunk(FETCH_PATHS); +export const updatePaths = createAction(UPDATE_PATHS); +export const clearPaths = createAction(CLEAR_PATHS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_PATHS]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const { + path, + allowFoldersWithoutTrailingSlashes = false + } = payload; + + const promise = $.ajax({ + url: '/filesystem', + data: { + path, + allowFoldersWithoutTrailingSlashes + } + }); + + promise.done((data) => { + dispatch(updatePaths({ path, ...data })); + + dispatch(set({ + section, + isFetching: false, + isPopulated: true, + error: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [UPDATE_PATHS]: (state, { payload }) => { + const newState = Object.assign({}, state); + + newState.currentPath = payload.path; + newState.directories = payload.directories; + newState.files = payload.files; + newState.parent = payload.parent; + + return newState; + }, + + [CLEAR_PATHS]: (state, { payload }) => { + const newState = Object.assign({}, state); + + newState.path = ''; + newState.directories = []; + newState.files = []; + newState.parent = ''; + + return newState; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js new file mode 100644 index 000000000..bb6b3c1f2 --- /dev/null +++ b/frontend/src/Store/Actions/queueActions.js @@ -0,0 +1,438 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import { set, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'queue'; +const status = `${section}.status`; +const details = `${section}.details`; +const paged = `${section}.paged`; + +// +// State + +export const defaultState = { + options: { + includeUnknownSeriesItems: false + }, + + status: { + isFetching: false, + isPopulated: false, + error: null, + item: {} + }, + + details: { + isFetching: false, + isPopulated: false, + error: null, + items: [], + params: {} + }, + + paged: { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'timeleft', + sortDirection: sortDirections.ASCENDING, + error: null, + items: [], + isGrabbing: false, + isRemoving: false, + + columns: [ + { + name: 'status', + columnLabel: 'Status', + isVisible: true, + isModifiable: false + }, + { + name: 'series.sortTitle', + label: 'Series', + isSortable: true, + isVisible: true + }, + { + name: 'episode', + label: 'Episode', + isSortable: true, + isVisible: true + }, + { + name: 'episode.title', + label: 'Episode Title', + isVisible: true + }, + { + name: 'episode.airDateUtc', + label: 'Episode Air Date', + isSortable: true, + isVisible: false + }, + { + name: 'quality', + label: 'Quality', + isSortable: true, + isVisible: true + }, + { + name: 'protocol', + label: 'Protocol', + isSortable: true, + isVisible: false + }, + { + name: 'indexer', + label: 'Indexer', + isSortable: true, + isVisible: false + }, + { + name: 'downloadClient', + label: 'Download Client', + isSortable: true, + isVisible: false + }, + { + name: 'estimatedCompletionTime', + label: 'Timeleft', + isSortable: true, + isVisible: true + }, + { + name: 'progress', + label: 'Progress', + isSortable: true, + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ] + } +}; + +export const persistState = [ + 'queue.options', + 'queue.paged.pageSize', + 'queue.paged.sortKey', + 'queue.paged.sortDirection', + 'queue.paged.columns' +]; + +// +// Helpers + +function fetchDataAugmenter(getState, payload, data) { + data.includeUnknownSeriesItems = getState().queue.options.includeUnknownSeriesItems; +} + +// +// Actions Types + +export const FETCH_QUEUE_STATUS = 'queue/fetchQueueStatus'; + +export const FETCH_QUEUE_DETAILS = 'queue/fetchQueueDetails'; +export const CLEAR_QUEUE_DETAILS = 'queue/clearQueueDetails'; + +export const FETCH_QUEUE = 'queue/fetchQueue'; +export const GOTO_FIRST_QUEUE_PAGE = 'queue/gotoQueueFirstPage'; +export const GOTO_PREVIOUS_QUEUE_PAGE = 'queue/gotoQueuePreviousPage'; +export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage'; +export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage'; +export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage'; +export const SET_QUEUE_SORT = 'queue/setQueueSort'; +export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption'; +export const SET_QUEUE_OPTION = 'queue/setQueueOption'; +export const CLEAR_QUEUE = 'queue/clearQueue'; + +export const GRAB_QUEUE_ITEM = 'queue/grabQueueItem'; +export const GRAB_QUEUE_ITEMS = 'queue/grabQueueItems'; +export const REMOVE_QUEUE_ITEM = 'queue/removeQueueItem'; +export const REMOVE_QUEUE_ITEMS = 'queue/removeQueueItems'; + +// +// Action Creators + +export const fetchQueueStatus = createThunk(FETCH_QUEUE_STATUS); + +export const fetchQueueDetails = createThunk(FETCH_QUEUE_DETAILS); +export const clearQueueDetails = createAction(CLEAR_QUEUE_DETAILS); + +export const fetchQueue = createThunk(FETCH_QUEUE); +export const gotoQueueFirstPage = createThunk(GOTO_FIRST_QUEUE_PAGE); +export const gotoQueuePreviousPage = createThunk(GOTO_PREVIOUS_QUEUE_PAGE); +export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE); +export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE); +export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE); +export const setQueueSort = createThunk(SET_QUEUE_SORT); +export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION); +export const setQueueOption = createAction(SET_QUEUE_OPTION); +export const clearQueue = createAction(CLEAR_QUEUE); + +export const grabQueueItem = createThunk(GRAB_QUEUE_ITEM); +export const grabQueueItems = createThunk(GRAB_QUEUE_ITEMS); +export const removeQueueItem = createThunk(REMOVE_QUEUE_ITEM); +export const removeQueueItems = createThunk(REMOVE_QUEUE_ITEMS); + +// +// Helpers + +const fetchQueueDetailsHelper = createFetchHandler(details, '/queue/details'); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_QUEUE_STATUS]: createFetchHandler(status, '/queue/status'), + + [FETCH_QUEUE_DETAILS]: function(getState, payload, dispatch) { + let params = payload; + + // If the payload params are empty try to get params from state. + + if (params && !_.isEmpty(params)) { + dispatch(set({ section: details, params })); + } else { + params = getState().queue.details.params; + } + + // Ensure there are params before trying to fetch the queue + // so we don't make a bad request to the server. + + if (params && !_.isEmpty(params)) { + fetchQueueDetailsHelper(getState, params, dispatch); + } + }, + + ...createServerSideCollectionHandlers( + paged, + '/queue', + fetchQueue, + { + [serverSideCollectionHandlers.FETCH]: FETCH_QUEUE, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_QUEUE_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_QUEUE_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE, + [serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT + }, + fetchDataAugmenter + ), + + [GRAB_QUEUE_ITEM]: function(getState, payload, dispatch) { + const id = payload.id; + + dispatch(updateItem({ section: paged, id, isGrabbing: true })); + + const promise = $.ajax({ + url: `/queue/grab/${id}`, + method: 'POST' + }); + + promise.done((data) => { + dispatch(batchActions([ + fetchQueue(), + + set({ + section: paged, + isGrabbing: false, + grabError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + section: paged, + id, + isGrabbing: false, + grabError: xhr + })); + }); + }, + + [GRAB_QUEUE_ITEMS]: function(getState, payload, dispatch) { + const ids = payload.ids; + + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isGrabbing: true + }); + }), + + set({ + section: paged, + isGrabbing: true + }) + ])); + + const promise = $.ajax({ + url: '/queue/grab/bulk', + method: 'POST', + dataType: 'json', + data: JSON.stringify(payload) + }); + + promise.done((data) => { + dispatch(batchActions([ + fetchQueue(), + + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isGrabbing: false, + grabError: null + }); + }), + + set({ + section: paged, + isGrabbing: false, + grabError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isGrabbing: false, + grabError: null + }); + }), + + set({ section: paged, isGrabbing: false }) + ])); + }); + }, + + [REMOVE_QUEUE_ITEM]: function(getState, payload, dispatch) { + const { + id, + blacklist + } = payload; + + dispatch(updateItem({ section: paged, id, isRemoving: true })); + + const promise = $.ajax({ + url: `/queue/${id}?blacklist=${blacklist}`, + method: 'DELETE' + }); + + promise.done((data) => { + dispatch(fetchQueue()); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ section: paged, id, isRemoving: false })); + }); + }, + + [REMOVE_QUEUE_ITEMS]: function(getState, payload, dispatch) { + const { + ids, + blacklist + } = payload; + + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isRemoving: true + }); + }), + + set({ section: paged, isRemoving: true }) + ])); + + const promise = $.ajax({ + url: `/queue/bulk?blacklist=${blacklist}`, + method: 'DELETE', + dataType: 'json', + data: JSON.stringify({ ids }) + }); + + promise.done((data) => { + dispatch(batchActions([ + set({ section: paged, isRemoving: false }), + fetchQueue() + ])); + }); + + promise.fail((xhr) => { + dispatch(batchActions([ + ...ids.map((id) => { + return updateItem({ + section: paged, + id, + isRemoving: false + }); + }), + + set({ section: paged, isRemoving: false }) + ])); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_QUEUE_DETAILS]: createClearReducer(details, defaultState.details), + + [SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged), + + [SET_QUEUE_OPTION]: function(state, { payload }) { + const queueOptions = state.options; + + return { + ...state, + options: { + ...queueOptions, + ...payload + } + }; + }, + + [CLEAR_QUEUE]: createClearReducer(paged, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + }) + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/reducers.js b/frontend/src/Store/Actions/reducers.js new file mode 100644 index 000000000..0254cd226 --- /dev/null +++ b/frontend/src/Store/Actions/reducers.js @@ -0,0 +1,20 @@ +import { combineReducers } from 'redux'; +import { enableBatching } from 'redux-batched-actions'; +import { routerReducer } from 'react-router-redux'; +import actions from 'Store/Actions'; + +const defaultState = {}; + +const reducers = { + routing: routerReducer +}; + +actions.forEach((action) => { + const section = action.section; + + defaultState[section] = action.defaultState; + reducers[section] = action.reducers; +}); + +export { defaultState }; +export default enableBatching(combineReducers(reducers)); diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js new file mode 100644 index 000000000..1ea4fa6b3 --- /dev/null +++ b/frontend/src/Store/Actions/releaseActions.js @@ -0,0 +1,280 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'releases'; +export const episodeSection = 'releases.episode'; +export const seasonSection = 'releases.season'; + +let abortCurrentRequest = null; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + sortKey: 'releaseWeight', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + peers: function(item, direction) { + const seeders = item.seeders || 0; + const leechers = item.leechers || 0; + + return seeders * 1000000 + leechers; + }, + + rejections: function(item, direction) { + const rejections = item.rejections; + const releaseWeight = item.releaseWeight; + + if (rejections.length !== 0) { + return releaseWeight + 1000000; + } + + return releaseWeight; + } + }, + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'season-pack', + label: 'Season Pack', + filters: [ + { + key: 'fullSeason', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'not-season-pack', + label: 'Not Season Pack', + filters: [ + { + key: 'fullSeason', + value: false, + type: filterTypes.EQUAL + } + ] + } + ], + + filterPredicates: { + quality: function(item, value, type) { + const qualityId = item.quality.quality.id; + + if (type === filterTypes.EQUAL) { + return qualityId === value; + } + + if (type === filterTypes.NOT_EQUAL) { + return qualityId !== value; + } + + // Default to false + return false; + } + }, + + filterBuilderProps: [ + { + name: 'title', + label: 'Title', + type: filterBuilderTypes.STRING + }, + { + name: 'age', + label: 'Age', + type: filterBuilderTypes.NUMBER + }, + { + name: 'protocol', + label: 'Protocol', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.PROTOCOL + }, + { + name: 'indexerId', + label: 'Indexer', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.INDEXER + }, + { + name: 'size', + label: 'Size', + type: filterBuilderTypes.NUMBER + }, + { + name: 'seeders', + label: 'Seeders', + type: filterBuilderTypes.NUMBER + }, + { + name: 'peers', + label: 'Peers', + type: filterBuilderTypes.NUMBER + }, + { + name: 'quality', + label: 'Quality', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY + }, + { + name: 'rejections', + label: 'Rejections', + type: filterBuilderTypes.NUMBER + } + ], + + episode: { + selectedFilterKey: 'all' + }, + + season: { + selectedFilterKey: 'season-pack' + } +}; + +export const persistState = [ + 'releases.selectedFilterKey', + 'releases.episode.customFilters', + 'releases.season.customFilters' +]; + +// +// Actions Types + +export const FETCH_RELEASES = 'releases/fetchReleases'; +export const CANCEL_FETCH_RELEASES = 'releases/cancelFetchReleases'; +export const SET_RELEASES_SORT = 'releases/setReleasesSort'; +export const CLEAR_RELEASES = 'releases/clearReleases'; +export const GRAB_RELEASE = 'releases/grabRelease'; +export const UPDATE_RELEASE = 'releases/updateRelease'; +export const SET_EPISODE_RELEASES_FILTER = 'releases/setEpisodeReleasesFilter'; +export const SET_SEASON_RELEASES_FILTER = 'releases/setSeasonReleasesFilter'; + +// +// Action Creators + +export const fetchReleases = createThunk(FETCH_RELEASES); +export const cancelFetchReleases = createThunk(CANCEL_FETCH_RELEASES); +export const setReleasesSort = createAction(SET_RELEASES_SORT); +export const clearReleases = createAction(CLEAR_RELEASES); +export const grabRelease = createThunk(GRAB_RELEASE); +export const updateRelease = createAction(UPDATE_RELEASE); +export const setEpisodeReleasesFilter = createAction(SET_EPISODE_RELEASES_FILTER); +export const setSeasonReleasesFilter = createAction(SET_SEASON_RELEASES_FILTER); + +// +// Helpers + +const fetchReleasesHelper = createFetchHandler(section, '/release'); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_RELEASES]: function(getState, payload, dispatch) { + const abortRequest = fetchReleasesHelper(getState, payload, dispatch); + + abortCurrentRequest = abortRequest; + }, + + [CANCEL_FETCH_RELEASES]: function(getState, payload, dispatch) { + if (abortCurrentRequest) { + abortCurrentRequest = abortCurrentRequest(); + } + }, + + [GRAB_RELEASE]: function(getState, payload, dispatch) { + const guid = payload.guid; + + dispatch(updateRelease({ guid, isGrabbing: true })); + + const promise = $.ajax({ + url: '/release', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(payload) + }); + + promise.done((data) => { + dispatch(updateRelease({ + guid, + isGrabbing: false, + isGrabbed: true, + grabError: null + })); + }); + + promise.fail((xhr) => { + const grabError = xhr.responseJSON && xhr.responseJSON.message || 'Failed to add to download queue'; + + dispatch(updateRelease({ + guid, + isGrabbing: false, + isGrabbed: false, + grabError + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_RELEASES]: (state) => { + const { + episode, + season, + ...otherDefaultState + } = defaultState; + + return Object.assign({}, state, otherDefaultState); + }, + + [UPDATE_RELEASE]: (state, { payload }) => { + const guid = payload.guid; + const newState = Object.assign({}, state); + const items = newState.items; + + // Return early if there aren't any items (the user closed the modal) + if (!items.length) { + return; + } + + const index = items.findIndex((item) => item.guid === guid); + const item = Object.assign({}, items[index], payload); + + newState.items = [...items]; + newState.items.splice(index, 1, item); + + return newState; + }, + + [SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_EPISODE_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(episodeSection), + [SET_SEASON_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(seasonSection) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/rootFolderActions.js b/frontend/src/Store/Actions/rootFolderActions.js new file mode 100644 index 000000000..8180cdc7d --- /dev/null +++ b/frontend/src/Store/Actions/rootFolderActions.js @@ -0,0 +1,97 @@ +import $ from 'jquery'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import { set, updateItem } from './baseActions'; + +// +// Variables + +export const section = 'rootFolders'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_ROOT_FOLDERS = 'rootFolders/fetchRootFolders'; +export const ADD_ROOT_FOLDER = 'rootFolders/addRootFolder'; +export const DELETE_ROOT_FOLDER = 'rootFolders/deleteRootFolder'; + +// +// Action Creators + +export const fetchRootFolders = createThunk(FETCH_ROOT_FOLDERS); +export const addRootFolder = createThunk(ADD_ROOT_FOLDER); +export const deleteRootFolder = createThunk(DELETE_ROOT_FOLDER); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_ROOT_FOLDERS]: createFetchHandler('rootFolders', '/rootFolder'), + + [DELETE_ROOT_FOLDER]: createRemoveItemHandler( + 'rootFolders', + '/rootFolder', + (state) => state.rootFolders + ), + + [ADD_ROOT_FOLDER]: function(getState, payload, dispatch) { + const path = payload.path; + + dispatch(set({ + section, + isSaving: true + })); + + const promise = $.ajax({ + url: '/rootFolder', + method: 'POST', + data: JSON.stringify({ path }), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(batchActions([ + updateItem({ + section, + ...data + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/seasonPassActions.js b/frontend/src/Store/Actions/seasonPassActions.js new file mode 100644 index 000000000..d2040136d --- /dev/null +++ b/frontend/src/Store/Actions/seasonPassActions.js @@ -0,0 +1,164 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set } from './baseActions'; +import { fetchSeries, filters, filterPredicates } from './seriesActions'; + +// +// Variables + +export const section = 'seasonPass'; + +// +// State + +export const defaultState = { + isSaving: false, + saveError: null, + sortKey: 'sortTitle', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'sortTitle', + secondarySortDirection: sortDirections.ASCENDING, + selectedFilterKey: 'all', + filters, + filterPredicates, + + filterBuilderProps: [ + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'status', + label: 'Status', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.SERIES_STATUS + }, + { + name: 'seriesType', + label: 'Series Type', + type: filterBuilderTypes.EXACT + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY_PROFILE + }, + { + name: 'languageProfileId', + label: 'Language Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.LANGUAGE_PROFILE + }, + { + name: 'rootFolderPath', + label: 'Root Folder Path', + type: filterBuilderTypes.EXACT + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + } + ] +}; + +export const persistState = [ + 'seasonPass.sortKey', + 'seasonPass.sortDirection', + 'seasonPass.selectedFilterKey', + 'seasonPass.customFilters' +]; + +// +// Actions Types + +export const SET_SEASON_PASS_SORT = 'seasonPass/setSeasonPassSort'; +export const SET_SEASON_PASS_FILTER = 'seasonPass/setSeasonPassFilter'; +export const SAVE_SEASON_PASS = 'seasonPass/saveSeasonPass'; + +// +// Action Creators + +export const setSeasonPassSort = createAction(SET_SEASON_PASS_SORT); +export const setSeasonPassFilter = createAction(SET_SEASON_PASS_FILTER); +export const saveSeasonPass = createThunk(SAVE_SEASON_PASS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [SAVE_SEASON_PASS]: function(getState, payload, dispatch) { + const { + seriesIds, + monitored, + monitor + } = payload; + + const series = []; + + seriesIds.forEach((id) => { + const seriesToUpdate = { id }; + + if (payload.hasOwnProperty('monitored')) { + seriesToUpdate.monitored = monitored; + } + + series.push(seriesToUpdate); + }); + + dispatch(set({ + section, + isSaving: true + })); + + const promise = $.ajax({ + url: '/seasonPass', + method: 'POST', + data: JSON.stringify({ + series, + monitoringOptions: { monitor } + }), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(fetchSeries()); + + dispatch(set({ + section, + isSaving: false, + saveError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_SEASON_PASS_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_SEASON_PASS_FILTER]: createSetClientSideCollectionFilterReducer(section) + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/seriesActions.js b/frontend/src/Store/Actions/seriesActions.js new file mode 100644 index 000000000..b3e28cf44 --- /dev/null +++ b/frontend/src/Store/Actions/seriesActions.js @@ -0,0 +1,379 @@ +import _ from 'lodash'; +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate'; +import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createSaveProviderHandler from './Creators/createSaveProviderHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { updateItem } from './baseActions'; + +// +// Local + +const MONITOR_TIMEOUT = 1000; +const seasonsToUpdate = {}; +let seasonMonitorToggleTimeout = null; + +// +// Variables + +export const section = 'series'; + +export const filters = [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'monitored', + label: 'Monitored Only', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored Only', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'continuing', + label: 'Continuing Only', + filters: [ + { + key: 'status', + value: 'continuing', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'ended', + label: 'Ended Only', + filters: [ + { + key: 'status', + value: 'ended', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'missing', + label: 'Missing Episodes', + filters: [ + { + key: 'missing', + value: true, + type: filterTypes.EQUAL + } + ] + } +]; + +export const filterPredicates = { + missing: function(item) { + const { statistics = {} } = item; + + return statistics.episodeCount - statistics.episodeFileCount > 0; + }, + + nextAiring: function(item, filterValue, type) { + return dateFilterPredicate(item.nextAiring, filterValue, type); + }, + + previousAiring: function(item, filterValue, type) { + return dateFilterPredicate(item.previousAiring, filterValue, type); + }, + + added: function(item, filterValue, type) { + return dateFilterPredicate(item.added, filterValue, type); + }, + + ratings: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + + return predicate(item.ratings.value * 10, filterValue); + }, + + seasonCount: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + const seasonCount = item.statistics ? item.statistics.seasonCount : 0; + + return predicate(seasonCount, filterValue); + }, + + sizeOnDisk: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + const sizeOnDisk = item.statistics ? item.statistics.sizeOnDisk : 0; + + return predicate(sizeOnDisk, filterValue); + } +}; + +export const sortPredicates = { + status: function(item) { + let result = 0; + + if (item.monitored) { + result += 2; + } + + if (item.status === 'continuing') { + result++; + } + + return result; + } +}; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + items: [], + sortKey: 'sortTitle', + sortDirection: sortDirections.ASCENDING, + pendingChanges: {} +}; + +// +// Actions Types + +export const FETCH_SERIES = 'series/fetchSeries'; +export const SET_SERIES_VALUE = 'series/setSeriesValue'; +export const SAVE_SERIES = 'series/saveSeries'; +export const DELETE_SERIES = 'series/deleteSeries'; + +export const TOGGLE_SERIES_MONITORED = 'series/toggleSeriesMonitored'; +export const TOGGLE_SEASON_MONITORED = 'series/toggleSeasonMonitored'; + +// +// Action Creators + +export const fetchSeries = createThunk(FETCH_SERIES); +export const saveSeries = createThunk(SAVE_SERIES, (payload) => { + const newPayload = { + ...payload + }; + + if (payload.moveFiles) { + newPayload.queryParams = { + moveFiles: true + }; + } + + delete newPayload.moveFiles; + + return newPayload; +}); + +export const deleteSeries = createThunk(DELETE_SERIES, (payload) => { + return { + ...payload, + queryParams: { + deleteFiles: payload.deleteFiles + } + }; +}); + +export const toggleSeriesMonitored = createThunk(TOGGLE_SERIES_MONITORED); +export const toggleSeasonMonitored = createThunk(TOGGLE_SEASON_MONITORED); + +export const setSeriesValue = createAction(SET_SERIES_VALUE, (payload) => { + return { + section: 'series', + ...payload + }; +}); + +// +// Helpers + +function getSaveAjaxOptions({ ajaxOptions, payload }) { + if (payload.moveFolder) { + ajaxOptions.url = `${ajaxOptions.url}?moveFolder=true`; + } + + return ajaxOptions; +} + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_SERIES]: createFetchHandler(section, '/series'), + [SAVE_SERIES]: createSaveProviderHandler(section, '/series', { getAjaxOptions: getSaveAjaxOptions }), + [DELETE_SERIES]: createRemoveItemHandler(section, '/series'), + + [TOGGLE_SERIES_MONITORED]: (getState, payload, dispatch) => { + const { + seriesId: id, + monitored + } = payload; + + const series = _.find(getState().series.items, { id }); + + dispatch(updateItem({ + id, + section, + isSaving: true + })); + + const promise = $.ajax({ + url: `/series/${id}`, + method: 'PUT', + data: JSON.stringify({ + ...series, + monitored + }), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(updateItem({ + id, + section, + isSaving: false, + monitored + })); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + id, + section, + isSaving: false + })); + }); + }, + + [TOGGLE_SEASON_MONITORED]: function(getState, payload, dispatch) { + if (seasonMonitorToggleTimeout) { + seasonMonitorToggleTimeout = clearTimeout(seasonMonitorToggleTimeout); + } + + const { + seriesId: id, + seasonNumber, + monitored + } = payload; + + const series = getState().series.items.find((s) => s.id === id); + const seasons = _.cloneDeep(series.seasons); + const season = seasons.find((s) => s.seasonNumber === seasonNumber); + + season.isSaving = true; + + dispatch(updateItem({ + id, + section, + seasons + })); + + seasonsToUpdate[seasonNumber] = monitored; + season.monitored = monitored; + + seasonMonitorToggleTimeout = setTimeout(() => { + $.ajax({ + url: `/series/${id}`, + method: 'PUT', + data: JSON.stringify({ + ...series, + seasons + }), + dataType: 'json' + }).then( + (data) => { + const changedSeasons = []; + + data.seasons.forEach((s) => { + if (seasonsToUpdate.hasOwnProperty(s.seasonNumber)) { + if (s.monitored === seasonsToUpdate[s.seasonNumber]) { + changedSeasons.push(s); + } else { + s.isSaving = true; + } + } + }); + + const episodesToUpdate = getState().episodes.items.reduce((acc, episode) => { + if (episode.seriesId !== data.id) { + return acc; + } + + const changedSeason = changedSeasons.find((s) => s.seasonNumber === episode.seasonNumber); + + if (!changedSeason) { + return acc; + } + + acc.push(updateItem({ + id: episode.id, + section: 'episodes', + monitored: changedSeason.monitored + })); + + return acc; + }, []); + + dispatch(batchActions([ + updateItem({ + id, + section, + ...data + }), + + ...episodesToUpdate + ])); + + changedSeasons.forEach((s) => { + delete seasonsToUpdate[s.seasonNumber]; + }); + }, + (xhr) => { + dispatch(updateItem({ + id, + section, + seasons: series.seasons + })); + + Object.keys(seasonsToUpdate).forEach((s) => { + delete seasonsToUpdate[s]; + }); + }); + }, MONITOR_TIMEOUT); + } + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_SERIES_VALUE]: createSetSettingValueReducer(section) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/seriesEditorActions.js b/frontend/src/Store/Actions/seriesEditorActions.js new file mode 100644 index 000000000..cbfd78094 --- /dev/null +++ b/frontend/src/Store/Actions/seriesEditorActions.js @@ -0,0 +1,190 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { set, updateItem } from './baseActions'; +import { filters, filterPredicates, sortPredicates } from './seriesActions'; + +// +// Variables + +export const section = 'seriesEditor'; + +// +// State + +export const defaultState = { + isSaving: false, + saveError: null, + isDeleting: false, + deleteError: null, + sortKey: 'sortTitle', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'sortTitle', + secondarySortDirection: sortDirections.ASCENDING, + selectedFilterKey: 'all', + filters, + filterPredicates, + + filterBuilderProps: [ + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'status', + label: 'Status', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.SERIES_STATUS + }, + { + name: 'seriesType', + label: 'Series Type', + type: filterBuilderTypes.EXACT + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY_PROFILE + }, + { + name: 'languageProfileId', + label: 'Language Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.LANGUAGE_PROFILE + }, + { + name: 'path', + label: 'Path', + type: filterBuilderTypes.STRING + }, + { + name: 'rootFolderPath', + label: 'Root Folder Path', + type: filterBuilderTypes.EXACT + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + } + ], + + sortPredicates +}; + +export const persistState = [ + 'seriesEditor.sortKey', + 'seriesEditor.sortDirection', + 'seriesEditor.selectedFilterKey', + 'seriesEditor.customFilters' +]; + +// +// Actions Types + +export const SET_SERIES_EDITOR_SORT = 'seriesEditor/setSeriesEditorSort'; +export const SET_SERIES_EDITOR_FILTER = 'seriesEditor/setSeriesEditorFilter'; +export const SAVE_SERIES_EDITOR = 'seriesEditor/saveSeriesEditor'; +export const BULK_DELETE_SERIES = 'seriesEditor/bulkDeleteSeries'; +// +// Action Creators + +export const setSeriesEditorSort = createAction(SET_SERIES_EDITOR_SORT); +export const setSeriesEditorFilter = createAction(SET_SERIES_EDITOR_FILTER); +export const saveSeriesEditor = createThunk(SAVE_SERIES_EDITOR); +export const bulkDeleteSeries = createThunk(BULK_DELETE_SERIES); +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [SAVE_SERIES_EDITOR]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isSaving: true + })); + + const promise = $.ajax({ + url: '/series/editor', + method: 'PUT', + data: JSON.stringify(payload), + dataType: 'json' + }); + + promise.done((data) => { + dispatch(batchActions([ + ...data.map((series) => { + return updateItem({ + id: series.id, + section: 'series', + ...series + }); + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }, + + [BULK_DELETE_SERIES]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isDeleting: true + })); + + const promise = $.ajax({ + url: '/series/editor', + method: 'DELETE', + data: JSON.stringify(payload), + dataType: 'json' + }); + + promise.done(() => { + // SignaR will take care of removing the series from the collection + + dispatch(set({ + section, + isDeleting: false, + deleteError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_SERIES_EDITOR_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_SERIES_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(section) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/seriesHistoryActions.js b/frontend/src/Store/Actions/seriesHistoryActions.js new file mode 100644 index 000000000..348853441 --- /dev/null +++ b/frontend/src/Store/Actions/seriesHistoryActions.js @@ -0,0 +1,104 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; + +// +// Variables + +export const section = 'seriesHistory'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_SERIES_HISTORY = 'seriesHistory/fetchSeriesHistory'; +export const CLEAR_SERIES_HISTORY = 'seriesHistory/clearSeriesHistory'; +export const SERIES_HISTORY_MARK_AS_FAILED = 'seriesHistory/seriesHistoryMarkAsFailed'; + +// +// Action Creators + +export const fetchSeriesHistory = createThunk(FETCH_SERIES_HISTORY); +export const clearSeriesHistory = createAction(CLEAR_SERIES_HISTORY); +export const seriesHistoryMarkAsFailed = createThunk(SERIES_HISTORY_MARK_AS_FAILED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_SERIES_HISTORY]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const promise = $.ajax({ + url: '/history/series', + data: payload + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [SERIES_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) { + const { + historyId, + seriesId, + seasonNumber + } = payload; + + const promise = $.ajax({ + url: '/history/failed', + method: 'POST', + data: { + id: historyId + } + }); + + promise.done(() => { + dispatch(fetchSeriesHistory({ seriesId, seasonNumber })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_SERIES_HISTORY]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/seriesIndexActions.js b/frontend/src/Store/Actions/seriesIndexActions.js new file mode 100644 index 000000000..4ba268e73 --- /dev/null +++ b/frontend/src/Store/Actions/seriesIndexActions.js @@ -0,0 +1,427 @@ +import moment from 'moment'; +import { createAction } from 'redux-actions'; +import sortByName from 'Utilities/Array/sortByName'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createHandleActions from './Creators/createHandleActions'; +import { filters, filterPredicates, sortPredicates } from './seriesActions'; + +// +// Variables + +export const section = 'seriesIndex'; + +// +// State + +export const defaultState = { + sortKey: 'sortTitle', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'sortTitle', + secondarySortDirection: sortDirections.ASCENDING, + view: 'posters', + + posterOptions: { + detailedProgressBar: false, + size: 'large', + showTitle: false, + showMonitored: true, + showQualityProfile: true, + showSearchAction: false + }, + + overviewOptions: { + detailedProgressBar: false, + size: 'medium', + showMonitored: true, + showNetwork: true, + showQualityProfile: true, + showPreviousAiring: false, + showAdded: false, + showSeasonCount: true, + showPath: false, + showSizeOnDisk: false, + showSearchAction: false + }, + + tableOptions: { + showBanners: false, + showSearchAction: false + }, + + columns: [ + { + name: 'status', + columnLabel: 'Status', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'sortTitle', + label: 'Series Title', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'network', + label: 'Network', + isSortable: true, + isVisible: true + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + isSortable: true, + isVisible: true + }, + { + name: 'languageProfileId', + label: 'Language Profile', + isSortable: true, + isVisible: false + }, + { + name: 'nextAiring', + label: 'Next Airing', + isSortable: true, + isVisible: true + }, + { + name: 'previousAiring', + label: 'Previous Airing', + isSortable: true, + isVisible: false + }, + { + name: 'added', + label: 'Added', + isSortable: true, + isVisible: false + }, + { + name: 'seasonCount', + label: 'Seasons', + isSortable: true, + isVisible: true + }, + { + name: 'episodeProgress', + label: 'Episodes', + isSortable: true, + isVisible: true + }, + { + name: 'episodeCount', + label: 'Episode Count', + isSortable: true, + isVisible: false + }, + { + name: 'latestSeason', + label: 'Latest Season', + isSortable: true, + isVisible: false + }, + { + name: 'path', + label: 'Path', + isSortable: true, + isVisible: false + }, + { + name: 'sizeOnDisk', + label: 'Size on Disk', + isSortable: true, + isVisible: false + }, + { + name: 'genres', + label: 'Genres', + isSortable: false, + isVisible: false + }, + { + name: 'ratings', + label: 'Rating', + isSortable: true, + isVisible: false + }, + { + name: 'certification', + label: 'Certification', + isSortable: false, + isVisible: false + }, + { + name: 'tags', + label: 'Tags', + isSortable: false, + isVisible: false + }, + { + name: 'useSceneNumbering', + label: 'Scene Numbering', + isSortable: true, + isVisible: false + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ], + + sortPredicates: { + ...sortPredicates, + + network: function(item) { + const network = item.network; + + return network ? network.toLowerCase() : ''; + }, + + nextAiring: function(item, direction) { + const nextAiring = item.nextAiring; + + if (nextAiring) { + return moment(nextAiring).unix(); + } + + if (direction === sortDirections.DESCENDING) { + return 0; + } + + return Number.MAX_VALUE; + }, + + episodeProgress: function(item) { + const { statistics = {} } = item; + + const { + episodeCount = 0, + episodeFileCount + } = statistics; + + const progress = episodeCount ? episodeFileCount / episodeCount * 100 : 100; + + return progress + episodeCount / 1000000; + }, + + episodeCount: function(item) { + const { statistics = {} } = item; + + return statistics.episodeCount; + }, + + seasonCount: function(item) { + const { statistics = {} } = item; + + return statistics.seasonCount; + }, + + sizeOnDisk: function(item) { + const { statistics = {} } = item; + + return statistics.sizeOnDisk; + }, + + ratings: function(item) { + const { ratings = {} } = item; + + return ratings.value; + } + }, + + selectedFilterKey: 'all', + + filters, + filterPredicates, + + filterBuilderProps: [ + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'status', + label: 'Status', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.SERIES_STATUS + }, + { + name: 'network', + label: 'Network', + type: filterBuilderTypes.STRING + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY_PROFILE + }, + { + name: 'languageProfileId', + label: 'Language Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.LANGUAGE_PROFILE + }, + { + name: 'nextAiring', + label: 'Next Airing', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'previousAiring', + label: 'Previous Airing', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'added', + label: 'Added', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'seasonCount', + label: 'Season Count', + type: filterBuilderTypes.NUMBER + }, + { + name: 'episodeProgress', + label: 'Episode Progress', + type: filterBuilderTypes.NUMBER + }, + { + name: 'path', + label: 'Path', + type: filterBuilderTypes.STRING + }, + { + name: 'sizeOnDisk', + label: 'Size on Disk', + type: filterBuilderTypes.NUMBER, + valueType: filterBuilderValueTypes.BYTES + }, + { + name: 'genres', + label: 'Genres', + type: filterBuilderTypes.ARRAY, + optionsSelector: function(items) { + const tagList = items.reduce((acc, series) => { + series.genres.forEach((genre) => { + acc.push({ + id: genre, + name: genre + }); + }); + + return acc; + }, []); + + return tagList.sort(sortByName); + } + }, + { + name: 'ratings', + label: 'Rating', + type: filterBuilderTypes.NUMBER + }, + { + name: 'certification', + label: 'Certification', + type: filterBuilderTypes.EXACT + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + }, + { + name: 'useSceneNumbering', + label: 'Scene Numbering', + type: filterBuilderTypes.EXACT + } + ] +}; + +export const persistState = [ + 'seriesIndex.sortKey', + 'seriesIndex.sortDirection', + 'seriesIndex.selectedFilterKey', + 'seriesIndex.customFilters', + 'seriesIndex.view', + 'seriesIndex.columns', + 'seriesIndex.posterOptions', + 'seriesIndex.overviewOptions', + 'seriesIndex.tableOptions' +]; + +// +// Actions Types + +export const SET_SERIES_SORT = 'seriesIndex/setSeriesSort'; +export const SET_SERIES_FILTER = 'seriesIndex/setSeriesFilter'; +export const SET_SERIES_VIEW = 'seriesIndex/setSeriesView'; +export const SET_SERIES_TABLE_OPTION = 'seriesIndex/setSeriesTableOption'; +export const SET_SERIES_POSTER_OPTION = 'seriesIndex/setSeriesPosterOption'; +export const SET_SERIES_OVERVIEW_OPTION = 'seriesIndex/setSeriesOverviewOption'; + +// +// Action Creators + +export const setSeriesSort = createAction(SET_SERIES_SORT); +export const setSeriesFilter = createAction(SET_SERIES_FILTER); +export const setSeriesView = createAction(SET_SERIES_VIEW); +export const setSeriesTableOption = createAction(SET_SERIES_TABLE_OPTION); +export const setSeriesPosterOption = createAction(SET_SERIES_POSTER_OPTION); +export const setSeriesOverviewOption = createAction(SET_SERIES_OVERVIEW_OPTION); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_SERIES_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_SERIES_FILTER]: createSetClientSideCollectionFilterReducer(section), + + [SET_SERIES_VIEW]: function(state, { payload }) { + return Object.assign({}, state, { view: payload.view }); + }, + + [SET_SERIES_TABLE_OPTION]: createSetTableOptionReducer(section), + + [SET_SERIES_POSTER_OPTION]: function(state, { payload }) { + const posterOptions = state.posterOptions; + + return { + ...state, + posterOptions: { + ...posterOptions, + ...payload + } + }; + }, + + [SET_SERIES_OVERVIEW_OPTION]: function(state, { payload }) { + const overviewOptions = state.overviewOptions; + + return { + ...state, + overviewOptions: { + ...overviewOptions, + ...payload + } + }; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js new file mode 100644 index 000000000..b8640dead --- /dev/null +++ b/frontend/src/Store/Actions/settingsActions.js @@ -0,0 +1,134 @@ +import { createAction } from 'redux-actions'; +import { handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import delayProfiles from './Settings/delayProfiles'; +import downloadClients from './Settings/downloadClients'; +import downloadClientOptions from './Settings/downloadClientOptions'; +import general from './Settings/general'; +import indexerOptions from './Settings/indexerOptions'; +import indexers from './Settings/indexers'; +import languageProfiles from './Settings/languageProfiles'; +import mediaManagement from './Settings/mediaManagement'; +import metadata from './Settings/metadata'; +import naming from './Settings/naming'; +import namingExamples from './Settings/namingExamples'; +import notifications from './Settings/notifications'; +import qualityDefinitions from './Settings/qualityDefinitions'; +import qualityProfiles from './Settings/qualityProfiles'; +import releaseProfiles from './Settings/releaseProfiles'; +import remotePathMappings from './Settings/remotePathMappings'; +import ui from './Settings/ui'; + +export * from './Settings/delayProfiles'; +export * from './Settings/downloadClients'; +export * from './Settings/downloadClientOptions'; +export * from './Settings/general'; +export * from './Settings/indexerOptions'; +export * from './Settings/indexers'; +export * from './Settings/languageProfiles'; +export * from './Settings/mediaManagement'; +export * from './Settings/metadata'; +export * from './Settings/naming'; +export * from './Settings/namingExamples'; +export * from './Settings/notifications'; +export * from './Settings/qualityDefinitions'; +export * from './Settings/qualityProfiles'; +export * from './Settings/releaseProfiles'; +export * from './Settings/remotePathMappings'; +export * from './Settings/ui'; + +// +// Variables + +export const section = 'settings'; + +// +// State + +export const defaultState = { + advancedSettings: false, + + delayProfiles: delayProfiles.defaultState, + downloadClients: downloadClients.defaultState, + downloadClientOptions: downloadClientOptions.defaultState, + general: general.defaultState, + indexerOptions: indexerOptions.defaultState, + indexers: indexers.defaultState, + languageProfiles: languageProfiles.defaultState, + mediaManagement: mediaManagement.defaultState, + metadata: metadata.defaultState, + naming: naming.defaultState, + namingExamples: namingExamples.defaultState, + notifications: notifications.defaultState, + qualityDefinitions: qualityDefinitions.defaultState, + qualityProfiles: qualityProfiles.defaultState, + releaseProfiles: releaseProfiles.defaultState, + remotePathMappings: remotePathMappings.defaultState, + ui: ui.defaultState +}; + +export const persistState = [ + 'settings.advancedSettings' +]; + +// +// Actions Types + +export const TOGGLE_ADVANCED_SETTINGS = 'settings/toggleAdvancedSettings'; + +// +// Action Creators + +export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + ...delayProfiles.actionHandlers, + ...downloadClients.actionHandlers, + ...downloadClientOptions.actionHandlers, + ...general.actionHandlers, + ...indexerOptions.actionHandlers, + ...indexers.actionHandlers, + ...languageProfiles.actionHandlers, + ...mediaManagement.actionHandlers, + ...metadata.actionHandlers, + ...naming.actionHandlers, + ...namingExamples.actionHandlers, + ...notifications.actionHandlers, + ...qualityDefinitions.actionHandlers, + ...qualityProfiles.actionHandlers, + ...releaseProfiles.actionHandlers, + ...remotePathMappings.actionHandlers, + ...ui.actionHandlers +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [TOGGLE_ADVANCED_SETTINGS]: (state, { payload }) => { + return Object.assign({}, state, { advancedSettings: !state.advancedSettings }); + }, + + ...delayProfiles.reducers, + ...downloadClients.reducers, + ...downloadClientOptions.reducers, + ...general.reducers, + ...indexerOptions.reducers, + ...indexers.reducers, + ...languageProfiles.reducers, + ...mediaManagement.reducers, + ...metadata.reducers, + ...naming.reducers, + ...namingExamples.reducers, + ...notifications.reducers, + ...qualityDefinitions.reducers, + ...qualityProfiles.reducers, + ...releaseProfiles.reducers, + ...remotePathMappings.reducers, + ...ui.reducers + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js new file mode 100644 index 000000000..de156cde2 --- /dev/null +++ b/frontend/src/Store/Actions/systemActions.js @@ -0,0 +1,375 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import { setAppValue } from 'Store/Actions/appActions'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createFetchHandler from './Creators/createFetchHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import { set } from './baseActions'; + +// +// Variables + +export const section = 'system'; +const backupsSection = 'system.backups'; + +// +// State + +export const defaultState = { + status: { + isFetching: false, + isPopulated: false, + error: null, + item: {} + }, + + health: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + diskSpace: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + tasks: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + backups: { + isFetching: false, + isPopulated: false, + error: null, + isRestoring: false, + restoreError: null, + isDeleting: false, + deleteError: null, + items: [] + }, + + updates: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + logs: { + isFetching: false, + isPopulated: false, + pageSize: 50, + sortKey: 'time', + sortDirection: sortDirections.DESCENDING, + error: null, + items: [], + + columns: [ + { + name: 'level', + isSortable: true, + isVisible: true + }, + { + name: 'logger', + label: 'Component', + isSortable: true, + isVisible: true + }, + { + name: 'message', + label: 'Message', + isVisible: true + }, + { + name: 'time', + label: 'Time', + isSortable: true, + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isSortable: true, + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'info', + label: 'Info', + filters: [ + { + key: 'level', + value: 'info', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'warn', + label: 'Warn', + filters: [ + { + key: 'level', + value: 'warn', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'error', + label: 'Error', + filters: [ + { + key: 'level', + value: 'error', + type: filterTypes.EQUAL + } + ] + } + ] + }, + + logFiles: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + updateLogFiles: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + } +}; + +export const persistState = [ + 'system.logs.pageSize', + 'system.logs.sortKey', + 'system.logs.sortDirection', + 'system.logs.selectedFilterKey' +]; + +// +// Actions Types + +export const FETCH_STATUS = 'system/status/fetchStatus'; +export const FETCH_HEALTH = 'system/health/fetchHealth'; +export const FETCH_DISK_SPACE = 'system/diskSpace/fetchDiskSPace'; + +export const FETCH_TASK = 'system/tasks/fetchTask'; +export const FETCH_TASKS = 'system/tasks/fetchTasks'; + +export const FETCH_BACKUPS = 'system/backups/fetchBackups'; +export const RESTORE_BACKUP = 'system/backups/restoreBackup'; +export const CLEAR_RESTORE_BACKUP = 'system/backups/clearRestoreBackup'; +export const DELETE_BACKUP = 'system/backups/deleteBackup'; + +export const FETCH_UPDATES = 'system/updates/fetchUpdates'; + +export const FETCH_LOGS = 'system/logs/fetchLogs'; +export const GOTO_FIRST_LOGS_PAGE = 'system/logs/gotoLogsFirstPage'; +export const GOTO_PREVIOUS_LOGS_PAGE = 'system/logs/gotoLogsPreviousPage'; +export const GOTO_NEXT_LOGS_PAGE = 'system/logs/gotoLogsNextPage'; +export const GOTO_LAST_LOGS_PAGE = 'system/logs/gotoLogsLastPage'; +export const GOTO_LOGS_PAGE = 'system/logs/gotoLogsPage'; +export const SET_LOGS_SORT = 'system/logs/setLogsSort'; +export const SET_LOGS_FILTER = 'system/logs/setLogsFilter'; +export const SET_LOGS_TABLE_OPTION = 'system/logs/ssetLogsTableOption'; + +export const FETCH_LOG_FILES = 'system/logFiles/fetchLogFiles'; +export const FETCH_UPDATE_LOG_FILES = 'system/updateLogFiles/fetchUpdateLogFiles'; + +export const RESTART = 'system/restart'; +export const SHUTDOWN = 'system/shutdown'; + +// +// Action Creators + +export const fetchStatus = createThunk(FETCH_STATUS); +export const fetchHealth = createThunk(FETCH_HEALTH); +export const fetchDiskSpace = createThunk(FETCH_DISK_SPACE); + +export const fetchTask = createThunk(FETCH_TASK); +export const fetchTasks = createThunk(FETCH_TASKS); + +export const fetchBackups = createThunk(FETCH_BACKUPS); +export const restoreBackup = createThunk(RESTORE_BACKUP); +export const clearRestoreBackup = createAction(CLEAR_RESTORE_BACKUP); +export const deleteBackup = createThunk(DELETE_BACKUP); + +export const fetchUpdates = createThunk(FETCH_UPDATES); + +export const fetchLogs = createThunk(FETCH_LOGS); +export const gotoLogsFirstPage = createThunk(GOTO_FIRST_LOGS_PAGE); +export const gotoLogsPreviousPage = createThunk(GOTO_PREVIOUS_LOGS_PAGE); +export const gotoLogsNextPage = createThunk(GOTO_NEXT_LOGS_PAGE); +export const gotoLogsLastPage = createThunk(GOTO_LAST_LOGS_PAGE); +export const gotoLogsPage = createThunk(GOTO_LOGS_PAGE); +export const setLogsSort = createThunk(SET_LOGS_SORT); +export const setLogsFilter = createThunk(SET_LOGS_FILTER); +export const setLogsTableOption = createAction(SET_LOGS_TABLE_OPTION); + +export const fetchLogFiles = createThunk(FETCH_LOG_FILES); +export const fetchUpdateLogFiles = createThunk(FETCH_UPDATE_LOG_FILES); + +export const restart = createThunk(RESTART); +export const shutdown = createThunk(SHUTDOWN); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_STATUS]: createFetchHandler('system.status', '/system/status'), + [FETCH_HEALTH]: createFetchHandler('system.health', '/health'), + [FETCH_DISK_SPACE]: createFetchHandler('system.diskSpace', '/diskspace'), + [FETCH_TASK]: createFetchHandler('system.tasks', '/system/task'), + [FETCH_TASKS]: createFetchHandler('system.tasks', '/system/task'), + + [FETCH_BACKUPS]: createFetchHandler(backupsSection, '/system/backup'), + + [RESTORE_BACKUP]: function(getState, payload, dispatch) { + const { + id, + file + } = payload; + + dispatch(set({ + section: backupsSection, + isRestoring: true + })); + + let ajaxOptions = null; + + if (id) { + ajaxOptions = { + url: `/system/backup/restore/${id}`, + method: 'POST', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify({ + id + }) + }; + } else if (file) { + const formData = new FormData(); + formData.append('restore', file); + + ajaxOptions = { + url: '/system/backup/restore/upload', + method: 'POST', + processData: false, + contentType: false, + data: formData + }; + } else { + dispatch(set({ + section: backupsSection, + isRestoring: false, + restoreError: 'Error restoring backup' + })); + } + + const promise = $.ajax(ajaxOptions); + + promise.done((data) => { + dispatch(set({ + section: backupsSection, + isRestoring: false, + restoreError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section: backupsSection, + isRestoring: false, + restoreError: xhr + })); + }); + }, + + [DELETE_BACKUP]: createRemoveItemHandler(backupsSection, '/system/backup'), + + [FETCH_UPDATES]: createFetchHandler('system.updates', '/update'), + [FETCH_LOG_FILES]: createFetchHandler('system.logFiles', '/log/file'), + [FETCH_UPDATE_LOG_FILES]: createFetchHandler('system.updateLogFiles', '/log/file/update'), + + ...createServerSideCollectionHandlers( + 'system.logs', + '/log', + fetchLogs, + { + [serverSideCollectionHandlers.FETCH]: FETCH_LOGS, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_LOGS_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_LOGS_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_LOGS_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_LOGS_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_LOGS_PAGE, + [serverSideCollectionHandlers.SORT]: SET_LOGS_SORT, + [serverSideCollectionHandlers.FILTER]: SET_LOGS_FILTER + } + ), + + [RESTART]: function(getState, payload, dispatch) { + const promise = $.ajax({ + url: '/system/restart', + method: 'POST' + }); + + promise.done(() => { + dispatch(setAppValue({ isRestarting: true })); + }); + }, + + [SHUTDOWN]: function() { + $.ajax({ + url: '/system/shutdown', + method: 'POST' + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_RESTORE_BACKUP]: function(state, { payload }) { + return { + ...state, + backups: { + ...state.backups, + isRestoring: false, + restoreError: null + } + }; + }, + + [SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs') + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/tagActions.js b/frontend/src/Store/Actions/tagActions.js new file mode 100644 index 000000000..b9d217bd3 --- /dev/null +++ b/frontend/src/Store/Actions/tagActions.js @@ -0,0 +1,75 @@ +import $ from 'jquery'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createHandleActions from './Creators/createHandleActions'; +import { update } from './baseActions'; + +// +// Variables + +export const section = 'tags'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + + details: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + } +}; + +// +// Actions Types + +export const FETCH_TAGS = 'tags/fetchTags'; +export const ADD_TAG = 'tags/addTag'; +export const DELETE_TAG = 'tags/deleteTag'; +export const FETCH_TAG_DETAILS = 'tags/fetchTagDetails'; + +// +// Action Creators + +export const fetchTags = createThunk(FETCH_TAGS); +export const addTag = createThunk(ADD_TAG); +export const deleteTag = createThunk(DELETE_TAG); +export const fetchTagDetails = createThunk(FETCH_TAG_DETAILS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_TAGS]: createFetchHandler(section, '/tag'), + + [ADD_TAG]: function(getState, payload, dispatch) { + const promise = $.ajax({ + url: '/tag', + method: 'POST', + data: JSON.stringify(payload.tag) + }); + + promise.done((data) => { + const tags = getState().tags.items.slice(); + tags.push(data); + + dispatch(update({ section, data: tags })); + payload.onTagCreated(data); + }); + }, + + [DELETE_TAG]: createRemoveItemHandler(section, '/tag'), + [FETCH_TAG_DETAILS]: createFetchHandler('tags.details', '/tag/detail') + +}); + +// +// Reducers +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/wantedActions.js b/frontend/src/Store/Actions/wantedActions.js new file mode 100644 index 000000000..3d1d19cc5 --- /dev/null +++ b/frontend/src/Store/Actions/wantedActions.js @@ -0,0 +1,317 @@ +import { createAction } from 'redux-actions'; +import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; +import { filterTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createClearReducer from './Creators/Reducers/createClearReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createBatchToggleEpisodeMonitoredHandler from './Creators/createBatchToggleEpisodeMonitoredHandler'; +import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'wanted'; + +// +// State + +export const defaultState = { + missing: { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'airDateUtc', + sortDirection: sortDirections.DESCENDING, + error: null, + items: [], + + columns: [ + { + name: 'series.sortTitle', + label: 'Series Title', + isSortable: true, + isVisible: true + }, + { + name: 'episode', + label: 'Episode', + isVisible: true + }, + { + name: 'episodeTitle', + label: 'Episode Title', + isVisible: true + }, + { + name: 'airDateUtc', + label: 'Air Date', + isSortable: true, + isVisible: true + }, + { + name: 'status', + label: 'Status', + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'monitored', + + filters: [ + { + key: 'monitored', + label: 'Monitored', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + } + ] + }, + + cutoffUnmet: { + isFetching: false, + isPopulated: false, + pageSize: 20, + sortKey: 'airDateUtc', + sortDirection: sortDirections.DESCENDING, + items: [], + + columns: [ + { + name: 'series.sortTitle', + label: 'Series Title', + isSortable: true, + isVisible: true + }, + { + name: 'episode', + label: 'Episode', + isVisible: true + }, + { + name: 'episodeTitle', + label: 'Episode Title', + isVisible: true + }, + { + name: 'airDateUtc', + label: 'Air Date', + isSortable: true, + isVisible: true + }, + { + name: 'language', + label: 'Language', + isVisible: false + }, + { + name: 'status', + label: 'Status', + isVisible: true + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ], + + selectedFilterKey: 'monitored', + + filters: [ + { + key: 'monitored', + label: 'Monitored', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + } + ] + } +}; + +export const persistState = [ + 'wanted.missing.pageSize', + 'wanted.missing.sortKey', + 'wanted.missing.sortDirection', + 'wanted.missing.selectedFilterKey', + 'wanted.missing.columns', + 'wanted.cutoffUnmet.pageSize', + 'wanted.cutoffUnmet.sortKey', + 'wanted.cutoffUnmet.sortDirection', + 'wanted.cutoffUnmet.selectedFilterKey', + 'wanted.cutoffUnmet.columns' +]; + +// +// Actions Types + +export const FETCH_MISSING = 'wanted/missing/fetchMissing'; +export const GOTO_FIRST_MISSING_PAGE = 'wanted/missing/gotoMissingFirstPage'; +export const GOTO_PREVIOUS_MISSING_PAGE = 'wanted/missing/gotoMissingPreviousPage'; +export const GOTO_NEXT_MISSING_PAGE = 'wanted/missing/gotoMissingNextPage'; +export const GOTO_LAST_MISSING_PAGE = 'wanted/missing/gotoMissingLastPage'; +export const GOTO_MISSING_PAGE = 'wanted/missing/gotoMissingPage'; +export const SET_MISSING_SORT = 'wanted/missing/setMissingSort'; +export const SET_MISSING_FILTER = 'wanted/missing/setMissingFilter'; +export const SET_MISSING_TABLE_OPTION = 'wanted/missing/setMissingTableOption'; +export const CLEAR_MISSING = 'wanted/missing/clearMissing'; + +export const BATCH_TOGGLE_MISSING_EPISODES = 'wanted/missing/batchToggleMissingEpisodes'; + +export const FETCH_CUTOFF_UNMET = 'wanted/cutoffUnmet/fetchCutoffUnmet'; +export const GOTO_FIRST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFirstPage'; +export const GOTO_PREVIOUS_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPreviousPage'; +export const GOTO_NEXT_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetNextPage'; +export const GOTO_LAST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFastPage'; +export const GOTO_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPage'; +export const SET_CUTOFF_UNMET_SORT = 'wanted/cutoffUnmet/setCutoffUnmetSort'; +export const SET_CUTOFF_UNMET_FILTER = 'wanted/cutoffUnmet/setCutoffUnmetFilter'; +export const SET_CUTOFF_UNMET_TABLE_OPTION = 'wanted/cutoffUnmet/setCutoffUnmetTableOption'; +export const CLEAR_CUTOFF_UNMET = 'wanted/cutoffUnmet/clearCutoffUnmet'; + +export const BATCH_TOGGLE_CUTOFF_UNMET_EPISODES = 'wanted/cutoffUnmet/batchToggleCutoffUnmetEpisodes'; + +// +// Action Creators + +export const fetchMissing = createThunk(FETCH_MISSING); +export const gotoMissingFirstPage = createThunk(GOTO_FIRST_MISSING_PAGE); +export const gotoMissingPreviousPage = createThunk(GOTO_PREVIOUS_MISSING_PAGE); +export const gotoMissingNextPage = createThunk(GOTO_NEXT_MISSING_PAGE); +export const gotoMissingLastPage = createThunk(GOTO_LAST_MISSING_PAGE); +export const gotoMissingPage = createThunk(GOTO_MISSING_PAGE); +export const setMissingSort = createThunk(SET_MISSING_SORT); +export const setMissingFilter = createThunk(SET_MISSING_FILTER); +export const setMissingTableOption = createAction(SET_MISSING_TABLE_OPTION); +export const clearMissing = createAction(CLEAR_MISSING); + +export const batchToggleMissingEpisodes = createThunk(BATCH_TOGGLE_MISSING_EPISODES); + +export const fetchCutoffUnmet = createThunk(FETCH_CUTOFF_UNMET); +export const gotoCutoffUnmetFirstPage = createThunk(GOTO_FIRST_CUTOFF_UNMET_PAGE); +export const gotoCutoffUnmetPreviousPage = createThunk(GOTO_PREVIOUS_CUTOFF_UNMET_PAGE); +export const gotoCutoffUnmetNextPage = createThunk(GOTO_NEXT_CUTOFF_UNMET_PAGE); +export const gotoCutoffUnmetLastPage = createThunk(GOTO_LAST_CUTOFF_UNMET_PAGE); +export const gotoCutoffUnmetPage = createThunk(GOTO_CUTOFF_UNMET_PAGE); +export const setCutoffUnmetSort = createThunk(SET_CUTOFF_UNMET_SORT); +export const setCutoffUnmetFilter = createThunk(SET_CUTOFF_UNMET_FILTER); +export const setCutoffUnmetTableOption = createAction(SET_CUTOFF_UNMET_TABLE_OPTION); +export const clearCutoffUnmet = createAction(CLEAR_CUTOFF_UNMET); + +export const batchToggleCutoffUnmetEpisodes = createThunk(BATCH_TOGGLE_CUTOFF_UNMET_EPISODES); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + ...createServerSideCollectionHandlers( + 'wanted.missing', + '/wanted/missing', + fetchMissing, + { + [serverSideCollectionHandlers.FETCH]: FETCH_MISSING, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_MISSING_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_MISSING_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_MISSING_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_MISSING_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_MISSING_PAGE, + [serverSideCollectionHandlers.SORT]: SET_MISSING_SORT, + [serverSideCollectionHandlers.FILTER]: SET_MISSING_FILTER + } + ), + + [BATCH_TOGGLE_MISSING_EPISODES]: createBatchToggleEpisodeMonitoredHandler('wanted.missing', fetchMissing), + + ...createServerSideCollectionHandlers( + 'wanted.cutoffUnmet', + '/wanted/cutoff', + fetchCutoffUnmet, + { + [serverSideCollectionHandlers.FETCH]: FETCH_CUTOFF_UNMET, + [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_CUTOFF_UNMET_PAGE, + [serverSideCollectionHandlers.SORT]: SET_CUTOFF_UNMET_SORT, + [serverSideCollectionHandlers.FILTER]: SET_CUTOFF_UNMET_FILTER + } + ), + + [BATCH_TOGGLE_CUTOFF_UNMET_EPISODES]: createBatchToggleEpisodeMonitoredHandler('wanted.cutoffUnmet', fetchCutoffUnmet) + +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_MISSING_TABLE_OPTION]: createSetTableOptionReducer('wanted.missing'), + [SET_CUTOFF_UNMET_TABLE_OPTION]: createSetTableOptionReducer('wanted.cutoffUnmet'), + + [CLEAR_MISSING]: createClearReducer( + 'wanted.missing', + { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + } + ), + + [CLEAR_CUTOFF_UNMET]: createClearReducer( + 'wanted.cutoffUnmet', + { + isFetching: false, + isPopulated: false, + error: null, + items: [], + totalPages: 0, + totalRecords: 0 + } + ) + +}, defaultState, section); diff --git a/frontend/src/Store/Middleware/createPersistState.js b/frontend/src/Store/Middleware/createPersistState.js new file mode 100644 index 000000000..ffb2aa0d0 --- /dev/null +++ b/frontend/src/Store/Middleware/createPersistState.js @@ -0,0 +1,101 @@ +import _ from 'lodash'; +import persistState from 'redux-localstorage'; +import actions from 'Store/Actions'; +import migrate from 'Store/Migrators/migrate'; + +const columnPaths = []; + +const paths = _.reduce([...actions], (acc, action) => { + if (action.persistState) { + action.persistState.forEach((path) => { + if (path.match(/\.columns$/)) { + columnPaths.push(path); + } + + acc.push(path); + }); + } + + return acc; +}, []); + +function mergeColumns(path, initialState, persistedState, computedState) { + const initialColumns = _.get(initialState, path); + const persistedColumns = _.get(persistedState, path); + + if (!persistedColumns || !persistedColumns.length) { + return; + } + + const columns = []; + + initialColumns.forEach((initialColumn) => { + const persistedColumnIndex = _.findIndex(persistedColumns, { name: initialColumn.name }); + const column = Object.assign({}, initialColumn); + const persistedColumn = persistedColumnIndex > -1 ? persistedColumns[persistedColumnIndex] : undefined; + + if (persistedColumn) { + column.isVisible = persistedColumn.isVisible; + } + + // If there is a persisted column, it's index doesn't exceed the column list + // and it's modifiable, insert it in the proper position. + + if (persistedColumn && columns.length - 1 > persistedColumnIndex && persistedColumn.isModifiable !== false) { + columns.splice(persistedColumnIndex, 0, column); + } else { + columns.push(column); + } + + // Set the columns in the persisted state + _.set(computedState, path, columns); + }); +} + +function slicer(paths_) { + return (state) => { + const subset = {}; + + paths_.forEach((path) => { + _.set(subset, path, _.get(state, path)); + }); + + return subset; + }; +} + +function serialize(obj) { + return JSON.stringify(obj, null, 2); +} + +function merge(initialState, persistedState) { + if (!persistedState) { + return initialState; + } + + const computedState = {}; + + _.merge(computedState, initialState, persistedState); + + columnPaths.forEach((columnPath) => { + mergeColumns(columnPath, initialState, persistedState, computedState); + }); + + return computedState; +} + +const config = { + slicer, + serialize, + merge, + key: 'sonarr' +}; + +export default function createPersistState() { + // Migrate existing local storage before proceeding + const persistedState = JSON.parse(localStorage.getItem(config.key)); + migrate(persistedState); + localStorage.setItem(config.key, serialize(persistedState)); + + return persistState(paths, config); +} diff --git a/frontend/src/Store/Middleware/createSentryMiddleware.js b/frontend/src/Store/Middleware/createSentryMiddleware.js new file mode 100644 index 000000000..ef861379c --- /dev/null +++ b/frontend/src/Store/Middleware/createSentryMiddleware.js @@ -0,0 +1,91 @@ +import _ from 'lodash'; +import * as sentry from '@sentry/browser'; +import parseUrl from 'Utilities/String/parseUrl'; + +function cleanseUrl(url) { + const properties = parseUrl(url); + + return `${properties.pathname}${properties.search}`; +} + +function cleanseData(data) { + const result = _.cloneDeep(data); + + result.transaction = cleanseUrl(result.transaction); + + if (result.exception) { + result.exception.values.forEach((exception) => { + const stacktrace = exception.stacktrace; + + if (stacktrace) { + stacktrace.frames.forEach((frame) => { + frame.filename = cleanseUrl(frame.filename); + }); + } + }); + } + + result.request.url = cleanseUrl(result.request.url); + + return result; +} + +function identity(stuff) { + return stuff; +} + +function createMiddleware() { + return (store) => (next) => (action) => { + try { + // Adds a breadcrumb for reporting later (if necessary). + sentry.addBreadcrumb({ + category: 'redux', + message: action.type + }); + + return next(action); + } catch (err) { + console.error(`[sentry] Reporting error to Sentry: ${err}`); + + // Send the report including breadcrumbs. + sentry.captureException(err, { + extra: { + action: identity(action), + state: identity(store.getState()) + } + }); + } + }; +} + +export default function createSentryMiddleware() { + const { + analytics, + branch, + version, + release, + isProduction + } = window.Sonarr; + + if (!analytics) { + return; + } + + const dsn = isProduction ? 'https://b80ca60625b443c38b242e0d21681eb7@sentry.sonarr.tv/13' : + 'https://8dbaacdfe2ff4caf97dc7945aecf9ace@sentry.sonarr.tv/12'; + + sentry.init({ + dsn, + environment: isProduction ? 'production' : 'development', + release, + sendDefaultPii: true, + beforeSend: cleanseData + }); + + sentry.configureScope((scope) => { + scope.setTag('branch', branch); + scope.setTag('version', version); + }); + + return createMiddleware(); +} diff --git a/frontend/src/Store/Middleware/middlewares.js b/frontend/src/Store/Middleware/middlewares.js new file mode 100644 index 000000000..59937bc45 --- /dev/null +++ b/frontend/src/Store/Middleware/middlewares.js @@ -0,0 +1,25 @@ +import { applyMiddleware, compose } from 'redux'; +import thunk from 'redux-thunk'; +import { routerMiddleware } from 'react-router-redux'; +import createSentryMiddleware from './createSentryMiddleware'; +import createPersistState from './createPersistState'; + +export default function(history) { + const middlewares = []; + const sentryMiddleware = createSentryMiddleware(); + + if (sentryMiddleware) { + middlewares.push(sentryMiddleware); + } + + middlewares.push(routerMiddleware(history)); + middlewares.push(thunk); + + // eslint-disable-next-line no-underscore-dangle + const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; + + return composeEnhancers( + applyMiddleware(...middlewares), + createPersistState() + ); +} diff --git a/frontend/src/Store/Migrators/migrate.js b/frontend/src/Store/Migrators/migrate.js new file mode 100644 index 000000000..e8d637ff0 --- /dev/null +++ b/frontend/src/Store/Migrators/migrate.js @@ -0,0 +1,5 @@ +import migrateAddSeriesDefaults from './migrateAddSeriesDefaults'; + +export default function migrate(persistedState) { + migrateAddSeriesDefaults(persistedState); +} diff --git a/frontend/src/Store/Migrators/migrateAddSeriesDefaults.js b/frontend/src/Store/Migrators/migrateAddSeriesDefaults.js new file mode 100644 index 000000000..5aaf0a850 --- /dev/null +++ b/frontend/src/Store/Migrators/migrateAddSeriesDefaults.js @@ -0,0 +1,14 @@ +import { get } from 'lodash'; +import monitorOptions from 'Utilities/Series/monitorOptions'; + +export default function migrateAddSeriesDefaults(persistedState) { + const monitor = get(persistedState, 'addSeries.defaults.monitor'); + + if (!monitor) { + return; + } + + if (!monitorOptions.find((option) => option.key === monitor)) { + persistedState.addSeries.defaults.monitor = monitorOptions[0].key; + } +} diff --git a/frontend/src/Store/Selectors/createAllSeriesSelector.js b/frontend/src/Store/Selectors/createAllSeriesSelector.js new file mode 100644 index 000000000..6a1abdac4 --- /dev/null +++ b/frontend/src/Store/Selectors/createAllSeriesSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createAllSeriesSelector() { + return createSelector( + (state) => state.series, + (series) => { + return series.items; + } + ); +} + +export default createAllSeriesSelector; diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js new file mode 100644 index 000000000..929b0afe0 --- /dev/null +++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js @@ -0,0 +1,130 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; +import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props'; + +function getSortClause(sortKey, sortDirection, sortPredicates) { + if (sortPredicates && sortPredicates.hasOwnProperty(sortKey)) { + return function(item) { + return sortPredicates[sortKey](item, sortDirection); + }; + } + + return function(item) { + return item[sortKey]; + }; +} + +function filter(items, state) { + const { + selectedFilterKey, + filters, + customFilters, + filterPredicates + } = state; + + if (!selectedFilterKey) { + return items; + } + + const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters); + + return _.filter(items, (item) => { + let i = 0; + let accepted = true; + + while (accepted && i < selectedFilters.length) { + const { + key, + value, + type = filterTypes.EQUAL + } = selectedFilters[i]; + + if (filterPredicates && filterPredicates.hasOwnProperty(key)) { + const predicate = filterPredicates[key]; + + if (Array.isArray(value)) { + accepted = value.some((v) => predicate(item, v, type)); + } else { + accepted = predicate(item, value, type); + } + } else if (item.hasOwnProperty(key)) { + const predicate = filterTypePredicates[type]; + + if (Array.isArray(value)) { + accepted = value.some((v) => predicate(item[key], v)); + } else { + accepted = predicate(item[key], value); + } + } else { + // Default to false if the filter can't be tested + accepted = false; + } + + i++; + } + + return accepted; + }); +} + +function sort(items, state) { + const { + sortKey, + sortDirection, + sortPredicates, + secondarySortKey, + secondarySortDirection + } = state; + + const clauses = []; + const orders = []; + + clauses.push(getSortClause(sortKey, sortDirection, sortPredicates)); + orders.push(sortDirection === sortDirections.ASCENDING ? 'asc' : 'desc'); + + if (secondarySortKey && + secondarySortDirection && + (sortKey !== secondarySortKey || + sortDirection !== secondarySortDirection)) { + clauses.push(getSortClause(secondarySortKey, secondarySortDirection, sortPredicates)); + orders.push(secondarySortDirection === sortDirections.ASCENDING ? 'asc' : 'desc'); + } + + return _.orderBy(items, clauses, orders); +} + +function createCustomFiltersSelector(type, alternateType) { + return createSelector( + (state) => state.customFilters.items, + (customFilters) => { + return customFilters.filter((customFilter) => { + return customFilter.type === type || customFilter.type === alternateType; + }); + } + ); +} + +function createClientSideCollectionSelector(section, uiSection) { + return createSelector( + (state) => _.get(state, section), + (state) => _.get(state, uiSection), + createCustomFiltersSelector(section, uiSection), + (sectionState, uiSectionState = {}, customFilters) => { + const state = Object.assign({}, sectionState, uiSectionState, { customFilters }); + + const filtered = filter(state.items, state); + const sorted = sort(filtered, state); + + return { + ...sectionState, + ...uiSectionState, + customFilters, + items: sorted, + totalItems: state.items.length + }; + } + ); +} + +export default createClientSideCollectionSelector; diff --git a/frontend/src/Store/Selectors/createCommandExecutingSelector.js b/frontend/src/Store/Selectors/createCommandExecutingSelector.js new file mode 100644 index 000000000..6037d5820 --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandExecutingSelector.js @@ -0,0 +1,14 @@ +import { createSelector } from 'reselect'; +import { isCommandExecuting } from 'Utilities/Command'; +import createCommandSelector from './createCommandSelector'; + +function createCommandExecutingSelector(name, contraints = {}) { + return createSelector( + createCommandSelector(name, contraints), + (command) => { + return isCommandExecuting(command); + } + ); +} + +export default createCommandExecutingSelector; diff --git a/frontend/src/Store/Selectors/createCommandSelector.js b/frontend/src/Store/Selectors/createCommandSelector.js new file mode 100644 index 000000000..709dfebaf --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandSelector.js @@ -0,0 +1,14 @@ +import { createSelector } from 'reselect'; +import { findCommand } from 'Utilities/Command'; +import createCommandsSelector from './createCommandsSelector'; + +function createCommandSelector(name, contraints = {}) { + return createSelector( + createCommandsSelector(), + (commands) => { + return findCommand(commands, { name, ...contraints }); + } + ); +} + +export default createCommandSelector; diff --git a/frontend/src/Store/Selectors/createCommandsSelector.js b/frontend/src/Store/Selectors/createCommandsSelector.js new file mode 100644 index 000000000..7b9edffd9 --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createCommandsSelector() { + return createSelector( + (state) => state.commands, + (commands) => { + return commands.items; + } + ); +} + +export default createCommandsSelector; diff --git a/frontend/src/Store/Selectors/createDimensionsSelector.js b/frontend/src/Store/Selectors/createDimensionsSelector.js new file mode 100644 index 000000000..ce26b2e2c --- /dev/null +++ b/frontend/src/Store/Selectors/createDimensionsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createDimensionsSelector() { + return createSelector( + (state) => state.app.dimensions, + (dimensions) => { + return dimensions; + } + ); +} + +export default createDimensionsSelector; diff --git a/frontend/src/Store/Selectors/createEpisodeFileSelector.js b/frontend/src/Store/Selectors/createEpisodeFileSelector.js new file mode 100644 index 000000000..f58f000ff --- /dev/null +++ b/frontend/src/Store/Selectors/createEpisodeFileSelector.js @@ -0,0 +1,17 @@ +import { createSelector } from 'reselect'; + +function createEpisodeFileSelector() { + return createSelector( + (state, { episodeFileId }) => episodeFileId, + (state) => state.episodeFiles, + (episodeFileId, episodeFiles) => { + if (!episodeFileId) { + return; + } + + return episodeFiles.items.find((episodeFile) => episodeFile.id === episodeFileId); + } + ); +} + +export default createEpisodeFileSelector; diff --git a/frontend/src/Store/Selectors/createEpisodeSelector.js b/frontend/src/Store/Selectors/createEpisodeSelector.js new file mode 100644 index 000000000..6725cadd9 --- /dev/null +++ b/frontend/src/Store/Selectors/createEpisodeSelector.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import episodeEntities from 'Episode/episodeEntities'; + +function createEpisodeSelector() { + return createSelector( + (state, { episodeId }) => episodeId, + (state, { episodeEntity = episodeEntities.EPISODES }) => _.get(state, episodeEntity, { items: [] }), + (episodeId, episodes) => { + return _.find(episodes.items, { id: episodeId }); + } + ); +} + +export default createEpisodeSelector; diff --git a/frontend/src/Store/Selectors/createExistingSeriesSelector.js b/frontend/src/Store/Selectors/createExistingSeriesSelector.js new file mode 100644 index 000000000..77d18acee --- /dev/null +++ b/frontend/src/Store/Selectors/createExistingSeriesSelector.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from './createAllSeriesSelector'; + +function createExistingSeriesSelector() { + return createSelector( + (state, { tvdbId }) => tvdbId, + createAllSeriesSelector(), + (tvdbId, series) => { + return _.some(series, { tvdbId }); + } + ); +} + +export default createExistingSeriesSelector; diff --git a/frontend/src/Store/Selectors/createImportSeriesItemSelector.js b/frontend/src/Store/Selectors/createImportSeriesItemSelector.js new file mode 100644 index 000000000..dc6c28a05 --- /dev/null +++ b/frontend/src/Store/Selectors/createImportSeriesItemSelector.js @@ -0,0 +1,28 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from './createAllSeriesSelector'; + +function createImportSeriesItemSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.addSeries, + (state) => state.importSeries, + createAllSeriesSelector(), + (id, addSeries, importSeries, series) => { + const item = _.find(importSeries.items, { id }) || {}; + const selectedSeries = item && item.selectedSeries; + const isExistingSeries = !!selectedSeries && _.some(series, { tvdbId: selectedSeries.tvdbId }); + + return { + defaultMonitor: addSeries.defaults.monitor, + defaultQualityProfileId: addSeries.defaults.qualityProfileId, + defaultSeriesType: addSeries.defaults.seriesType, + defaultSeasonFolder: addSeries.defaults.seasonFolder, + ...item, + isExistingSeries + }; + } + ); +} + +export default createImportSeriesItemSelector; diff --git a/frontend/src/Store/Selectors/createLanguageProfileSelector.js b/frontend/src/Store/Selectors/createLanguageProfileSelector.js new file mode 100644 index 000000000..2ad04d506 --- /dev/null +++ b/frontend/src/Store/Selectors/createLanguageProfileSelector.js @@ -0,0 +1,14 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; + +function createLanguageProfileSelector() { + return createSelector( + (state, { languageProfileId }) => languageProfileId, + (state) => state.settings.languageProfiles.items, + (languageProfileId, languageProfiles) => { + return _.find(languageProfiles, { id: languageProfileId }); + } + ); +} + +export default createLanguageProfileSelector; diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.js b/frontend/src/Store/Selectors/createProfileInUseSelector.js new file mode 100644 index 000000000..540b61d26 --- /dev/null +++ b/frontend/src/Store/Selectors/createProfileInUseSelector.js @@ -0,0 +1,19 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from './createAllSeriesSelector'; + +function createProfileInUseSelector(profileProp) { + return createSelector( + (state, { id }) => id, + createAllSeriesSelector(), + (id, series) => { + if (!id) { + return false; + } + + return _.some(series, { [profileProp]: id }); + } + ); +} + +export default createProfileInUseSelector; diff --git a/frontend/src/Store/Selectors/createProviderSettingsSelector.js b/frontend/src/Store/Selectors/createProviderSettingsSelector.js new file mode 100644 index 000000000..46659609f --- /dev/null +++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.js @@ -0,0 +1,63 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; + +function createProviderSettingsSelector(sectionName) { + return createSelector( + (state, { id }) => id, + (state) => state.settings[sectionName], + (id, section) => { + if (!id) { + const item = _.isArray(section.schema) ? section.selectedSchema : section.schema; + const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError); + + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges, + ...settings, + item: settings.settings + }; + } + + const { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError); + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + ...settings, + item: settings.settings + }; + } + ); +} + +export default createProviderSettingsSelector; diff --git a/frontend/src/Store/Selectors/createQualityProfileSelector.js b/frontend/src/Store/Selectors/createQualityProfileSelector.js new file mode 100644 index 000000000..9308d63ac --- /dev/null +++ b/frontend/src/Store/Selectors/createQualityProfileSelector.js @@ -0,0 +1,14 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; + +function createQualityProfileSelector() { + return createSelector( + (state, { qualityProfileId }) => qualityProfileId, + (state) => state.settings.qualityProfiles.items, + (qualityProfileId, qualityProfiles) => { + return _.find(qualityProfiles, { id: qualityProfileId }); + } + ); +} + +export default createQualityProfileSelector; diff --git a/frontend/src/Store/Selectors/createQueueItemSelector.js b/frontend/src/Store/Selectors/createQueueItemSelector.js new file mode 100644 index 000000000..1172feb1e --- /dev/null +++ b/frontend/src/Store/Selectors/createQueueItemSelector.js @@ -0,0 +1,19 @@ +import { createSelector } from 'reselect'; + +function createQueueItemSelector() { + return createSelector( + (state, { episodeId }) => episodeId, + (state) => state.queue.details.items, + (episodeId, details) => { + if (!episodeId) { + return null; + } + + return details.find((item) => { + return item.episode.id === episodeId; + }); + } + ); +} + +export default createQueueItemSelector; diff --git a/frontend/src/Store/Selectors/createSeriesCountSelector.js b/frontend/src/Store/Selectors/createSeriesCountSelector.js new file mode 100644 index 000000000..f59d8ce5e --- /dev/null +++ b/frontend/src/Store/Selectors/createSeriesCountSelector.js @@ -0,0 +1,13 @@ +import { createSelector } from 'reselect'; +import createAllSeriesSelector from './createAllSeriesSelector'; + +function createSeriesCountSelector() { + return createSelector( + createAllSeriesSelector(), + (series) => { + return series.length; + } + ); +} + +export default createSeriesCountSelector; diff --git a/frontend/src/Store/Selectors/createSeriesSelector.js b/frontend/src/Store/Selectors/createSeriesSelector.js new file mode 100644 index 000000000..1c1ab5bb8 --- /dev/null +++ b/frontend/src/Store/Selectors/createSeriesSelector.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import createAllSeriesSelector from './createAllSeriesSelector'; + +function createSeriesSelector() { + return createSelector( + (state, { seriesId }) => seriesId, + createAllSeriesSelector(), + (seriesId, series) => { + return _.find(series, { id: seriesId }); + } + ); +} + +export default createSeriesSelector; diff --git a/frontend/src/Store/Selectors/createSettingsSectionSelector.js b/frontend/src/Store/Selectors/createSettingsSectionSelector.js new file mode 100644 index 000000000..a9f6cbff6 --- /dev/null +++ b/frontend/src/Store/Selectors/createSettingsSectionSelector.js @@ -0,0 +1,32 @@ +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; + +function createSettingsSectionSelector(section) { + return createSelector( + (state) => state.settings[section], + (sectionSettings) => { + const { + isFetching, + isPopulated, + error, + item, + pendingChanges, + isSaving, + saveError + } = sectionSettings; + + const settings = selectSettings(item, pendingChanges, saveError); + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + ...settings + }; + } + ); +} + +export default createSettingsSectionSelector; diff --git a/frontend/src/Store/Selectors/createSystemStatusSelector.js b/frontend/src/Store/Selectors/createSystemStatusSelector.js new file mode 100644 index 000000000..df586bbb9 --- /dev/null +++ b/frontend/src/Store/Selectors/createSystemStatusSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createSystemStatusSelector() { + return createSelector( + (state) => state.system.status, + (status) => { + return status.item; + } + ); +} + +export default createSystemStatusSelector; diff --git a/frontend/src/Store/Selectors/createTagDetailsSelector.js b/frontend/src/Store/Selectors/createTagDetailsSelector.js new file mode 100644 index 000000000..dd178944c --- /dev/null +++ b/frontend/src/Store/Selectors/createTagDetailsSelector.js @@ -0,0 +1,13 @@ +import { createSelector } from 'reselect'; + +function createTagDetailsSelector() { + return createSelector( + (state, { id }) => id, + (state) => state.tags.details.items, + (id, tagDetails) => { + return tagDetails.find((t) => t.id === id); + } + ); +} + +export default createTagDetailsSelector; diff --git a/frontend/src/Store/Selectors/createTagsSelector.js b/frontend/src/Store/Selectors/createTagsSelector.js new file mode 100644 index 000000000..fbfd91cdb --- /dev/null +++ b/frontend/src/Store/Selectors/createTagsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createTagsSelector() { + return createSelector( + (state) => state.tags.items, + (tags) => { + return tags; + } + ); +} + +export default createTagsSelector; diff --git a/frontend/src/Store/Selectors/createUISettingsSelector.js b/frontend/src/Store/Selectors/createUISettingsSelector.js new file mode 100644 index 000000000..b256d0e98 --- /dev/null +++ b/frontend/src/Store/Selectors/createUISettingsSelector.js @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; + +function createUISettingsSelector() { + return createSelector( + (state) => state.settings.ui, + (ui) => { + return ui.item; + } + ); +} + +export default createUISettingsSelector; diff --git a/frontend/src/Store/Selectors/selectSettings.js b/frontend/src/Store/Selectors/selectSettings.js new file mode 100644 index 000000000..3e30478b7 --- /dev/null +++ b/frontend/src/Store/Selectors/selectSettings.js @@ -0,0 +1,104 @@ +import _ from 'lodash'; + +function getValidationFailures(saveError) { + if (!saveError || saveError.status !== 400) { + return []; + } + + return _.cloneDeep(saveError.responseJSON); +} + +function mapFailure(failure) { + return { + message: failure.errorMessage, + link: failure.infoLink, + detailedMessage: failure.detailedDescription + }; +} + +function selectSettings(item, pendingChanges, saveError) { + const validationFailures = getValidationFailures(saveError); + + // Merge all settings from the item along with pending + // changes to ensure any settings that were not included + // with the item are included. + const allSettings = Object.assign({}, item, pendingChanges); + + const settings = _.reduce(allSettings, (result, value, key) => { + if (key === 'fields') { + return result; + } + + // Return a flattened value + if (key === 'implementationName') { + result.implementationName = item[key]; + + return result; + } + + const setting = { + value: item[key], + errors: _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === key.toLowerCase() && !failure.isWarning; + }), mapFailure), + + warnings: _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === key.toLowerCase() && failure.isWarning; + }), mapFailure) + }; + + if (pendingChanges.hasOwnProperty(key)) { + setting.previousValue = setting.value; + setting.value = pendingChanges[key]; + setting.pending = true; + } + + result[key] = setting; + return result; + }, {}); + + const fields = _.reduce(item.fields, (result, f) => { + const field = Object.assign({ pending: false }, f); + const hasPendingFieldChange = pendingChanges.fields && pendingChanges.fields.hasOwnProperty(field.name); + + if (hasPendingFieldChange) { + field.previousValue = field.value; + field.value = pendingChanges.fields[field.name]; + field.pending = true; + } + + field.errors = _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === field.name.toLowerCase() && !failure.isWarning; + }), mapFailure); + + field.warnings = _.map(_.remove(validationFailures, (failure) => { + return failure.propertyName.toLowerCase() === field.name.toLowerCase() && failure.isWarning; + }), mapFailure); + + result.push(field); + return result; + }, []); + + if (fields.length) { + settings.fields = fields; + } + + const validationErrors = _.filter(validationFailures, (failure) => { + return !failure.isWarning; + }); + + const validationWarnings = _.filter(validationFailures, (failure) => { + return failure.isWarning; + }); + + return { + settings, + validationErrors, + validationWarnings, + hasPendingChanges: !_.isEmpty(pendingChanges), + hasSettings: !_.isEmpty(settings), + pendingChanges + }; +} + +export default selectSettings; diff --git a/frontend/src/Store/createAppStore.js b/frontend/src/Store/createAppStore.js new file mode 100644 index 000000000..e05c80323 --- /dev/null +++ b/frontend/src/Store/createAppStore.js @@ -0,0 +1,15 @@ +import { createStore } from 'redux'; +import reducers, { defaultState } from 'Store/Actions/reducers'; +import middlewares from 'Store/Middleware/middlewares'; + +function createAppStore(history) { + const appStore = createStore( + reducers, + defaultState, + middlewares(history) + ); + + return appStore; +} + +export default createAppStore; diff --git a/frontend/src/Store/scrollPositions.js b/frontend/src/Store/scrollPositions.js new file mode 100644 index 000000000..39648c008 --- /dev/null +++ b/frontend/src/Store/scrollPositions.js @@ -0,0 +1,5 @@ +const scrollPositions = { + seriesIndex: 0 +}; + +export default scrollPositions; diff --git a/frontend/src/Store/thunks.js b/frontend/src/Store/thunks.js new file mode 100644 index 000000000..ebcf10917 --- /dev/null +++ b/frontend/src/Store/thunks.js @@ -0,0 +1,28 @@ +const thunks = {}; + +function identity(payload) { + return payload; +} + +export function createThunk(type, identityFunction = identity) { + return function(payload = {}) { + return function(dispatch, getState) { + const thunk = thunks[type]; + + if (thunk) { + return thunk(getState, identityFunction(payload), dispatch); + } + + throw Error(`Thunk handler has not been registered for ${type}`); + }; + }; +} + +export function handleThunks(handlers) { + const types = Object.keys(handlers); + + types.forEach((type) => { + thunks[type] = handlers[type]; + }); +} + diff --git a/frontend/src/Styles/Mixins/cover.css b/frontend/src/Styles/Mixins/cover.css new file mode 100644 index 000000000..e44c99be6 --- /dev/null +++ b/frontend/src/Styles/Mixins/cover.css @@ -0,0 +1,8 @@ +@define-mixin cover { + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + height: 100%; +} diff --git a/frontend/src/Styles/Mixins/linkOverlay.css b/frontend/src/Styles/Mixins/linkOverlay.css new file mode 100644 index 000000000..74c3fd753 --- /dev/null +++ b/frontend/src/Styles/Mixins/linkOverlay.css @@ -0,0 +1,11 @@ +@define-mixin linkOverlay { + @add-mixin cover; + + pointer-events: none; + user-select: none; + + a, + button { + pointer-events: all; + } +} diff --git a/frontend/src/Styles/Mixins/scroller.css b/frontend/src/Styles/Mixins/scroller.css new file mode 100644 index 000000000..62a619103 --- /dev/null +++ b/frontend/src/Styles/Mixins/scroller.css @@ -0,0 +1,26 @@ +@define-mixin scrollbar { + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } +} + +@define-mixin scrollbarTrack { + &&::-webkit-scrollbar-track { + background-color: transparent; + } +} + +@define-mixin scrollbarThumb { + &::-webkit-scrollbar-thumb { + min-height: 50px; + border: 1px solid transparent; + border-radius: 5px; + background-color: $scrollbarBackgroundColor; + background-clip: padding-box; + + &:hover { + background-color: $scrollbarHoverBackgroundColor; + } + } +} diff --git a/frontend/src/Styles/Mixins/truncate.css b/frontend/src/Styles/Mixins/truncate.css new file mode 100644 index 000000000..1941afc9b --- /dev/null +++ b/frontend/src/Styles/Mixins/truncate.css @@ -0,0 +1,18 @@ +/** + * From: https://github.com/suitcss/utils-text/blob/master/lib/text.css + * + * Text truncation + * + * Prevent text from wrapping onto multiple lines, and truncate with an + * ellipsis. + * + * 1. Ensure that the node has a maximum width after which truncation can + * occur. + */ + +@define-mixin truncate { + overflow: hidden !important; + max-width: 100%; /* 1 */ + text-overflow: ellipsis !important; + white-space: nowrap !important; +} diff --git a/frontend/src/Styles/Variables/animations.js b/frontend/src/Styles/Variables/animations.js new file mode 100644 index 000000000..52d12827a --- /dev/null +++ b/frontend/src/Styles/Variables/animations.js @@ -0,0 +1,8 @@ +// Use CommonJS since this is consumed by PostCSS via webpack (node.js). + +module.exports = { + // Durations + defaultSpeed: '0.2s', + slowSpeed: '0.6s', + fastSpeed: '0.1s' +}; diff --git a/frontend/src/Styles/Variables/colors.js b/frontend/src/Styles/Variables/colors.js new file mode 100644 index 000000000..4ded49029 --- /dev/null +++ b/frontend/src/Styles/Variables/colors.js @@ -0,0 +1,181 @@ +const sonarrBlue = '#35c5f4'; + +module.exports = { + defaultColor: '#333', + disabledColor: '#999', + dimColor: '#555', + black: '#000', + white: '#fff', + offWhite: '#f5f7fa', + primaryColor: '#5d9cec', + selectedColor: '#f9be03', + successColor: '#27c24c', + dangerColor: '#f05050', + warningColor: '#ffa500', + infoColor: sonarrBlue, + purple: '#7a43b6', + pink: '#ff69b4', + sonarrBlue, + helpTextColor: '#909293', + darkGray: '#888', + gray: '#adadad', + lightGray: '#ddd', + disabledInputColor: '#808080', + + // Theme Colors + + themeBlue: sonarrBlue, + themeAlternateBlue: '#2193b5', + themeRed: '#c4273c', + themeDarkColor: '#3a3f51', + themeLightColor: '#4f566f', + + torrentColor: '#00853d', + usenetColor: '#17b1d9', + + // Links + defaultLinkHoverColor: '#fff', + linkColor: '#5d9cec', + linkHoverColor: '#1b72e2', + + // Sidebar + + sidebarColor: '#e1e2e3', + sidebarBackgroundColor: '#3a3f51', + sidebarActiveBackgroundColor: '#252833', + + // Toolbar + toolbarColor: '#e1e2e3', + toolbarBackgroundColor: '#4f566f', + toolbarMenuItemBackgroundColor: '#454b60', + toolbarMenuItemHoverBackgroundColor: '#3a3f51', + toolbarLabelColor: '#8895aa', + + // Accents + borderColor: '#e5e5e5', + inputBorderColor: '#dde6e9', + inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)', + inputFocusBorderColor: '#66afe9', + inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)', + inputErrorBorderColor: '#f05050', + inputErrorBoxShadowColor: 'rgba(240, 80, 80, 0.6)', + inputWarningBorderColor: '#ffa500', + inputWarningBoxShadowColor: 'rgba(255, 165, 0, 0.6)', + colorImpairedGradient: '#fcfcfc', + + // + // Buttons + + defaultBackgroundColor: '#fff', + defaultBorderColor: '#eaeaea', + defaultHoverBackgroundColor: '#f5f5f5', + defaultHoverBorderColor: '#d6d6d6;', + + primaryBackgroundColor: '#5d9cec', + primaryBorderColor: '#5899eb', + primaryHoverBackgroundColor: '#4b91ea', + primaryHoverBorderColor: '#3483e7;', + + successBackgroundColor: '#27c24c', + successBorderColor: '#26be4a', + successHoverBackgroundColor: '#24b145', + successHoverBorderColor: '#1f9c3d;', + + warningBackgroundColor: '#ff902b', + warningBorderColor: '#ff8d26', + warningHoverBackgroundColor: '#ff8517', + warningHoverBorderColor: '#fc7800;', + + dangerBackgroundColor: '#f05050', + dangerBorderColor: '#f04b4b', + dangerHoverBackgroundColor: '#ee3d3d', + dangerHoverBorderColor: '#ec2626;', + + iconButtonDisabledColor: '#7a7a7a', + iconButtonHoverColor: '#666', + iconButtonHoverLightColor: '#ccc', + + // + // Modal + + modalBackdropBackgroundColor: 'rgba(0, 0, 0, 0.6)', + modalBackgroundColor: '#fff', + modalCloseButtonHoverColor: '#888', + + // + // Menu + menuItemColor: '#e1e2e3', + menuItemHoverColor: '#fbfcfc', + menuItemHoverBackgroundColor: '#f5f7fa', + + // + // Toolbar + + toobarButtonHoverColor: '#35c5f4', + toobarButtonSelectedColor: '#35c5f4', + + // + // Scroller + + scrollbarBackgroundColor: '#9ea4b9', + scrollbarHoverBackgroundColor: '#656d8c', + + // + // Card + + cardShadowColor: '#e1e1e1', + cardAlternateBackgroundColor: '#f5f5f5', + + // + // Alert + + alertDangerBorderColor: '#ebccd1', + alertDangerBackgroundColor: '#f2dede', + alertDangerColor: '#a94442', + + alertInfoBorderColor: '#bce8f1', + alertInfoBackgroundColor: '#d9edf7', + alertInfoColor: '#31708f', + + alertSuccessBorderColor: '#d6e9c6', + alertSuccessBackgroundColor: '#dff0d8', + alertSuccessColor: '#3c763d', + + alertWarningBorderColor: '#faebcc', + alertWarningBackgroundColor: '#fcf8e3', + alertWarningColor: '#8a6d3b', + + // + // Slider + + sliderAccentColor: '#5d9cec', + + // + // Form + + advancedFormLabelColor: '#ff902b', + disabledCheckInputColor: '#ddd', + + // + // Popover + + popoverTitleBackgroundColor: '#f7f7f7', + popoverTitleBorderColor: '#ebebeb', + popoverShadowColor: 'rgba(0, 0, 0, 0.2)', + popoverArrowBorderColor: 'rgba(0, 0, 0, 0.25)', + + popoverTitleBackgroundInverseColor: '#3a3f51', + popoverTitleBorderInverseColor: '#4f566f', + popoverShadowInverseColor: 'rgba(0, 0, 0, 0.2)', + popoverArrowBorderInverseColor: 'rgba(58, 63, 81, 0.75)', + + // + // Calendar + + calendarTodayBackgroundColor: '#ddd', + + // + // Table + + tableRowHoverBackgroundColor: '#fafbfc' +}; diff --git a/frontend/src/Styles/Variables/dimensions.js b/frontend/src/Styles/Variables/dimensions.js new file mode 100644 index 000000000..83f9e9d4e --- /dev/null +++ b/frontend/src/Styles/Variables/dimensions.js @@ -0,0 +1,53 @@ +module.exports = { + // Page + pageContentBodyPadding: '20px', + pageContentBodyPaddingSmallScreen: '10px', + + // Header + headerHeight: '60px', + + // Sidebar + sidebarWidth: '210px', + + // Toolbar + toolbarHeight: '60px', + toolbarButtonWidth: '60px', + toolbarSeparatorMargin: '20px', + + // Break Points + breakpointExtraSmall: '480px', + breakpointSmall: '768px', + breakpointMedium: '992px', + breakpointLarge: '1200px', + breakpointExtraLarge: '1450px', + + // Form + formGroupExtraSmallWidth: '550px', + formGroupSmallWidth: '650px', + formGroupMediumWidth: '800px', + formGroupLargeWidth: '1200px', + formLabelSmallWidth: '150px', + formLabelLargeWidth: '250px', + formLabelRightMarginWidth: '20px', + + // Drag + dragHandleWidth: '40px', + qualityProfileItemHeight: '30px', + qualityProfileItemDragSourcePadding: '4px', + + // Progress Bar + progressBarSmallHeight: '5px', + progressBarMediumHeight: '15px', + progressBarLargeHeight: '20px', + + // Jump Bar + jumpBarItemHeight: '25px', + + // Modal + modalBodyPadding: '30px', + + // Series + seriesIndexColumnPadding: '20px', + seriesIndexColumnPaddingSmallScreen: '10px', + seriesIndexOverviewInfoRowHeight: '21px' +}; diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js new file mode 100644 index 000000000..3b0077c5a --- /dev/null +++ b/frontend/src/Styles/Variables/fonts.js @@ -0,0 +1,15 @@ +module.exports = { + // Families + defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif', + monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;', + passwordFamily: 'text-security-disc', + + // Sizes + extraSmallFontSize: '11px', + smallFontSize: '12px', + defaultFontSize: '14px', + intermediateFontSize: '15px', + largeFontSize: '16px', + + lineHeight: '1.528571429' +}; diff --git a/frontend/src/Styles/globals.css b/frontend/src/Styles/globals.css new file mode 100644 index 000000000..70967f0c4 --- /dev/null +++ b/frontend/src/Styles/globals.css @@ -0,0 +1,7 @@ +/* stylelint-disable */ + +@import '~normalize.css/normalize.css'; +@import 'scaffolding.css'; +@import '../Content/Fonts/fonts.css'; + +/* stylelint-enable */ diff --git a/frontend/src/Styles/scaffolding.css b/frontend/src/Styles/scaffolding.css new file mode 100644 index 000000000..8d95f8d12 --- /dev/null +++ b/frontend/src/Styles/scaffolding.css @@ -0,0 +1,45 @@ +/* stylelint-disable */ +* { + box-sizing: border-box; +} + +*::before, +*::after { + box-sizing: border-box; +} + +*:focus { + outline: none; +} +/* stylelint-enable */ + +html, +body { + color: #515253; + font-family: 'Roboto', 'open sans', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; +} + +body { + font-size: 14px; + line-height: 1.528571429; /* 20/14 */ +} + +/* Override normalize */ + +button, +input, +optgroup, +select, +textarea { + margin: 0; + font-size: inherit; + font-family: inherit; + line-height: 1.528571429; /* 20/14 */ +} + +/* Better defaults for unordererd lists */ + +ul { + margin: 0; + padding-left: 20px; +} diff --git a/frontend/src/System/Backup/BackupRow.css b/frontend/src/System/Backup/BackupRow.css new file mode 100644 index 000000000..d83a22e25 --- /dev/null +++ b/frontend/src/System/Backup/BackupRow.css @@ -0,0 +1,12 @@ +.type { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 20px; + text-align: center; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} diff --git a/frontend/src/System/Backup/BackupRow.js b/frontend/src/System/Backup/BackupRow.js new file mode 100644 index 000000000..df5ff974c --- /dev/null +++ b/frontend/src/System/Backup/BackupRow.js @@ -0,0 +1,153 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import RestoreBackupModalConnector from './RestoreBackupModalConnector'; +import styles from './BackupRow.css'; + +class BackupRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRestoreModalOpen: false, + isConfirmDeleteModalOpen: false + }; + } + + // + // Listeners + + onRestorePress = () => { + this.setState({ isRestoreModalOpen: true }); + } + + onRestoreModalClose = () => { + this.setState({ isRestoreModalOpen: false }); + } + + onDeletePress = () => { + this.setState({ isConfirmDeleteModalOpen: true }); + } + + onConfirmDeleteModalClose = () => { + this.setState({ isConfirmDeleteModalOpen: false }); + } + + onConfirmDeletePress = () => { + const { + id, + onDeleteBackupPress + } = this.props; + + this.setState({ isConfirmDeleteModalOpen: false }, () => { + onDeleteBackupPress(id); + }); + } + + // + // Render + + render() { + const { + id, + type, + name, + path, + time + } = this.props; + + const { + isRestoreModalOpen, + isConfirmDeleteModalOpen + } = this.state; + + let iconClassName = icons.SCHEDULED; + let iconTooltip = 'Scheduled'; + + if (type === 'manual') { + iconClassName = icons.INTERACTIVE; + iconTooltip = 'Manual'; + } else if (type === 'update') { + iconClassName = icons.UPDATE; + iconTooltip = 'Before update'; + } + + return ( + + + { + + } + + + + + {name} + + + + + + + + + + + + + + + + ); + } +} + +BackupRow.propTypes = { + id: PropTypes.number.isRequired, + type: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + path: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + onDeleteBackupPress: PropTypes.func.isRequired +}; + +export default BackupRow; diff --git a/frontend/src/System/Backup/Backups.js b/frontend/src/System/Backup/Backups.js new file mode 100644 index 000000000..97167e6f2 --- /dev/null +++ b/frontend/src/System/Backup/Backups.js @@ -0,0 +1,166 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import BackupRow from './BackupRow'; +import RestoreBackupModalConnector from './RestoreBackupModalConnector'; + +const columns = [ + { + name: 'type', + isVisible: true + }, + { + name: 'name', + label: 'Name', + isVisible: true + }, + { + name: 'time', + label: 'Time', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +class Backups extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isRestoreModalOpen: false + }; + } + + // + // Listeners + + onRestorePress = () => { + this.setState({ isRestoreModalOpen: true }); + } + + onRestoreModalClose = () => { + this.setState({ isRestoreModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + backupExecuting, + onBackupPress, + onDeleteBackupPress + } = this.props; + + const hasBackups = isPopulated && !!items.length; + const noBackups = isPopulated && !items.length; + + return ( + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
Unable to load backups
+ } + + { + noBackups && +
No backups are available
+ } + + { + hasBackups && + + + { + items.map((item) => { + const { + id, + type, + name, + path, + time + } = item; + + return ( + + ); + }) + } + +
+ } +
+ + +
+ ); + } + +} + +Backups.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.array.isRequired, + backupExecuting: PropTypes.bool.isRequired, + onBackupPress: PropTypes.func.isRequired, + onDeleteBackupPress: PropTypes.func.isRequired +}; + +export default Backups; diff --git a/frontend/src/System/Backup/BackupsConnector.js b/frontend/src/System/Backup/BackupsConnector.js new file mode 100644 index 000000000..434354f5b --- /dev/null +++ b/frontend/src/System/Backup/BackupsConnector.js @@ -0,0 +1,84 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { fetchBackups, deleteBackup } from 'Store/Actions/systemActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import Backups from './Backups'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.backups, + createCommandExecutingSelector(commandNames.BACKUP), + (backups, backupExecuting) => { + const { + isFetching, + isPopulated, + error, + items + } = backups; + + return { + isFetching, + isPopulated, + error, + items, + backupExecuting + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchBackups() { + dispatch(fetchBackups()); + }, + + onDeleteBackupPress(id) { + dispatch(deleteBackup({ id })); + }, + + onBackupPress() { + dispatch(executeCommand({ + name: commandNames.BACKUP + })); + } + }; +} + +class BackupsConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchBackups(); + } + + componentDidUpdate(prevProps) { + if (prevProps.backupExecuting && !this.props.backupExecuting) { + this.props.dispatchFetchBackups(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +BackupsConnector.propTypes = { + backupExecuting: PropTypes.bool.isRequired, + dispatchFetchBackups: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(BackupsConnector); diff --git a/frontend/src/System/Backup/RestoreBackupModal.js b/frontend/src/System/Backup/RestoreBackupModal.js new file mode 100644 index 000000000..48dad4d2a --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import RestoreBackupModalContentConnector from './RestoreBackupModalContentConnector'; + +function RestoreBackupModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +RestoreBackupModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RestoreBackupModal; diff --git a/frontend/src/System/Backup/RestoreBackupModalConnector.js b/frontend/src/System/Backup/RestoreBackupModalConnector.js new file mode 100644 index 000000000..98cbcd11b --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalConnector.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { clearRestoreBackup } from 'Store/Actions/systemActions'; +import RestoreBackupModal from './RestoreBackupModal'; + +function createMapDispatchToProps(dispatch, props) { + return { + onModalClose() { + dispatch(clearRestoreBackup()); + + props.onModalClose(); + } + }; +} + +export default connect(null, createMapDispatchToProps)(RestoreBackupModal); diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.css b/frontend/src/System/Backup/RestoreBackupModalContent.css new file mode 100644 index 000000000..783443e33 --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalContent.css @@ -0,0 +1,24 @@ +.additionalInfo { + flex-grow: 1; + color: #777; +} + +.steps { + margin-top: 20px; +} + +.step { + display: flex; + font-size: $largeFontSize; + line-height: 20px; +} + +.stepState { + margin-right: 8px; +} + +@media only screen and (max-width: $breakpointSmall) { + composes: modalFooter from 'Components/Modal/ModalFooter.css'; + + flex-wrap: wrap; +} diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.js b/frontend/src/System/Backup/RestoreBackupModalContent.js new file mode 100644 index 000000000..2c42d7ab5 --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalContent.js @@ -0,0 +1,232 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import TextInput from 'Components/Form/TextInput'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './RestoreBackupModalContent.css'; + +function getErrorMessage(error) { + if (!error || !error.responseJSON || !error.responseJSON.message) { + return 'Error restoring backup'; + } + + return error.responseJSON.message; +} + +function getStepIconProps(isExecuting, hasExecuted, error) { + if (isExecuting) { + return { + name: icons.SPINNER, + isSpinning: true + }; + } + + if (hasExecuted) { + return { + name: icons.CHECK, + kind: kinds.SUCCESS + }; + } + + if (error) { + return { + name: icons.FATAL, + kinds: kinds.DANGER, + title: getErrorMessage(error) + }; + } + + return { + name: icons.PENDING + }; +} + +class RestoreBackupModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + file: null, + path: '', + isRestored: false, + isRestarted: false, + isReloading: false + }; + } + + componentDidUpdate(prevProps) { + const { + isRestoring, + restoreError, + isRestarting, + dispatchRestart + } = this.props; + + if (prevProps.isRestoring && !isRestoring && !restoreError) { + this.setState({ isRestored: true }, () => { + dispatchRestart(); + }); + } + + if (prevProps.isRestarting && !isRestarting) { + this.setState({ + isRestarted: true, + isReloading: true + }, () => { + location.reload(); + }); + } + } + + // + // Listeners + + onPathChange = ({ value, files }) => { + this.setState({ + file: files[0], + path: value + }); + } + + onRestorePress = () => { + const { + id, + onRestorePress + } = this.props; + + onRestorePress({ + id, + file: this.state.file + }); + } + + // + // Render + + render() { + const { + id, + name, + isRestoring, + restoreError, + isRestarting, + onModalClose + } = this.props; + + const { + path, + isRestored, + isRestarted, + isReloading + } = this.state; + + const isRestoreDisabled = ( + (!id && !path) || + isRestoring || + isRestarting || + isReloading + ); + + return ( + + + Restore Backup + + + + { + !!id && `Would you like to restore the backup '${name}'?` + } + + { + !id && + + } + +
+
+
+ +
+ +
Restore
+
+ +
+
+ +
+ +
Restart
+
+ +
+
+ +
+ +
Reload
+
+
+
+ + +
+ Note: Sonarr 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..1858d483a --- /dev/null +++ b/frontend/src/System/Events/LogsTable.js @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { align, icons } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import LogsTableRow from './LogsTableRow'; + +function LogsTable(props) { + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + totalRecords, + clearLogExecuting, + onRefreshPress, + onClearLogsPress, + onFilterSelect, + ...otherProps + } = props; + + return ( + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + isPopulated && !error && !items.length && +
+ No logs found +
+ } + + { + isPopulated && !error && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + +
+ } +
+
+ ); +} + +LogsTable.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + clearLogExecuting: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onClearLogsPress: PropTypes.func.isRequired +}; + +export default LogsTable; diff --git a/frontend/src/System/Events/LogsTableConnector.js b/frontend/src/System/Events/LogsTableConnector.js new file mode 100644 index 000000000..c02d67650 --- /dev/null +++ b/frontend/src/System/Events/LogsTableConnector.js @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as systemActions from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import LogsTable from './LogsTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.logs, + createCommandExecutingSelector(commandNames.CLEAR_LOGS), + (logs, clearLogExecuting) => { + return { + clearLogExecuting, + ...logs + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand, + ...systemActions +}; + +class LogsTableConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchLogs(); + } + + componentDidUpdate(prevProps) { + if (prevProps.clearLogExecuting && !this.props.clearLogExecuting) { + this.props.gotoLogsFirstPage(); + } + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoLogsFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoLogsPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoLogsNextPage(); + } + + onLastPagePress = () => { + this.props.gotoLogsLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoLogsPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setLogsSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setLogsFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setLogsTableOption(payload); + + if (payload.pageSize) { + this.props.gotoLogsFirstPage(); + } + } + + onRefreshPress = () => { + this.props.gotoLogsFirstPage(); + } + + onClearLogsPress = () => { + this.props.executeCommand({ name: commandNames.CLEAR_LOGS }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +LogsTableConnector.propTypes = { + clearLogExecuting: PropTypes.bool.isRequired, + fetchLogs: PropTypes.func.isRequired, + gotoLogsFirstPage: PropTypes.func.isRequired, + gotoLogsPreviousPage: PropTypes.func.isRequired, + gotoLogsNextPage: PropTypes.func.isRequired, + gotoLogsLastPage: PropTypes.func.isRequired, + gotoLogsPage: PropTypes.func.isRequired, + setLogsSort: PropTypes.func.isRequired, + setLogsFilter: PropTypes.func.isRequired, + setLogsTableOption: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(LogsTableConnector); diff --git a/frontend/src/System/Events/LogsTableDetailsModal.css b/frontend/src/System/Events/LogsTableDetailsModal.css new file mode 100644 index 000000000..127c1139f --- /dev/null +++ b/frontend/src/System/Events/LogsTableDetailsModal.css @@ -0,0 +1,17 @@ +.detailsText { + composes: scroller from 'Components/Scroller/Scroller.css'; + + display: block; + margin: 0 0 10.5px; + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px; + background-color: #f5f5f5; + color: #3a3f51; + white-space: pre; + word-wrap: break-word; + word-break: break-all; + font-size: 13px; + font-family: $monoSpaceFontFamily; + line-height: 1.52857143; +} diff --git a/frontend/src/System/Events/LogsTableDetailsModal.js b/frontend/src/System/Events/LogsTableDetailsModal.js new file mode 100644 index 000000000..de6a881df --- /dev/null +++ b/frontend/src/System/Events/LogsTableDetailsModal.js @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { scrollDirections } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Scroller from 'Components/Scroller/Scroller'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './LogsTableDetailsModal.css'; + +function LogsTableDetailsModal(props) { + const { + isOpen, + message, + exception, + onModalClose + } = props; + + return ( + + + + Details + + + +
Message
+ + + {message} + + + { + !!exception && +
+
Exception
+ + {exception} + +
+ } +
+ + + + +
+
+ ); +} + +LogsTableDetailsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + message: PropTypes.string.isRequired, + exception: PropTypes.string, + onModalClose: PropTypes.func.isRequired +}; + +export default LogsTableDetailsModal; diff --git a/frontend/src/System/Events/LogsTableRow.css b/frontend/src/System/Events/LogsTableRow.css new file mode 100644 index 000000000..557d690f0 --- /dev/null +++ b/frontend/src/System/Events/LogsTableRow.css @@ -0,0 +1,35 @@ +.level { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 20px; +} + +.info { + color: #1e90ff; +} + +.debug { + color: #808080; +} + +.trace { + color: #d3d3d3; +} + +.warn { + color: $warningColor; +} + +.error { + color: $dangerColor; +} + +.fatal { + color: $purple; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 45px; +} diff --git a/frontend/src/System/Events/LogsTableRow.js b/frontend/src/System/Events/LogsTableRow.js new file mode 100644 index 000000000..390769727 --- /dev/null +++ b/frontend/src/System/Events/LogsTableRow.js @@ -0,0 +1,158 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRowButton from 'Components/Table/TableRowButton'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import LogsTableDetailsModal from './LogsTableDetailsModal'; +import styles from './LogsTableRow.css'; + +function getIconName(level) { + switch (level) { + case 'trace': + case 'debug': + case 'info': + return icons.INFO; + case 'warn': + return icons.DANGER; + case 'error': + return icons.BUG; + case 'fatal': + return icons.FATAL; + default: + return icons.UNKNOWN; + } +} + +class LogsTableRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onPress = () => { + // Don't re-open the modal if it's already open + if (!this.state.isDetailsModalOpen) { + this.setState({ isDetailsModalOpen: true }); + } + } + + onModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + level, + logger, + message, + time, + exception, + columns + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'level') { + return ( + + + + ); + } + + if (name === 'logger') { + return ( + + {logger} + + ); + } + + if (name === 'message') { + return ( + + {message} + + ); + } + + if (name === 'time') { + return ( + + ); + } + + if (name === 'actions') { + return ( + + ); + } + + return null; + }) + } + + + + ); + } + +} + +LogsTableRow.propTypes = { + level: PropTypes.string.isRequired, + logger: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + time: PropTypes.string.isRequired, + exception: PropTypes.string, + columns: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default LogsTableRow; diff --git a/frontend/src/System/Logs/Files/LogFiles.js b/frontend/src/System/Logs/Files/LogFiles.js new file mode 100644 index 000000000..47482b3fe --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFiles.js @@ -0,0 +1,139 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import Alert from 'Components/Alert'; +import Link from 'Components/Link/Link'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import TableBody from 'Components/Table/TableBody'; +import LogsNavMenu from '../LogsNavMenu'; +import LogFilesTableRow from './LogFilesTableRow'; + +const columns = [ + { + name: 'filename', + label: 'Filename', + isVisible: true + }, + { + name: 'lastWriteTime', + label: 'Last Write Time', + isVisible: true + }, + { + name: 'download', + isVisible: true + } +]; + +class LogFiles extends Component { + + // + // Render + + render() { + const { + isFetching, + items, + deleteFilesExecuting, + currentLogView, + location, + onRefreshPress, + onDeleteFilesPress, + ...otherProps + } = this.props; + + return ( + + + + + + + + + + + + + + +
+ Log files are located in: {location} +
+ + { + currentLogView === 'Log Files' && +
+ The log level defaults to 'Info' and can be changed in General Settings +
+ } +
+ + { + isFetching && + + } + + { + !isFetching && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+
+ } + + { + !isFetching && !items.length && +
No log files
+ } +
+
+ ); + } + +} + +LogFiles.propTypes = { + isFetching: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired, + deleteFilesExecuting: PropTypes.bool.isRequired, + currentLogView: PropTypes.string.isRequired, + location: PropTypes.string.isRequired, + onRefreshPress: PropTypes.func.isRequired, + onDeleteFilesPress: PropTypes.func.isRequired +}; + +export default LogFiles; diff --git a/frontend/src/System/Logs/Files/LogFilesConnector.js b/frontend/src/System/Logs/Files/LogFilesConnector.js new file mode 100644 index 000000000..628bb571c --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFilesConnector.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import combinePath from 'Utilities/String/combinePath'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchLogFiles } from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import LogFiles from './LogFiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.logFiles, + (state) => state.system.status.item, + createCommandExecutingSelector(commandNames.DELETE_LOG_FILES), + (logFiles, status, deleteFilesExecuting) => { + const { + isFetching, + items + } = logFiles; + + const { + appData, + isWindows + } = status; + + return { + isFetching, + items, + deleteFilesExecuting, + currentLogView: 'Log Files', + location: combinePath(isWindows, appData, ['logs']) + }; + } + ); +} + +const mapDispatchToProps = { + fetchLogFiles, + executeCommand +}; + +class LogFilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchLogFiles(); + } + + componentDidUpdate(prevProps) { + if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) { + this.props.fetchLogFiles(); + } + } + + // + // Listeners + + onRefreshPress = () => { + this.props.fetchLogFiles(); + } + + onDeleteFilesPress = () => { + this.props.executeCommand({ name: commandNames.DELETE_LOG_FILES }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +LogFilesConnector.propTypes = { + deleteFilesExecuting: PropTypes.bool.isRequired, + fetchLogFiles: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(LogFilesConnector); diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.css b/frontend/src/System/Logs/Files/LogFilesTableRow.css new file mode 100644 index 000000000..779794b7d --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFilesTableRow.css @@ -0,0 +1,5 @@ +.download { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.js b/frontend/src/System/Logs/Files/LogFilesTableRow.js new file mode 100644 index 000000000..7ae61a531 --- /dev/null +++ b/frontend/src/System/Logs/Files/LogFilesTableRow.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './LogFilesTableRow.css'; + +class LogFilesTableRow extends Component { + + // + // Render + + render() { + const { + filename, + lastWriteTime, + downloadUrl + } = this.props; + + return ( + + {filename} + + + + + + Download + + + + ); + } + +} + +LogFilesTableRow.propTypes = { + filename: PropTypes.string.isRequired, + lastWriteTime: PropTypes.string.isRequired, + downloadUrl: PropTypes.string.isRequired +}; + +export default LogFilesTableRow; diff --git a/frontend/src/System/Logs/Logs.js b/frontend/src/System/Logs/Logs.js new file mode 100644 index 000000000..fa0be453e --- /dev/null +++ b/frontend/src/System/Logs/Logs.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; +import { Route } from 'react-router-dom'; +import Switch from 'Components/Router/Switch'; +import LogFilesConnector from './Files/LogFilesConnector'; +import UpdateLogFilesConnector from './Updates/UpdateLogFilesConnector'; + +class Logs extends Component { + + // + // Render + + render() { + return ( + + + + + + ); + } +} + +export default Logs; diff --git a/frontend/src/System/Logs/LogsNavMenu.js b/frontend/src/System/Logs/LogsNavMenu.js new file mode 100644 index 000000000..b69630248 --- /dev/null +++ b/frontend/src/System/Logs/LogsNavMenu.js @@ -0,0 +1,71 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; + +class LogsNavMenu extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMenuOpen: false + }; + } + + // + // Listeners + + onMenuButtonPress = () => { + this.setState({ isMenuOpen: !this.state.isMenuOpen }); + } + + onMenuItemPress = () => { + this.setState({ isMenuOpen: false }); + } + + // + // Render + + render() { + const { + current + } = this.props; + + return ( + + + {current} + + + + Log Files + + + + Updater Log Files + + + + ); + } +} + +LogsNavMenu.propTypes = { + current: PropTypes.string.isRequired +}; + +export default LogsNavMenu; diff --git a/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js new file mode 100644 index 000000000..3030c12ce --- /dev/null +++ b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import combinePath from 'Utilities/String/combinePath'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchUpdateLogFiles } from 'Store/Actions/systemActions'; +import * as commandNames from 'Commands/commandNames'; +import LogFiles from '../Files/LogFiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.updateLogFiles, + (state) => state.system.status.item, + createCommandExecutingSelector(commandNames.DELETE_UPDATE_LOG_FILES), + (updateLogFiles, status, deleteFilesExecuting) => { + const { + isFetching, + items + } = updateLogFiles; + + const { + appData, + isWindows + } = status; + + return { + isFetching, + items, + deleteFilesExecuting, + currentLogView: 'Updater Log Files', + location: combinePath(isWindows, appData, ['UpdateLogs']) + }; + } + ); +} + +const mapDispatchToProps = { + fetchUpdateLogFiles, + executeCommand +}; + +class UpdateLogFilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchUpdateLogFiles(); + } + + componentDidUpdate(prevProps) { + if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) { + this.props.fetchUpdateLogFiles(); + } + } + + // + // Listeners + + onRefreshPress = () => { + this.props.fetchUpdateLogFiles(); + } + + onDeleteFilesPress = () => { + this.props.executeCommand({ name: commandNames.DELETE_UPDATE_LOG_FILES }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UpdateLogFilesConnector.propTypes = { + deleteFilesExecuting: PropTypes.bool.isRequired, + fetchUpdateLogFiles: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(UpdateLogFilesConnector); diff --git a/frontend/src/System/Status/About/About.css b/frontend/src/System/Status/About/About.css new file mode 100644 index 000000000..fc20848b0 --- /dev/null +++ b/frontend/src/System/Status/About/About.css @@ -0,0 +1,5 @@ +.descriptionList { + composes: descriptionList from 'Components/DescriptionList/DescriptionList.css'; + + margin-bottom: 10px; +} diff --git a/frontend/src/System/Status/About/About.js b/frontend/src/System/Status/About/About.js new file mode 100644 index 000000000..0965baff4 --- /dev/null +++ b/frontend/src/System/Status/About/About.js @@ -0,0 +1,88 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import FieldSet from 'Components/FieldSet'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import StartTime from './StartTime'; +import styles from './About.css'; + +class About extends Component { + + // + // Render + + render() { + const { + version, + isMonoRuntime, + runtimeVersion, + appData, + startupPath, + mode, + startTime, + timeFormat, + longDateFormat + } = this.props; + + return ( +
+ + + + { + isMonoRuntime && + + } + + + + + + + + + } + /> + +
+ ); + } + +} + +About.propTypes = { + version: PropTypes.string.isRequired, + isMonoRuntime: PropTypes.bool.isRequired, + runtimeVersion: PropTypes.string.isRequired, + appData: PropTypes.string.isRequired, + startupPath: PropTypes.string.isRequired, + mode: PropTypes.string.isRequired, + startTime: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired +}; + +export default About; diff --git a/frontend/src/System/Status/About/AboutConnector.js b/frontend/src/System/Status/About/AboutConnector.js new file mode 100644 index 000000000..475d9778b --- /dev/null +++ b/frontend/src/System/Status/About/AboutConnector.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchStatus } from 'Store/Actions/systemActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import About from './About'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.status, + createUISettingsSelector(), + (status, uiSettings) => { + return { + ...status.item, + timeFormat: uiSettings.timeFormat, + longDateFormat: uiSettings.longDateFormat + }; + } + ); +} + +const mapDispatchToProps = { + fetchStatus +}; + +class AboutConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchStatus(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AboutConnector.propTypes = { + fetchStatus: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AboutConnector); diff --git a/frontend/src/System/Status/About/StartTime.js b/frontend/src/System/Status/About/StartTime.js new file mode 100644 index 000000000..94b4322d5 --- /dev/null +++ b/frontend/src/System/Status/About/StartTime.js @@ -0,0 +1,93 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; + +function getUptime(startTime) { + return formatTimeSpan(moment().diff(startTime)); +} + +class StartTime extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + startTime, + timeFormat, + longDateFormat + } = props; + + this._timeoutId = null; + + this.state = { + uptime: getUptime(startTime), + startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true }) + }; + } + + componentDidMount() { + this._timeoutId = setTimeout(this.onTimeout, 1000); + } + + componentDidUpdate(prevProps) { + const { + startTime, + timeFormat, + longDateFormat + } = this.props; + + if ( + startTime !== prevProps.startTime || + timeFormat !== prevProps.timeFormat || + longDateFormat !== prevProps.longDateFormat + ) { + this.setState({ + uptime: getUptime(startTime), + startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true }) + }); + } + } + + componentWillUnmount() { + if (this._timeoutId) { + this._timeoutId = clearTimeout(this._timeoutId); + } + } + + // + // Listeners + + onTimeout = () => { + this.setState({ uptime: getUptime(this.props.startTime) }); + this._timeoutId = setTimeout(this.onTimeout, 1000); + } + + // + // Render + + render() { + const { + uptime, + startTime + } = this.state; + + return ( + + {uptime} + + ); + } +} + +StartTime.propTypes = { + startTime: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired +}; + +export default StartTime; diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.css b/frontend/src/System/Status/DiskSpace/DiskSpace.css new file mode 100644 index 000000000..70ef6f884 --- /dev/null +++ b/frontend/src/System/Status/DiskSpace/DiskSpace.css @@ -0,0 +1,5 @@ +.space { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.js b/frontend/src/System/Status/DiskSpace/DiskSpace.js new file mode 100644 index 000000000..ad1de4d64 --- /dev/null +++ b/frontend/src/System/Status/DiskSpace/DiskSpace.js @@ -0,0 +1,120 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import formatBytes from 'Utilities/Number/formatBytes'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import ProgressBar from 'Components/ProgressBar'; +import styles from './DiskSpace.css'; + +const columns = [ + { + name: 'path', + label: 'Location', + isVisible: true + }, + { + name: 'freeSpace', + label: 'Free Space', + isVisible: true + }, + { + name: 'totalSpace', + label: 'Total Space', + isVisible: true + }, + { + name: 'progress', + isVisible: true + } +]; + +class DiskSpace extends Component { + + // + // Render + + render() { + const { + isFetching, + items + } = this.props; + + return ( +
+ { + isFetching && + + } + + { + !isFetching && + + + { + items.map((item) => { + const { + freeSpace, + totalSpace + } = item; + + const diskUsage = (100 - freeSpace / totalSpace * 100); + let diskUsageKind = kinds.PRIMARY; + + if (diskUsage > 90) { + diskUsageKind = kinds.DANGER; + } else if (diskUsage > 80) { + diskUsageKind = kinds.WARNING; + } + + return ( + + + {item.path} + + { + item.label && + ` (${item.label})` + } + + + + {formatBytes(freeSpace)} + + + + {formatBytes(totalSpace)} + + + + + + + ); + }) + } + +
+ } +
+ ); + } + +} + +DiskSpace.propTypes = { + isFetching: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default DiskSpace; diff --git a/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js new file mode 100644 index 000000000..3049b2ead --- /dev/null +++ b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchDiskSpace } from 'Store/Actions/systemActions'; +import DiskSpace from './DiskSpace'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.diskSpace, + (diskSpace) => { + const { + isFetching, + items + } = diskSpace; + + return { + isFetching, + items + }; + } + ); +} + +const mapDispatchToProps = { + fetchDiskSpace +}; + +class DiskSpaceConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchDiskSpace(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +DiskSpaceConnector.propTypes = { + fetchDiskSpace: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(DiskSpaceConnector); diff --git a/frontend/src/System/Status/Health/Health.css b/frontend/src/System/Status/Health/Health.css new file mode 100644 index 000000000..1aad8ee77 --- /dev/null +++ b/frontend/src/System/Status/Health/Health.css @@ -0,0 +1,21 @@ +.legend { + display: flex; + justify-content: space-between; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin-top: 2px; + margin-left: 10px; + text-align: left; +} + +.status { + width: 20px; +} + +.healthOk { + margin-bottom: 25px; +} + diff --git a/frontend/src/System/Status/Health/Health.js b/frontend/src/System/Status/Health/Health.js new file mode 100644 index 000000000..92c95022c --- /dev/null +++ b/frontend/src/System/Status/Health/Health.js @@ -0,0 +1,206 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FieldSet from 'Components/FieldSet'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './Health.css'; + +function getInternalLink(source) { + switch (source) { + case 'IndexerRssCheck': + case 'IndexerSearchCheck': + case 'IndexerStatusCheck': + return ( + + ); + case 'DownloadClientCheck': + case 'ImportMechanismCheck': + return ( + + ); + case 'RootFolderCheck': + return ( + + ); + case 'UpdateCheck': + return ( + + ); + default: + return; + } +} + +function getTestLink(source, props) { + switch (source) { + case 'IndexerStatusCheck': + return ( + + ); + case 'DownloadClientCheck': + return ( + + ); + + default: + break; + } +} + +const columns = [ + { + className: styles.status, + name: 'type', + isVisible: true + }, + { + name: 'message', + label: 'Message', + isVisible: true + }, + { + name: 'actions', + label: 'Actions', + isVisible: true + } +]; + +class Health extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + items + } = this.props; + + const healthIssues = !!items.length; + + return ( +
+ Health + + { + isFetching && isPopulated && + + } +
+ } + > + { + isFetching && !isPopulated && + + } + + { + !healthIssues && +
+ No issues with your configuration +
+ } + + { + healthIssues && + + + { + items.map((item) => { + const internalLink = getInternalLink(item.source); + const testLink = getTestLink(item.source, this.props); + + return ( + + + + + + {item.message} + + + + + { + internalLink + } + + { + !!testLink && + testLink + } + + + ); + }) + } + +
+ } + + ); + } + +} + +Health.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired, + isTestingAllDownloadClients: PropTypes.bool.isRequired, + isTestingAllIndexers: PropTypes.bool.isRequired, + dispatchTestAllDownloadClients: PropTypes.func.isRequired, + dispatchTestAllIndexers: PropTypes.func.isRequired +}; + +export default Health; diff --git a/frontend/src/System/Status/Health/HealthConnector.js b/frontend/src/System/Status/Health/HealthConnector.js new file mode 100644 index 000000000..d2adc41cc --- /dev/null +++ b/frontend/src/System/Status/Health/HealthConnector.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import { testAllDownloadClients, testAllIndexers } from 'Store/Actions/settingsActions'; +import Health from './Health'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.health, + (state) => state.settings.downloadClients.isTestingAll, + (state) => state.settings.indexers.isTestingAll, + (health, isTestingAllDownloadClients, isTestingAllIndexers) => { + const { + isFetching, + isPopulated, + items + } = health; + + return { + isFetching, + isPopulated, + items, + isTestingAllDownloadClients, + isTestingAllIndexers + }; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchHealth: fetchHealth, + dispatchTestAllDownloadClients: testAllDownloadClients, + dispatchTestAllIndexers: testAllIndexers +}; + +class HealthConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchHealth(); + } + + // + // Render + + render() { + const { + dispatchFetchHealth, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +HealthConnector.propTypes = { + dispatchFetchHealth: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(HealthConnector); diff --git a/frontend/src/System/Status/Health/HealthStatusConnector.js b/frontend/src/System/Status/Health/HealthStatusConnector.js new file mode 100644 index 000000000..181eae916 --- /dev/null +++ b/frontend/src/System/Status/Health/HealthStatusConnector.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHealth } from 'Store/Actions/systemActions'; +import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app, + (state) => state.system.health, + (app, health) => { + const count = health.items.length; + let errors = false; + let warnings = false; + + health.items.forEach((item) => { + if (item.type === 'error') { + errors = true; + } + + if (item.type === 'warning') { + warnings = true; + } + }); + + return { + isConnected: app.isConnected, + isReconnecting: app.isReconnecting, + isPopulated: health.isPopulated, + count, + errors, + warnings + }; + } + ); +} + +const mapDispatchToProps = { + fetchHealth +}; + +class HealthStatusConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + if (!this.props.isPopulated) { + this.props.fetchHealth(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.isConnected && prevProps.isReconnecting) { + this.props.fetchHealth(); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +HealthStatusConnector.propTypes = { + isConnected: PropTypes.bool.isRequired, + isReconnecting: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + fetchHealth: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(HealthStatusConnector); diff --git a/frontend/src/System/Status/MoreInfo/MoreInfo.js b/frontend/src/System/Status/MoreInfo/MoreInfo.js new file mode 100644 index 000000000..4ac04c4e7 --- /dev/null +++ b/frontend/src/System/Status/MoreInfo/MoreInfo.js @@ -0,0 +1,73 @@ +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import FieldSet from 'Components/FieldSet'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; + +class MoreInfo extends Component { + + // + // Render + + render() { + return ( +
+ + Home page + + sonarr.tv + + + Wiki + + wiki.sonarr.tv + + + Forums + + forums.sonarr.tv + + + Twitter + + @sonarrtv + + + IRC + + #sonarr on Freenode + + + Freenode webchat + + + Donations + + sonarr.tv/donate + + + Source + + github.com/Sonarr/Sonarr + + + Feature Requests + + forums.sonarr.tv + + + github.com/Sonarr/Sonarr/issues + + + +
+ ); + } +} + +MoreInfo.propTypes = { + +}; + +export default MoreInfo; diff --git a/frontend/src/System/Status/Status.js b/frontend/src/System/Status/Status.js new file mode 100644 index 000000000..f0b157515 --- /dev/null +++ b/frontend/src/System/Status/Status.js @@ -0,0 +1,29 @@ +import React, { Component } from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import HealthConnector from './Health/HealthConnector'; +import DiskSpaceConnector from './DiskSpace/DiskSpaceConnector'; +import AboutConnector from './About/AboutConnector'; +import MoreInfo from './MoreInfo/MoreInfo'; + +class Status extends Component { + + // + // Render + + render() { + return ( + + + + + + + + + ); + } + +} + +export default Status; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.css b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css new file mode 100644 index 000000000..30f86efff --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.css @@ -0,0 +1,31 @@ +.trigger { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.triggerContent { + display: flex; + justify-content: space-between; + width: 100%; +} + +.queued, +.started, +.ended { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 180px; +} + +.duration { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 20px; +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.js b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js new file mode 100644 index 000000000..4aa6d76d6 --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.js @@ -0,0 +1,265 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import titleCase from 'Utilities/String/titleCase'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import { icons, kinds } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './QueuedTaskRow.css'; + +function getStatusIconProps(status, message) { + const title = titleCase(status); + + switch (status) { + case 'queued': + return { + name: icons.PENDING, + title + }; + + case 'started': + return { + name: icons.REFRESH, + isSpinning: true, + title + }; + + case 'completed': + return { + name: icons.CHECK, + kind: kinds.SUCCESS, + title: message === 'Completed' ? title : `${title}: ${message}` + }; + + case 'failed': + return { + name: icons.FATAL, + kind: kinds.ERROR, + title: `${title}: ${message}` + }; + + default: + return { + name: icons.UNKNOWN, + title + }; + } +} + +function getFormattedDates(props) { + const { + queued, + started, + ended, + showRelativeDates, + shortDateFormat + } = props; + + if (showRelativeDates) { + return { + queuedAt: moment(queued).fromNow(), + startedAt: started ? moment(started).fromNow() : '-', + endedAt: ended ? moment(ended).fromNow() : '-' + }; + } + + return { + queuedAt: formatDate(queued, shortDateFormat), + startedAt: started ? formatDate(started, shortDateFormat) : '-', + endedAt: ended ? formatDate(ended, shortDateFormat) : '-' + }; +} + +class QueuedTaskRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + ...getFormattedDates(props), + isCancelConfirmModalOpen: false + }; + + this._updateTimeoutId = null; + } + + componentDidMount() { + this.setUpdateTimer(); + } + + componentDidUpdate(prevProps) { + const { + queued, + started, + ended + } = this.props; + + if ( + queued !== prevProps.queued || + started !== prevProps.started || + ended !== prevProps.ended + ) { + this.setState(getFormattedDates(this.props)); + } + } + + componentWillUnmount() { + if (this._updateTimeoutId) { + this._updateTimeoutId = clearTimeout(this._updateTimeoutId); + } + } + + // + // Control + + setUpdateTimer() { + this._updateTimeoutId = setTimeout(() => { + this.setState(getFormattedDates(this.props)); + this.setUpdateTimer(); + }, 30000); + } + + // + // Listeners + + onCancelPress = () => { + this.setState({ + isCancelConfirmModalOpen: true + }); + } + + onAbortCancel = () => { + this.setState({ + isCancelConfirmModalOpen: false + }); + } + + // + // Render + + render() { + const { + trigger, + commandName, + queued, + started, + ended, + status, + duration, + message, + longDateFormat, + timeFormat, + onCancelPress + } = this.props; + + const { + queuedAt, + startedAt, + endedAt, + isCancelConfirmModalOpen + } = this.state; + + let triggerIcon = icons.UNKNOWN; + + if (trigger === 'manual') { + triggerIcon = icons.INTERACTIVE; + } else if (trigger === 'scheduled') { + triggerIcon = icons.SCHEDULED; + } + + return ( + + + + + + + + + + {commandName} + + + {queuedAt} + + + + {startedAt} + + + + {endedAt} + + + + {formatTimeSpan(duration)} + + + + { + status === 'queued' && + + } + + + + + ); + } +} + +QueuedTaskRow.propTypes = { + trigger: PropTypes.string.isRequired, + commandName: PropTypes.string.isRequired, + queued: PropTypes.string.isRequired, + started: PropTypes.string, + ended: PropTypes.string, + status: PropTypes.string.isRequired, + duration: PropTypes.string, + message: PropTypes.string, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onCancelPress: PropTypes.func.isRequired +}; + +export default QueuedTaskRow; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js new file mode 100644 index 000000000..f55ab985a --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { cancelCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import QueuedTaskRow from './QueuedTaskRow'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + (uiSettings) => { + return { + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onCancelPress() { + dispatch(cancelCommand({ + id: props.id + })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(QueuedTaskRow); diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.js b/frontend/src/System/Tasks/Queued/QueuedTasks.js new file mode 100644 index 000000000..a2fd526fa --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTasks.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import QueuedTaskRowConnector from './QueuedTaskRowConnector'; + +const columns = [ + { + name: 'trigger', + label: '', + isVisible: true + }, + { + name: 'commandName', + label: 'Name', + isVisible: true + }, + { + name: 'queued', + label: 'Queued', + isVisible: true + }, + { + name: 'started', + label: 'Started', + isVisible: true + }, + { + name: 'ended', + label: 'Ended', + isVisible: true + }, + { + name: 'duration', + label: 'Duration', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +function QueuedTasks(props) { + const { + isFetching, + isPopulated, + items + } = props; + + return ( +
+ { + isFetching && !isPopulated && + + } + + { + isPopulated && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ ); +} + +QueuedTasks.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default QueuedTasks; diff --git a/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js new file mode 100644 index 000000000..5fa4d9ead --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchCommands } from 'Store/Actions/commandActions'; +import QueuedTasks from './QueuedTasks'; + +function createMapStateToProps() { + return createSelector( + (state) => state.commands, + (commands) => { + return commands; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchCommands: fetchCommands +}; + +class QueuedTasksConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchCommands(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +QueuedTasksConnector.propTypes = { + dispatchFetchCommands: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector); diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css new file mode 100644 index 000000000..dc83cfd69 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.css @@ -0,0 +1,18 @@ +.interval { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.lastExecution, +.nextExecution { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 180px; +} + +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 20px; +} diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js new file mode 100644 index 000000000..82cedc720 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRow.js @@ -0,0 +1,182 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import { icons } from 'Helpers/Props'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './ScheduledTaskRow.css'; + +function getFormattedDates(props) { + const { + lastExecution, + nextExecution, + interval, + showRelativeDates, + shortDateFormat + } = props; + + const isDisabled = interval === 0; + + if (showRelativeDates) { + return { + lastExecutionTime: moment(lastExecution).fromNow(), + nextExecutionTime: isDisabled ? '-' : moment(nextExecution).fromNow() + }; + } + + return { + lastExecutionTime: formatDate(lastExecution, shortDateFormat), + nextExecutionTime: isDisabled ? '-' : formatDate(nextExecution, shortDateFormat) + }; +} + +class ScheduledTaskRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = getFormattedDates(props); + + this._updateTimeoutId = null; + } + + componentDidMount() { + this.setUpdateTimer(); + } + + componentDidUpdate(prevProps) { + const { + lastExecution, + nextExecution + } = this.props; + + if ( + lastExecution !== prevProps.lastExecution || + nextExecution !== prevProps.nextExecution + ) { + this.setState(getFormattedDates(this.props)); + } + } + + componentWillUnmount() { + if (this._updateTimeoutId) { + this._updateTimeoutId = clearTimeout(this._updateTimeoutId); + } + } + + // + // Listeners + + setUpdateTimer() { + const { interval } = this.props; + const timeout = interval < 60 ? 10000 : 60000; + + this._updateTimeoutId = setTimeout(() => { + this.setState(getFormattedDates(this.props)); + this.setUpdateTimer(); + }, timeout); + } + + // + // Render + + render() { + const { + name, + interval, + lastExecution, + nextExecution, + isQueued, + isExecuting, + longDateFormat, + timeFormat, + onExecutePress + } = this.props; + + const { + lastExecutionTime, + nextExecutionTime + } = this.state; + + const isDisabled = interval === 0; + const executeNow = !isDisabled && moment().isAfter(nextExecution); + const hasNextExecutionTime = !isDisabled && !executeNow; + const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1'); + + return ( + + {name} + + {isDisabled ? 'disabled' : duration} + + + + {lastExecutionTime} + + + { + isDisabled && + - + } + + { + executeNow && isQueued && + queued + } + + { + executeNow && !isQueued && + now + } + + { + hasNextExecutionTime && + + {nextExecutionTime} + + } + + + + + + ); + } +} + +ScheduledTaskRow.propTypes = { + name: PropTypes.string.isRequired, + interval: PropTypes.number.isRequired, + lastExecution: PropTypes.string.isRequired, + nextExecution: PropTypes.string.isRequired, + isQueued: PropTypes.bool.isRequired, + isExecuting: PropTypes.bool.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onExecutePress: PropTypes.func.isRequired +}; + +export default ScheduledTaskRow; diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js new file mode 100644 index 000000000..79a0c6c87 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTaskRowConnector.js @@ -0,0 +1,92 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { findCommand, isCommandExecuting } from 'Utilities/Command'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchTask } from 'Store/Actions/systemActions'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import ScheduledTaskRow from './ScheduledTaskRow'; + +function createMapStateToProps() { + return createSelector( + (state, { taskName }) => taskName, + createCommandsSelector(), + createUISettingsSelector(), + (taskName, commands, uiSettings) => { + const command = findCommand(commands, { name: taskName }); + + return { + isQueued: !!(command && command.state === 'queued'), + isExecuting: isCommandExecuting(command), + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + const taskName = props.taskName; + + return { + dispatchFetchTask() { + dispatch(fetchTask({ + id: props.id + })); + }, + + onExecutePress() { + dispatch(executeCommand({ + name: taskName + })); + } + }; +} + +class ScheduledTaskRowConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps) { + const { + isExecuting, + dispatchFetchTask + } = this.props; + + if (!isExecuting && prevProps.isExecuting) { + // Give the host a moment to update after the command completes + setTimeout(() => { + dispatchFetchTask(); + }, 1000); + } + } + + // + // Render + + render() { + const { + dispatchFetchTask, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +ScheduledTaskRowConnector.propTypes = { + id: PropTypes.number.isRequired, + isExecuting: PropTypes.bool.isRequired, + dispatchFetchTask: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(ScheduledTaskRowConnector); diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js new file mode 100644 index 000000000..7c6fe8a32 --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasks.js @@ -0,0 +1,79 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import ScheduledTaskRowConnector from './ScheduledTaskRowConnector'; + +const columns = [ + { + name: 'name', + label: 'Name', + isVisible: true + }, + { + name: 'interval', + label: 'Interval', + isVisible: true + }, + { + name: 'lastExecution', + label: 'Last Execution', + isVisible: true + }, + { + name: 'nextExecution', + label: 'Next Execution', + isVisible: true + }, + { + name: 'actions', + isVisible: true + } +]; + +function ScheduledTasks(props) { + const { + isFetching, + isPopulated, + items + } = props; + + return ( +
+ { + isFetching && !isPopulated && + + } + + { + isPopulated && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ ); +} + +ScheduledTasks.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + items: PropTypes.array.isRequired +}; + +export default ScheduledTasks; diff --git a/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js new file mode 100644 index 000000000..8f418d3bb --- /dev/null +++ b/frontend/src/System/Tasks/Scheduled/ScheduledTasksConnector.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchTasks } from 'Store/Actions/systemActions'; +import ScheduledTasks from './ScheduledTasks'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.tasks, + (tasks) => { + return tasks; + } + ); +} + +const mapDispatchToProps = { + dispatchFetchTasks: fetchTasks +}; + +class ScheduledTasksConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchTasks(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ScheduledTasksConnector.propTypes = { + dispatchFetchTasks: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ScheduledTasksConnector); diff --git a/frontend/src/System/Tasks/Tasks.js b/frontend/src/System/Tasks/Tasks.js new file mode 100644 index 000000000..dbbb4d1bf --- /dev/null +++ b/frontend/src/System/Tasks/Tasks.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector'; +import QueuedTasksConnector from './Queued/QueuedTasksConnector'; + +function Tasks() { + return ( + + + + + + + ); +} + +export default Tasks; diff --git a/frontend/src/System/Updates/UpdateChanges.css b/frontend/src/System/Updates/UpdateChanges.css new file mode 100644 index 000000000..d21897373 --- /dev/null +++ b/frontend/src/System/Updates/UpdateChanges.css @@ -0,0 +1,4 @@ +.title { + margin-top: 10px; + font-size: 16px; +} diff --git a/frontend/src/System/Updates/UpdateChanges.js b/frontend/src/System/Updates/UpdateChanges.js new file mode 100644 index 000000000..63c7e0d85 --- /dev/null +++ b/frontend/src/System/Updates/UpdateChanges.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import styles from './UpdateChanges.css'; + +class UpdateChanges extends Component { + + // + // Render + + render() { + const { + title, + changes + } = this.props; + + if (changes.length === 0) { + return null; + } + + return ( +
+
{title}
+
    + { + changes.map((change, index) => { + return ( +
  • + {change} +
  • + ); + }) + } +
+
+ ); + } + +} + +UpdateChanges.propTypes = { + title: PropTypes.string.isRequired, + changes: PropTypes.arrayOf(PropTypes.string) +}; + +export default UpdateChanges; diff --git a/frontend/src/System/Updates/Updates.css b/frontend/src/System/Updates/Updates.css new file mode 100644 index 000000000..3502f6d1f --- /dev/null +++ b/frontend/src/System/Updates/Updates.css @@ -0,0 +1,57 @@ +.updateAvailable { + display: flex; +} + +.upToDate { + display: flex; + margin-bottom: 20px; +} + +.upToDateIcon { + color: #37bc9b; + font-size: 30px; +} + +.upToDateMessage { + padding-left: 5px; + font-size: 18px; + line-height: 30px; +} + +.loading { + composes: loading from 'Components/Loading/LoadingIndicator.css'; + + margin-top: 5px; + margin-left: auto; +} + +.update { + margin-top: 20px; +} + +.info { + display: flex; + align-items: center; + margin-bottom: 10px; + padding-bottom: 5px; + border-bottom: 1px solid #e5e5e5; +} + +.version { + font-size: 21px; +} + +.space { + padding: 0 5px; +} + +.date { + font-size: 16px; +} + +.branch { + composes: label from 'Components/Label.css'; + + margin-left: 10px; + font-size: 14px; +} diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js new file mode 100644 index 000000000..1ee7af090 --- /dev/null +++ b/frontend/src/System/Updates/Updates.js @@ -0,0 +1,169 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds } from 'Helpers/Props'; +import formatDate from 'Utilities/Date/formatDate'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import UpdateChanges from './UpdateChanges'; +import styles from './Updates.css'; + +class Updates extends Component { + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + isInstallingUpdate, + shortDateFormat, + onInstallLatestPress + } = this.props; + + const hasUpdates = isPopulated && !error && items.length > 0; + const noUpdates = isPopulated && !error && !items.length; + const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true }); + const noUpdateToInstall = hasUpdates && !hasUpdateToInstall; + + return ( + + + { + !isPopulated && !error && + + } + + { + noUpdates && +
No updates are available
+ } + + { + hasUpdateToInstall && +
+ + Install Latest + + + { + isFetching && + + } +
+ } + + { + noUpdateToInstall && +
+ +
+ The latest version of Sonarr is already installed +
+ + { + isFetching && + + } +
+ } + + { + hasUpdates && +
+ { + items.map((update) => { + const hasChanges = !!update.changes; + + return ( +
+
+
{update.version}
+
+
{formatDate(update.releaseDate, shortDateFormat)}
+ + { + update.branch !== 'master' && + + } +
+ + { + !hasChanges && +
Maintenance release
+ } + + { + hasChanges && +
+ + + +
+ } +
+ ); + }) + } +
+ } + + { + !!error && +
+ Failed to fetch updates +
+ } +
+
+ ); + } + +} + +Updates.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.array.isRequired, + isInstallingUpdate: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + onInstallLatestPress: PropTypes.func.isRequired +}; + +export default Updates; diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js new file mode 100644 index 000000000..0d0aa491f --- /dev/null +++ b/frontend/src/System/Updates/UpdatesConnector.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import * as commandNames from 'Commands/commandNames'; +import Updates from './Updates'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.updates, + createUISettingsSelector(), + createCommandExecutingSelector(commandNames.APPLICATION_UPDATE), + (updates, uiSettings, isInstallingUpdate) => { + const { + isFetching, + isPopulated, + error, + items + } = updates; + + return { + isFetching, + isPopulated, + error, + items, + isInstallingUpdate, + shortDateFormat: uiSettings.shortDateFormat + }; + } + ); +} + +const mapDispatchToProps = { + fetchUpdates, + executeCommand +}; + +class UpdatesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchUpdates(); + } + + // + // Listeners + + onInstallLatestPress = () => { + this.props.executeCommand({ name: commandNames.APPLICATION_UPDATE }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +UpdatesConnector.propTypes = { + fetchUpdates: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector); diff --git a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js new file mode 100644 index 000000000..165bb5cc1 --- /dev/null +++ b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js @@ -0,0 +1,13 @@ +import _ from 'lodash'; + +export default function getIndexOfFirstCharacter(items, character) { + return _.findIndex(items, (item) => { + const firstCharacter = item.sortTitle.charAt(0); + + if (character === '#') { + return !isNaN(firstCharacter); + } + + return firstCharacter === character; + }); +} diff --git a/frontend/src/Utilities/Array/sortByName.js b/frontend/src/Utilities/Array/sortByName.js new file mode 100644 index 000000000..1956d3bac --- /dev/null +++ b/frontend/src/Utilities/Array/sortByName.js @@ -0,0 +1,5 @@ +function sortByName(a, b) { + return a.name.localeCompare(b.name); +} + +export default sortByName; diff --git a/frontend/src/Utilities/Command/findCommand.js b/frontend/src/Utilities/Command/findCommand.js new file mode 100644 index 000000000..cf7d5444a --- /dev/null +++ b/frontend/src/Utilities/Command/findCommand.js @@ -0,0 +1,10 @@ +import _ from 'lodash'; +import isSameCommand from './isSameCommand'; + +function findCommand(commands, options) { + return _.findLast(commands, (command) => { + return isSameCommand(command.body, options); + }); +} + +export default findCommand; diff --git a/frontend/src/Utilities/Command/index.js b/frontend/src/Utilities/Command/index.js new file mode 100644 index 000000000..66043bf03 --- /dev/null +++ b/frontend/src/Utilities/Command/index.js @@ -0,0 +1,5 @@ +export { default as findCommand } from './findCommand'; +export { default as isCommandComplete } from './isCommandComplete'; +export { default as isCommandExecuting } from './isCommandExecuting'; +export { default as isCommandFailed } from './isCommandFailed'; +export { default as isSameCommand } from './isSameCommand'; diff --git a/frontend/src/Utilities/Command/isCommandComplete.js b/frontend/src/Utilities/Command/isCommandComplete.js new file mode 100644 index 000000000..558ab801b --- /dev/null +++ b/frontend/src/Utilities/Command/isCommandComplete.js @@ -0,0 +1,9 @@ +function isCommandComplete(command) { + if (!command) { + return false; + } + + return command.status === 'complete'; +} + +export default isCommandComplete; diff --git a/frontend/src/Utilities/Command/isCommandExecuting.js b/frontend/src/Utilities/Command/isCommandExecuting.js new file mode 100644 index 000000000..8e637704e --- /dev/null +++ b/frontend/src/Utilities/Command/isCommandExecuting.js @@ -0,0 +1,9 @@ +function isCommandExecuting(command) { + if (!command) { + return false; + } + + return command.status === 'queued' || command.status === 'started'; +} + +export default isCommandExecuting; diff --git a/frontend/src/Utilities/Command/isCommandFailed.js b/frontend/src/Utilities/Command/isCommandFailed.js new file mode 100644 index 000000000..00e5ccdf2 --- /dev/null +++ b/frontend/src/Utilities/Command/isCommandFailed.js @@ -0,0 +1,12 @@ +function isCommandFailed(command) { + if (!command) { + return false; + } + + return command.status === 'failed' || + command.status === 'aborted' || + command.status === 'cancelled' || + command.status === 'orphaned'; +} + +export default isCommandFailed; diff --git a/frontend/src/Utilities/Command/isSameCommand.js b/frontend/src/Utilities/Command/isSameCommand.js new file mode 100644 index 000000000..d0acb24b5 --- /dev/null +++ b/frontend/src/Utilities/Command/isSameCommand.js @@ -0,0 +1,24 @@ +import _ from 'lodash'; + +function isSameCommand(commandA, commandB) { + if (commandA.name.toLocaleLowerCase() !== commandB.name.toLocaleLowerCase()) { + return false; + } + + for (const key in commandB) { + if (key !== 'name') { + const value = commandB[key]; + if (Array.isArray(value)) { + if (_.difference(value, commandA[key]).length > 0) { + return false; + } + } else if (value !== commandA[key]) { + return false; + } + } + } + + return true; +} + +export default isSameCommand; diff --git a/frontend/src/Utilities/Constants/keyCodes.js b/frontend/src/Utilities/Constants/keyCodes.js new file mode 100644 index 000000000..9285b10fe --- /dev/null +++ b/frontend/src/Utilities/Constants/keyCodes.js @@ -0,0 +1,7 @@ +export const TAB = 9; +export const ENTER = 13; +export const SHIFT = 16; +export const CONTROL = 17; +export const ESCAPE = 27; +export const UP_ARROW = 38; +export const DOWN_ARROW = 40; diff --git a/frontend/src/Utilities/Date/dateFilterPredicate.js b/frontend/src/Utilities/Date/dateFilterPredicate.js new file mode 100644 index 000000000..2c74f435a --- /dev/null +++ b/frontend/src/Utilities/Date/dateFilterPredicate.js @@ -0,0 +1,33 @@ +import moment from 'moment'; +import isAfter from 'Utilities/Date/isAfter'; +import isBefore from 'Utilities/Date/isBefore'; +import * as filterTypes from 'Helpers/Props/filterTypes'; + +export default function(itemValue, filterValue, type) { + if (!itemValue) { + return false; + } + + switch (type) { + case filterTypes.LESS_THAN: + return moment(itemValue).isBefore(filterValue); + + case filterTypes.GREATER_THAN: + return moment(itemValue).isAfter(filterValue); + + case filterTypes.IN_LAST: + return ( + isAfter(itemValue, { [filterValue.time]: filterValue.value * -1 }) && + isBefore(itemValue) + ); + + case filterTypes.IN_NEXT: + return ( + isAfter(itemValue) && + isBefore(itemValue, { [filterValue.time]: filterValue.value }) + ); + + default: + return false; + } +} diff --git a/frontend/src/Utilities/Date/formatDate.js b/frontend/src/Utilities/Date/formatDate.js new file mode 100644 index 000000000..92eb57840 --- /dev/null +++ b/frontend/src/Utilities/Date/formatDate.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function formatDate(date, dateFormat) { + if (!date) { + return ''; + } + + return moment(date).format(dateFormat); +} + +export default formatDate; diff --git a/frontend/src/Utilities/Date/formatDateTime.js b/frontend/src/Utilities/Date/formatDateTime.js new file mode 100644 index 000000000..f36f4f3e0 --- /dev/null +++ b/frontend/src/Utilities/Date/formatDateTime.js @@ -0,0 +1,39 @@ +import moment from 'moment'; +import formatTime from './formatTime'; +import isToday from './isToday'; +import isTomorrow from './isTomorrow'; +import isYesterday from './isYesterday'; + +function getRelativeDay(date, includeRelativeDate) { + if (!includeRelativeDate) { + return ''; + } + + if (isYesterday(date)) { + return 'Yesterday, '; + } + + if (isToday(date)) { + return 'Today, '; + } + + if (isTomorrow(date)) { + return 'Tomorrow, '; + } + + return ''; +} + +function formatDateTime(date, dateFormat, timeFormat, { includeSeconds = false, includeRelativeDay = false } = {}) { + if (!date) { + return ''; + } + + const relativeDay = getRelativeDay(date, includeRelativeDay); + const formattedDate = moment(date).format(dateFormat); + const formattedTime = formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds }); + + return `${relativeDay}${formattedDate} ${formattedTime}`; +} + +export default formatDateTime; diff --git a/frontend/src/Utilities/Date/formatTime.js b/frontend/src/Utilities/Date/formatTime.js new file mode 100644 index 000000000..89c908d1f --- /dev/null +++ b/frontend/src/Utilities/Date/formatTime.js @@ -0,0 +1,19 @@ +import moment from 'moment'; + +function formatTime(date, timeFormat, { includeMinuteZero = false, includeSeconds = false } = {}) { + if (!date) { + return ''; + } + + if (includeSeconds) { + timeFormat = timeFormat.replace(/\(?:mm\)?/, ':mm:ss'); + } else if (includeMinuteZero) { + timeFormat = timeFormat.replace('(:mm)', ':mm'); + } else { + timeFormat = timeFormat.replace('(:mm)', ''); + } + + return moment(date).format(timeFormat); +} + +export default formatTime; diff --git a/frontend/src/Utilities/Date/formatTimeSpan.js b/frontend/src/Utilities/Date/formatTimeSpan.js new file mode 100644 index 000000000..ef1a278e5 --- /dev/null +++ b/frontend/src/Utilities/Date/formatTimeSpan.js @@ -0,0 +1,24 @@ +import moment from 'moment'; +import padNumber from 'Utilities/Number/padNumber'; + +function formatTimeSpan(timeSpan) { + if (!timeSpan) { + return ''; + } + + const duration = moment.duration(timeSpan); + const days = duration.get('days'); + const hours = padNumber(duration.get('hours'), 2); + const minutes = padNumber(duration.get('minutes'), 2); + const seconds = padNumber(duration.get('seconds'), 2); + + const time = `${hours}:${minutes}:${seconds}`; + + if (days > 0) { + return `${days}d ${time}`; + } + + return time; +} + +export default formatTimeSpan; diff --git a/frontend/src/Utilities/Date/getRelativeDate.js b/frontend/src/Utilities/Date/getRelativeDate.js new file mode 100644 index 000000000..0a60135ce --- /dev/null +++ b/frontend/src/Utilities/Date/getRelativeDate.js @@ -0,0 +1,42 @@ +import moment from 'moment'; +import formatTime from 'Utilities/Date/formatTime'; +import isInNextWeek from 'Utilities/Date/isInNextWeek'; +import isToday from 'Utilities/Date/isToday'; +import isTomorrow from 'Utilities/Date/isTomorrow'; +import isYesterday from 'Utilities/Date/isYesterday'; + +function getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds = false, timeForToday = false } = {}) { + if (!date) { + return null; + } + + const isTodayDate = isToday(date); + + if (isTodayDate && timeForToday && timeFormat) { + return formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds }); + } + + if (!showRelativeDates) { + return moment(date).format(shortDateFormat); + } + + if (isYesterday(date)) { + return 'Yesterday'; + } + + if (isTodayDate) { + return 'Today'; + } + + if (isTomorrow(date)) { + return 'Tomorrow'; + } + + if (isInNextWeek(date)) { + return moment(date).format('dddd'); + } + + return moment(date).format(shortDateFormat); +} + +export default getRelativeDate; diff --git a/frontend/src/Utilities/Date/isAfter.js b/frontend/src/Utilities/Date/isAfter.js new file mode 100644 index 000000000..4bbd8660b --- /dev/null +++ b/frontend/src/Utilities/Date/isAfter.js @@ -0,0 +1,17 @@ +import moment from 'moment'; + +function isAfter(date, offsets = {}) { + if (!date) { + return false; + } + + const offsetTime = moment(); + + Object.keys(offsets).forEach((key) => { + offsetTime.add(offsets[key], key); + }); + + return moment(date).isAfter(offsetTime); +} + +export default isAfter; diff --git a/frontend/src/Utilities/Date/isBefore.js b/frontend/src/Utilities/Date/isBefore.js new file mode 100644 index 000000000..3e1e81f67 --- /dev/null +++ b/frontend/src/Utilities/Date/isBefore.js @@ -0,0 +1,17 @@ +import moment from 'moment'; + +function isBefore(date, offsets = {}) { + if (!date) { + return false; + } + + const offsetTime = moment(); + + Object.keys(offsets).forEach((key) => { + offsetTime.add(offsets[key], key); + }); + + return moment(date).isBefore(offsetTime); +} + +export default isBefore; diff --git a/frontend/src/Utilities/Date/isInNextWeek.js b/frontend/src/Utilities/Date/isInNextWeek.js new file mode 100644 index 000000000..7b5fd7cc7 --- /dev/null +++ b/frontend/src/Utilities/Date/isInNextWeek.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isInNextWeek(date) { + if (!date) { + return false; + } + const now = moment(); + return moment(date).isBetween(now, now.clone().add(6, 'days').endOf('day')); +} + +export default isInNextWeek; diff --git a/frontend/src/Utilities/Date/isSameWeek.js b/frontend/src/Utilities/Date/isSameWeek.js new file mode 100644 index 000000000..14b76ffb7 --- /dev/null +++ b/frontend/src/Utilities/Date/isSameWeek.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isSameWeek(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment(), 'week'); +} + +export default isSameWeek; diff --git a/frontend/src/Utilities/Date/isToday.js b/frontend/src/Utilities/Date/isToday.js new file mode 100644 index 000000000..31502951f --- /dev/null +++ b/frontend/src/Utilities/Date/isToday.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isToday(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment(), 'day'); +} + +export default isToday; diff --git a/frontend/src/Utilities/Date/isTomorrow.js b/frontend/src/Utilities/Date/isTomorrow.js new file mode 100644 index 000000000..d22386dbd --- /dev/null +++ b/frontend/src/Utilities/Date/isTomorrow.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isTomorrow(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment().add(1, 'day'), 'day'); +} + +export default isTomorrow; diff --git a/frontend/src/Utilities/Date/isYesterday.js b/frontend/src/Utilities/Date/isYesterday.js new file mode 100644 index 000000000..9de21d82a --- /dev/null +++ b/frontend/src/Utilities/Date/isYesterday.js @@ -0,0 +1,11 @@ +import moment from 'moment'; + +function isYesterday(date) { + if (!date) { + return false; + } + + return moment(date).isSame(moment().subtract(1, 'day'), 'day'); +} + +export default isYesterday; diff --git a/frontend/src/Utilities/Episode/updateEpisodes.js b/frontend/src/Utilities/Episode/updateEpisodes.js new file mode 100644 index 000000000..80890b53f --- /dev/null +++ b/frontend/src/Utilities/Episode/updateEpisodes.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; +import { update } from 'Store/Actions/baseActions'; + +function updateEpisodes(section, episodes, episodeIds, options) { + const data = _.reduce(episodes, (result, item) => { + if (episodeIds.indexOf(item.id) > -1) { + result.push({ + ...item, + ...options + }); + } else { + result.push(item); + } + + return result; + }, []); + + return update({ section, data }); +} + +export default updateEpisodes; diff --git a/frontend/src/Utilities/Filter/findSelectedFilters.js b/frontend/src/Utilities/Filter/findSelectedFilters.js new file mode 100644 index 000000000..1c104073c --- /dev/null +++ b/frontend/src/Utilities/Filter/findSelectedFilters.js @@ -0,0 +1,19 @@ +export default function findSelectedFilters(selectedFilterKey, filters = [], customFilters = []) { + if (!selectedFilterKey) { + return []; + } + + let selectedFilter = filters.find((f) => f.key === selectedFilterKey); + + if (!selectedFilter) { + selectedFilter = customFilters.find((f) => f.id === selectedFilterKey); + } + + if (!selectedFilter) { + // TODO: throw in dev + console.error('Matching filter not found'); + return []; + } + + return selectedFilter.filters; +} diff --git a/frontend/src/Utilities/Filter/getFilterValue.js b/frontend/src/Utilities/Filter/getFilterValue.js new file mode 100644 index 000000000..70b0b51f1 --- /dev/null +++ b/frontend/src/Utilities/Filter/getFilterValue.js @@ -0,0 +1,11 @@ +export default function getFilterValue(filters, filterKey, filterValueKey, defaultValue) { + const filter = filters.find((f) => f.key === filterKey); + + if (!filter) { + return defaultValue; + } + + const filterValue = filter.filters.find((f) => f.key === filterValueKey); + + return filterValue ? filterValue.value : defaultValue; +} diff --git a/frontend/src/Utilities/Number/convertToBytes.js b/frontend/src/Utilities/Number/convertToBytes.js new file mode 100644 index 000000000..6c63fb117 --- /dev/null +++ b/frontend/src/Utilities/Number/convertToBytes.js @@ -0,0 +1,16 @@ + +function convertToBytes(input, power, binaryPrefix) { + const size = Number(input); + + if (isNaN(size)) { + return ''; + } + + const prefix = binaryPrefix ? 1024 : 1000; + const multiplier = Math.pow(prefix, power); + const result = size * multiplier; + + return Math.round(result); +} + +export default convertToBytes; diff --git a/frontend/src/Utilities/Number/formatAge.js b/frontend/src/Utilities/Number/formatAge.js new file mode 100644 index 000000000..b8a4aacc5 --- /dev/null +++ b/frontend/src/Utilities/Number/formatAge.js @@ -0,0 +1,17 @@ +function formatAge(age, ageHours, ageMinutes) { + age = Math.round(age); + ageHours = parseFloat(ageHours); + ageMinutes = ageMinutes && parseFloat(ageMinutes); + + if (age < 2 && ageHours) { + if (ageHours < 2 && !!ageMinutes) { + return `${ageMinutes.toFixed(0)} ${ageHours === 1 ? 'minute' : 'minutes'}`; + } + + return `${ageHours.toFixed(1)} ${ageHours === 1 ? 'hour' : 'hours'}`; + } + + return `${age} ${age === 1 ? 'day' : 'days'}`; +} + +export default formatAge; diff --git a/frontend/src/Utilities/Number/formatBytes.js b/frontend/src/Utilities/Number/formatBytes.js new file mode 100644 index 000000000..1ff1b5a97 --- /dev/null +++ b/frontend/src/Utilities/Number/formatBytes.js @@ -0,0 +1,16 @@ +import filesize from 'filesize'; + +function formatBytes(input) { + const size = Number(input); + + if (isNaN(size)) { + return ''; + } + + return filesize(size, { + base: 2, + round: 1 + }); +} + +export default formatBytes; diff --git a/frontend/src/Utilities/Number/padNumber.js b/frontend/src/Utilities/Number/padNumber.js new file mode 100644 index 000000000..53ae69cac --- /dev/null +++ b/frontend/src/Utilities/Number/padNumber.js @@ -0,0 +1,10 @@ +function padNumber(input, width, paddingCharacter = 0) { + if (input == null) { + return ''; + } + + input = `${input}`; + return input.length >= width ? input : new Array(width - input.length + 1).join(paddingCharacter) + input; +} + +export default padNumber; diff --git a/frontend/src/Utilities/Object/getErrorMessage.js b/frontend/src/Utilities/Object/getErrorMessage.js new file mode 100644 index 000000000..1ba874660 --- /dev/null +++ b/frontend/src/Utilities/Object/getErrorMessage.js @@ -0,0 +1,11 @@ +function getErrorMessage(xhr, fallbackErrorMessage) { + if (!xhr || !xhr.responseJSON || !xhr.responseJSON.message) { + return fallbackErrorMessage; + } + + const message = xhr.responseJSON.message; + + return message || fallbackErrorMessage; +} + +export default getErrorMessage; diff --git a/frontend/src/Utilities/Object/hasDifferentItems.js b/frontend/src/Utilities/Object/hasDifferentItems.js new file mode 100644 index 000000000..f89c99a10 --- /dev/null +++ b/frontend/src/Utilities/Object/hasDifferentItems.js @@ -0,0 +1,10 @@ +import _ from 'lodash'; + +function hasDifferentItems(prevItems, currentItems, idProp = 'id') { + const diff1 = _.differenceBy(prevItems, currentItems, (item) => item[idProp]); + const diff2 = _.differenceBy(currentItems, prevItems, (item) => item[idProp]); + + return diff1.length > 0 || diff2.length > 0; +} + +export default hasDifferentItems; diff --git a/frontend/src/Utilities/Object/selectUniqueIds.js b/frontend/src/Utilities/Object/selectUniqueIds.js new file mode 100644 index 000000000..c2c0c17e3 --- /dev/null +++ b/frontend/src/Utilities/Object/selectUniqueIds.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; + +function selectUniqueIds(items, idProp) { + const ids = _.reduce(items, (result, item) => { + if (item[idProp]) { + result.push(item[idProp]); + } + + return result; + }, []); + + return _.uniq(ids); +} + +export default selectUniqueIds; diff --git a/frontend/src/Utilities/Quality/getQualities.js b/frontend/src/Utilities/Quality/getQualities.js new file mode 100644 index 000000000..da09851ea --- /dev/null +++ b/frontend/src/Utilities/Quality/getQualities.js @@ -0,0 +1,16 @@ +export default function getQualities(qualities) { + if (!qualities) { + return []; + } + + return qualities.reduce((acc, item) => { + if (item.quality) { + acc.push(item.quality); + } else { + const groupQualities = item.items.map((i) => i.quality); + acc.push(...groupQualities); + } + + return acc; + }, []); +} diff --git a/frontend/src/Utilities/ResolutionUtility.js b/frontend/src/Utilities/ResolutionUtility.js new file mode 100644 index 000000000..358448ca9 --- /dev/null +++ b/frontend/src/Utilities/ResolutionUtility.js @@ -0,0 +1,26 @@ +import $ from 'jquery'; + +module.exports = { + resolutions: { + desktopLarge: 1200, + desktop: 992, + tablet: 768, + mobile: 480 + }, + + isDesktopLarge() { + return $(window).width() < this.resolutions.desktopLarge; + }, + + isDesktop() { + return $(window).width() < this.resolutions.desktop; + }, + + isTablet() { + return $(window).width() < this.resolutions.tablet; + }, + + isMobile() { + return $(window).width() < this.resolutions.mobile; + } +}; diff --git a/frontend/src/Utilities/Series/getNewSeries.js b/frontend/src/Utilities/Series/getNewSeries.js new file mode 100644 index 000000000..f33b5c869 --- /dev/null +++ b/frontend/src/Utilities/Series/getNewSeries.js @@ -0,0 +1,31 @@ + +function getNewSeries(series, payload) { + const { + rootFolderPath, + monitor, + qualityProfileId, + languageProfileId, + seriesType, + seasonFolder, + tags, + searchForMissingEpisodes = false + } = payload; + + const addOptions = { + monitor, + searchForMissingEpisodes + }; + + series.addOptions = addOptions; + series.monitored = true; + series.qualityProfileId = qualityProfileId; + series.languageProfileId = languageProfileId; + series.rootFolderPath = rootFolderPath; + series.seriesType = seriesType; + series.seasonFolder = seasonFolder; + series.tags = tags; + + return series; +} + +export default getNewSeries; diff --git a/frontend/src/Utilities/Series/getProgressBarKind.js b/frontend/src/Utilities/Series/getProgressBarKind.js new file mode 100644 index 000000000..eb3b2dd6e --- /dev/null +++ b/frontend/src/Utilities/Series/getProgressBarKind.js @@ -0,0 +1,15 @@ +import { kinds } from 'Helpers/Props'; + +function getProgressBarKind(status, monitored, progress) { + if (progress === 100) { + return status === 'ended' ? kinds.SUCCESS : kinds.PRIMARY; + } + + if (monitored) { + return kinds.DANGER; + } + + return kinds.WARNING; +} + +export default getProgressBarKind; diff --git a/frontend/src/Utilities/Series/monitorOptions.js b/frontend/src/Utilities/Series/monitorOptions.js new file mode 100644 index 000000000..57d46413f --- /dev/null +++ b/frontend/src/Utilities/Series/monitorOptions.js @@ -0,0 +1,11 @@ +const monitorOptions = [ + { key: 'all', value: 'All Episodes' }, + { key: 'future', value: 'Future Episodes' }, + { key: 'missing', value: 'Missing Episodes' }, + { key: 'existing', value: 'Existing Episodes' }, + { key: 'firstSeason', value: 'Only First Season' }, + { key: 'latestSeason', value: 'Only Latest Season' }, + { key: 'none', value: 'None' } +]; + +export default monitorOptions; diff --git a/frontend/src/Utilities/State/getProviderState.js b/frontend/src/Utilities/State/getProviderState.js new file mode 100644 index 000000000..987680cb5 --- /dev/null +++ b/frontend/src/Utilities/State/getProviderState.js @@ -0,0 +1,35 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; + +function getProviderState(payload, getState, section) { + const { + id, + ...otherPayload + } = payload; + + const state = getSectionState(getState(), section, true); + const pendingChanges = Object.assign({}, state.pendingChanges, otherPayload); + const pendingFields = state.pendingChanges.fields || {}; + delete pendingChanges.fields; + + const item = id ? _.find(state.items, { id }) : state.selectedSchema || state.schema || {}; + + if (item.fields) { + pendingChanges.fields = _.reduce(item.fields, (result, field) => { + const value = pendingFields.hasOwnProperty(field.name) ? + pendingFields[field.name] : + field.value; + + result.push({ + ...field, + value + }); + + return result; + }, []); + } + + return Object.assign({}, item, pendingChanges); +} + +export default getProviderState; diff --git a/frontend/src/Utilities/State/getSectionState.js b/frontend/src/Utilities/State/getSectionState.js new file mode 100644 index 000000000..00871bed2 --- /dev/null +++ b/frontend/src/Utilities/State/getSectionState.js @@ -0,0 +1,22 @@ +import _ from 'lodash'; + +function getSectionState(state, section, isFullStateTree = false) { + if (isFullStateTree) { + return _.get(state, section); + } + + const [, subSection] = section.split('.'); + + if (subSection) { + return Object.assign({}, state[subSection]); + } + + // TODO: Remove in favour of using subSection + if (state.hasOwnProperty(section)) { + return Object.assign({}, state[section]); + } + + return Object.assign({}, state); +} + +export default getSectionState; diff --git a/frontend/src/Utilities/State/selectProviderSchema.js b/frontend/src/Utilities/State/selectProviderSchema.js new file mode 100644 index 000000000..c8a31760c --- /dev/null +++ b/frontend/src/Utilities/State/selectProviderSchema.js @@ -0,0 +1,34 @@ +import _ from 'lodash'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +function applySchemaDefaults(selectedSchema, schemaDefaults) { + if (!schemaDefaults) { + return selectedSchema; + } else if (_.isFunction(schemaDefaults)) { + return schemaDefaults(selectedSchema); + } + + return Object.assign(selectedSchema, schemaDefaults); +} + +function selectProviderSchema(state, section, payload, schemaDefaults) { + const newState = getSectionState(state, section); + + const { + implementation, + presetName + } = payload; + + const selectedImplementation = _.find(newState.schema, { implementation }); + + const selectedSchema = presetName ? + _.find(selectedImplementation.presets, { name: presetName }) : + selectedImplementation; + + newState.selectedSchema = applySchemaDefaults(_.cloneDeep(selectedSchema), schemaDefaults); + + return updateSectionState(state, section, newState); +} + +export default selectProviderSchema; diff --git a/frontend/src/Utilities/State/updateSectionState.js b/frontend/src/Utilities/State/updateSectionState.js new file mode 100644 index 000000000..81b33ecaf --- /dev/null +++ b/frontend/src/Utilities/State/updateSectionState.js @@ -0,0 +1,16 @@ +function updateSectionState(state, section, newState) { + const [, subSection] = section.split('.'); + + if (subSection) { + return Object.assign({}, state, { [subSection]: newState }); + } + + // TODO: Remove in favour of using subSection + if (state.hasOwnProperty(section)) { + return Object.assign({}, state, { [section]: newState }); + } + + return Object.assign({}, state, newState); +} + +export default updateSectionState; diff --git a/frontend/src/Utilities/String/combinePath.js b/frontend/src/Utilities/String/combinePath.js new file mode 100644 index 000000000..9e4e9abe8 --- /dev/null +++ b/frontend/src/Utilities/String/combinePath.js @@ -0,0 +1,5 @@ +export default function combinePath(isWindows, basePath, paths = []) { + const slash = isWindows ? '\\' : '/'; + + return `${basePath}${slash}${paths.join(slash)}`; +} diff --git a/frontend/src/Utilities/String/generateUUIDv4.js b/frontend/src/Utilities/String/generateUUIDv4.js new file mode 100644 index 000000000..51b15ec60 --- /dev/null +++ b/frontend/src/Utilities/String/generateUUIDv4.js @@ -0,0 +1,6 @@ +export default function generateUUIDv4() { + return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) => + // eslint-disable-next-line no-bitwise + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); +} diff --git a/frontend/src/Utilities/String/isString.js b/frontend/src/Utilities/String/isString.js new file mode 100644 index 000000000..1e7c3dff8 --- /dev/null +++ b/frontend/src/Utilities/String/isString.js @@ -0,0 +1,3 @@ +export default function isString(possibleString) { + return typeof possibleString === 'string' || possibleString instanceof String; +} diff --git a/frontend/src/Utilities/String/parseUrl.js b/frontend/src/Utilities/String/parseUrl.js new file mode 100644 index 000000000..93341f85f --- /dev/null +++ b/frontend/src/Utilities/String/parseUrl.js @@ -0,0 +1,36 @@ +import _ from 'lodash'; +import qs from 'qs'; + +// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils +const anchor = document.createElement('a'); + +export default function parseUrl(url) { + anchor.href = url; + + // The `origin`, `password`, and `username` properties are unavailable in + // Opera Presto. We synthesize `origin` if it's not present. While `password` + // and `username` are ignored intentionally. + const properties = _.pick( + anchor, + 'hash', + 'host', + 'hostname', + 'href', + 'origin', + 'pathname', + 'port', + 'protocol', + 'search' + ); + + properties.isAbsolute = (/^[\w:]*\/\//).test(url); + + if (properties.search) { + // Remove leading ? from querystring before parsing. + properties.params = qs.parse(properties.search.substring(1)); + } else { + properties.params = {}; + } + + return properties; +} diff --git a/frontend/src/Utilities/String/split.js b/frontend/src/Utilities/String/split.js new file mode 100644 index 000000000..0e57e7545 --- /dev/null +++ b/frontend/src/Utilities/String/split.js @@ -0,0 +1,17 @@ +import _ from 'lodash'; + +function split(input, separator = ',') { + if (!input) { + return []; + } + + return _.reduce(input.split(separator), (result, s) => { + if (s) { + result.push(s); + } + + return result; + }, []); +} + +export default split; diff --git a/frontend/src/Utilities/String/titleCase.js b/frontend/src/Utilities/String/titleCase.js new file mode 100644 index 000000000..5b76c10dd --- /dev/null +++ b/frontend/src/Utilities/String/titleCase.js @@ -0,0 +1,11 @@ +function titleCase(input) { + if (!input) { + return ''; + } + + return input.replace(/\b\w+/g, (match) => { + return match.charAt(0).toUpperCase() + match.substr(1).toLowerCase(); + }); +} + +export default titleCase; diff --git a/frontend/src/Utilities/Table/areAllSelected.js b/frontend/src/Utilities/Table/areAllSelected.js new file mode 100644 index 000000000..26102f89b --- /dev/null +++ b/frontend/src/Utilities/Table/areAllSelected.js @@ -0,0 +1,17 @@ +export default function areAllSelected(selectedState) { + let allSelected = true; + let allUnselected = true; + + Object.keys(selectedState).forEach((key) => { + if (selectedState[key]) { + allUnselected = false; + } else { + allSelected = false; + } + }); + + return { + allSelected, + allUnselected + }; +} diff --git a/frontend/src/Utilities/Table/getSelectedIds.js b/frontend/src/Utilities/Table/getSelectedIds.js new file mode 100644 index 000000000..705f13a5d --- /dev/null +++ b/frontend/src/Utilities/Table/getSelectedIds.js @@ -0,0 +1,15 @@ +import _ from 'lodash'; + +function getSelectedIds(selectedState, { parseIds = true } = {}) { + return _.reduce(selectedState, (result, value, id) => { + if (value) { + const parsedId = parseIds ? parseInt(id) : id; + + result.push(parsedId); + } + + return result; + }, []); +} + +export default getSelectedIds; diff --git a/frontend/src/Utilities/Table/getToggledRange.js b/frontend/src/Utilities/Table/getToggledRange.js new file mode 100644 index 000000000..c0cc44fe5 --- /dev/null +++ b/frontend/src/Utilities/Table/getToggledRange.js @@ -0,0 +1,23 @@ +import _ from 'lodash'; + +function getToggledRange(items, id, lastToggled) { + const lastToggledIndex = _.findIndex(items, { id: lastToggled }); + const changedIndex = _.findIndex(items, { id }); + let lower = 0; + let upper = 0; + + if (lastToggledIndex > changedIndex) { + lower = changedIndex; + upper = lastToggledIndex + 1; + } else { + lower = lastToggledIndex; + upper = changedIndex; + } + + return { + lower, + upper + }; +} + +export default getToggledRange; diff --git a/frontend/src/Utilities/Table/removeOldSelectedState.js b/frontend/src/Utilities/Table/removeOldSelectedState.js new file mode 100644 index 000000000..ff3a4fe11 --- /dev/null +++ b/frontend/src/Utilities/Table/removeOldSelectedState.js @@ -0,0 +1,16 @@ +import areAllSelected from './areAllSelected'; + +export default function removeOldSelectedState(state, prevItems) { + const selectedState = { + ...state.selectedState + }; + + prevItems.forEach((item) => { + delete selectedState[item.id]; + }); + + return { + ...areAllSelected(selectedState), + selectedState + }; +} diff --git a/frontend/src/Utilities/Table/selectAll.js b/frontend/src/Utilities/Table/selectAll.js new file mode 100644 index 000000000..ffaaeaddf --- /dev/null +++ b/frontend/src/Utilities/Table/selectAll.js @@ -0,0 +1,17 @@ +import _ from 'lodash'; + +function selectAll(selectedState, selected) { + const newSelectedState = _.reduce(Object.keys(selectedState), (result, item) => { + result[item] = selected; + return result; + }, {}); + + return { + allSelected: selected, + allUnselected: !selected, + lastToggled: null, + selectedState: newSelectedState + }; +} + +export default selectAll; diff --git a/frontend/src/Utilities/Table/toggleSelected.js b/frontend/src/Utilities/Table/toggleSelected.js new file mode 100644 index 000000000..dbc0d6223 --- /dev/null +++ b/frontend/src/Utilities/Table/toggleSelected.js @@ -0,0 +1,30 @@ +import areAllSelected from './areAllSelected'; +import getToggledRange from './getToggledRange'; + +function toggleSelected(state, items, id, selected, shiftKey) { + const lastToggled = state.lastToggled; + const selectedState = { + ...state.selectedState, + [id]: selected + }; + + if (selected == null) { + delete selectedState[id]; + } + + if (shiftKey && lastToggled) { + const { lower, upper } = getToggledRange(items, id, lastToggled); + + for (let i = lower; i < upper; i++) { + selectedState[items[i].id] = selected; + } + } + + return { + ...areAllSelected(selectedState), + lastToggled: id, + selectedState + }; +} + +export default toggleSelected; diff --git a/frontend/src/Utilities/createAjaxRequest.js b/frontend/src/Utilities/createAjaxRequest.js new file mode 100644 index 000000000..7ad8961da --- /dev/null +++ b/frontend/src/Utilities/createAjaxRequest.js @@ -0,0 +1,30 @@ +import $ from 'jquery'; + +export default function createAjaxRequest(ajaxOptions) { + const requestXHR = new window.XMLHttpRequest(); + let aborted = false; + let complete = false; + + function abortRequest() { + if (!complete) { + aborted = true; + requestXHR.abort(); + } + } + + const request = $.ajax({ + xhr: () => requestXHR, + ...ajaxOptions + }).then(null, (xhr, textStatus, errorThrown) => { + xhr.aborted = aborted; + + return $.Deferred().reject(xhr, textStatus, errorThrown).promise(); + }).always(() => { + complete = true; + }); + + return { + request, + abortRequest + }; +} diff --git a/frontend/src/Utilities/getPathWithUrlBase.js b/frontend/src/Utilities/getPathWithUrlBase.js new file mode 100644 index 000000000..60533d3d3 --- /dev/null +++ b/frontend/src/Utilities/getPathWithUrlBase.js @@ -0,0 +1,3 @@ +export default function getPathWithUrlBase(path) { + return `${window.Sonarr.urlBase}${path}`; +} diff --git a/frontend/src/Utilities/getUniqueElementId.js b/frontend/src/Utilities/getUniqueElementId.js new file mode 100644 index 000000000..dae5150b7 --- /dev/null +++ b/frontend/src/Utilities/getUniqueElementId.js @@ -0,0 +1,7 @@ +let i = 0; + +// returns a HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022) + +export default function getUniqueElementId() { + return `id-${i++}`; +} diff --git a/frontend/src/Utilities/isMobile.js b/frontend/src/Utilities/isMobile.js new file mode 100644 index 000000000..489020a23 --- /dev/null +++ b/frontend/src/Utilities/isMobile.js @@ -0,0 +1,7 @@ +import MobileDetect from 'mobile-detect'; + +export default function isMobile() { + const mobileDetect = new MobileDetect(window.navigator.userAgent); + + return mobileDetect.mobile() != null; +} diff --git a/frontend/src/Utilities/pagePopulator.js b/frontend/src/Utilities/pagePopulator.js new file mode 100644 index 000000000..f58dbe803 --- /dev/null +++ b/frontend/src/Utilities/pagePopulator.js @@ -0,0 +1,28 @@ +let currentPopulator = null; +let currentReasons = []; + +export function registerPagePopulator(populator, reasons = []) { + currentPopulator = populator; + currentReasons = reasons; +} + +export function unregisterPagePopulator(populator) { + if (currentPopulator === populator) { + currentPopulator = null; + currentReasons = []; + } +} + +export function repopulatePage(reason) { + if (!currentPopulator) { + return; + } + + if (!reason) { + currentPopulator(); + } + + if (reason && currentReasons.includes(reason)) { + currentPopulator(); + } +} diff --git a/frontend/src/Utilities/pages.js b/frontend/src/Utilities/pages.js new file mode 100644 index 000000000..1355442d9 --- /dev/null +++ b/frontend/src/Utilities/pages.js @@ -0,0 +1,9 @@ +const pages = { + FIRST: 'first', + PREVIOUS: 'previous', + NEXT: 'next', + LAST: 'last', + EXACT: 'exact' +}; + +export default pages; diff --git a/frontend/src/Utilities/requestAction.js b/frontend/src/Utilities/requestAction.js new file mode 100644 index 000000000..3f2564a7b --- /dev/null +++ b/frontend/src/Utilities/requestAction.js @@ -0,0 +1,40 @@ +import $ from 'jquery'; +import _ from 'lodash'; + +function flattenProviderData(providerData) { + return _.reduce(Object.keys(providerData), (result, key) => { + const property = providerData[key]; + + if (key === 'fields') { + result[key] = property; + } else { + result[key] = property.value; + } + + return result; + }, {}); +} + +function requestAction(payload) { + const { + provider, + action, + providerData, + queryParams + } = payload; + + const ajaxOptions = { + url: `/${provider}/action/${action}`, + contentType: 'application/json', + method: 'POST', + data: JSON.stringify(flattenProviderData(providerData)) + }; + + if (queryParams) { + ajaxOptions.url += `?${$.param(queryParams, true)}`; + } + + return $.ajax(ajaxOptions); +} + +export default requestAction; diff --git a/frontend/src/Utilities/sectionTypes.js b/frontend/src/Utilities/sectionTypes.js new file mode 100644 index 000000000..5479b32b9 --- /dev/null +++ b/frontend/src/Utilities/sectionTypes.js @@ -0,0 +1,6 @@ +const sectionTypes = { + COLLECTION: 'collection', + MODEL: 'model' +}; + +export default sectionTypes; diff --git a/frontend/src/Utilities/serverSideCollectionHandlers.js b/frontend/src/Utilities/serverSideCollectionHandlers.js new file mode 100644 index 000000000..03fa39c00 --- /dev/null +++ b/frontend/src/Utilities/serverSideCollectionHandlers.js @@ -0,0 +1,12 @@ +const serverSideCollectionHandlers = { + FETCH: 'fetch', + FIRST_PAGE: 'firstPage', + PREVIOUS_PAGE: 'previousPage', + NEXT_PAGE: 'nextPage', + LAST_PAGE: 'lastPage', + EXACT_PAGE: 'exactPage', + SORT: 'sort', + FILTER: 'filter' +}; + +export default serverSideCollectionHandlers; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js new file mode 100644 index 000000000..e12d3a160 --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js @@ -0,0 +1,280 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getFilterValue from 'Utilities/Filter/getFilterValue'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import CutoffUnmetRowConnector from './CutoffUnmetRowConnector'; + +function getMonitoredValue(props) { + const { + filters, + selectedFilterKey + } = props; + + return getFilterValue(filters, selectedFilterKey, 'monitored', false); +} + +class CutoffUnmet extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isConfirmSearchAllCutoffUnmetModalOpen: false, + isInteractiveImportModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + this.setState((state) => { + return removeOldSelectedState(state, prevProps.items); + }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onFilterMenuItemPress = (filterKey, filterValue) => { + this.props.onFilterSelect(filterKey, filterValue); + } + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onSearchSelectedPress = () => { + const selected = this.getSelectedIds(); + + this.props.onSearchSelectedPress(selected); + } + + onToggleSelectedPress = () => { + const episodeIds = this.getSelectedIds(); + + this.props.batchToggleCutoffUnmetEpisodes({ + episodeIds, + monitored: !getMonitoredValue(this.props) + }); + } + + onSearchAllCutoffUnmetPress = () => { + this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: true }); + } + + onSearchAllCutoffUnmetConfirmed = () => { + this.props.onSearchAllCutoffUnmetPress(); + this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false }); + } + + onConfirmSearchAllCutoffUnmetModalClose = () => { + this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + selectedFilterKey, + filters, + columns, + totalRecords, + isSearchingForCutoffUnmetEpisodes, + isSaving, + onFilterSelect, + ...otherProps + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmSearchAllCutoffUnmetModalOpen + } = this.state; + + const itemsSelected = !!this.getSelectedIds().length; + const isShowingMonitored = getMonitoredValue(this.props); + + return ( + + + + + + + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && error && +
+ Error fetching cutoff unmet +
+ } + + { + isPopulated && !error && !items.length && +
+ No cutoff unmet items +
+ } + + { + isPopulated && !error && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + + + +
+ Are you sure you want to search for all {totalRecords} Cutoff Unmet episodes? +
+
+ This cannot be cancelled once started without restarting Sonarr. +
+
+ } + confirmLabel="Search" + onConfirm={this.onSearchAllCutoffUnmetConfirmed} + onCancel={this.onConfirmSearchAllCutoffUnmetModalClose} + /> +
+ } + + + ); + } +} + +CutoffUnmet.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isSearchingForCutoffUnmetEpisodes: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onSearchSelectedPress: PropTypes.func.isRequired, + batchToggleCutoffUnmetEpisodes: PropTypes.func.isRequired, + onSearchAllCutoffUnmetPress: PropTypes.func.isRequired +}; + +export default CutoffUnmet; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js new file mode 100644 index 000000000..398d1ccc5 --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js @@ -0,0 +1,183 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import * as wantedActions from 'Store/Actions/wantedActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import { fetchEpisodeFiles, clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; +import * as commandNames from 'Commands/commandNames'; +import CutoffUnmet from './CutoffUnmet'; + +function createMapStateToProps() { + return createSelector( + (state) => state.wanted.cutoffUnmet, + createCommandExecutingSelector(commandNames.CUTOFF_UNMET_EPISODE_SEARCH), + (cutoffUnmet, isSearchingForCutoffUnmetEpisodes) => { + return { + isSearchingForCutoffUnmetEpisodes, + isSaving: cutoffUnmet.items.filter((m) => m.isSaving).length > 1, + ...cutoffUnmet + }; + } + ); +} + +const mapDispatchToProps = { + ...wantedActions, + executeCommand, + fetchQueueDetails, + clearQueueDetails, + fetchEpisodeFiles, + clearEpisodeFiles +}; + +class CutoffUnmetConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchCutoffUnmet, + gotoCutoffUnmetFirstPage + } = this.props; + + registerPagePopulator(this.repopulate, ['episodeFileUpdated']); + + if (useCurrentPage) { + fetchCutoffUnmet(); + } else { + gotoCutoffUnmetFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + const episodeIds = selectUniqueIds(this.props.items, 'id'); + const episodeFileIds = selectUniqueIds(this.props.items, 'episodeFileId'); + + this.props.fetchQueueDetails({ episodeIds }); + + if (episodeFileIds.length) { + this.props.fetchEpisodeFiles({ episodeFileIds }); + } + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearCutoffUnmet(); + this.props.clearQueueDetails(); + this.props.clearEpisodeFiles(); + } + + // + // Control + + repopulate = () => { + this.props.fetchCutoffUnmet(); + } + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoCutoffUnmetFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoCutoffUnmetPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoCutoffUnmetNextPage(); + } + + onLastPagePress = () => { + this.props.gotoCutoffUnmetLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoCutoffUnmetPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setCutoffUnmetSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setCutoffUnmetFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setCutoffUnmetTableOption(payload); + + if (payload.pageSize) { + this.props.gotoCutoffUnmetFirstPage(); + } + } + + onSearchSelectedPress = (selected) => { + this.props.executeCommand({ + name: commandNames.EPISODE_SEARCH, + episodeIds: selected + }); + } + + onSearchAllCutoffUnmetPress = () => { + this.props.executeCommand({ + name: commandNames.CUTOFF_UNMET_EPISODE_SEARCH + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CutoffUnmetConnector.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchCutoffUnmet: PropTypes.func.isRequired, + gotoCutoffUnmetFirstPage: PropTypes.func.isRequired, + gotoCutoffUnmetPreviousPage: PropTypes.func.isRequired, + gotoCutoffUnmetNextPage: PropTypes.func.isRequired, + gotoCutoffUnmetLastPage: PropTypes.func.isRequired, + gotoCutoffUnmetPage: PropTypes.func.isRequired, + setCutoffUnmetSort: PropTypes.func.isRequired, + setCutoffUnmetFilter: PropTypes.func.isRequired, + setCutoffUnmetTableOption: PropTypes.func.isRequired, + clearCutoffUnmet: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired, + fetchEpisodeFiles: PropTypes.func.isRequired, + clearEpisodeFiles: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(CutoffUnmetConnector) +); diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css new file mode 100644 index 000000000..934076d15 --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css @@ -0,0 +1,7 @@ +.episode, +.language, +.status { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js new file mode 100644 index 000000000..de0b0e161 --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js @@ -0,0 +1,175 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import episodeEntities from 'Episode/episodeEntities'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector'; +import EpisodeFileLanguageConnector from 'EpisodeFile/EpisodeFileLanguageConnector'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import styles from './CutoffUnmetRow.css'; + +function CutoffUnmetRow(props) { + const { + id, + episodeFileId, + series, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + unverifiedSceneNumbering, + airDateUtc, + title, + isSelected, + columns, + onSelectedChange + } = props; + + return ( + + + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'episode') { + return ( + + + + ); + } + + if (name === 'episodeTitle') { + return ( + + + + ); + } + + if (name === 'airDateUtc') { + return ( + + ); + } + + if (name === 'language') { + return ( + + + + ); + } + + if (name === 'status') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + ); + } + + return null; + }) + } + + ); +} + +CutoffUnmetRow.propTypes = { + id: PropTypes.number.isRequired, + episodeFileId: PropTypes.number, + series: PropTypes.object.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + sceneSeasonNumber: PropTypes.number, + sceneEpisodeNumber: PropTypes.number, + sceneAbsoluteEpisodeNumber: PropTypes.number, + unverifiedSceneNumbering: PropTypes.bool.isRequired, + airDateUtc: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + isSelected: PropTypes.bool, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +export default CutoffUnmetRow; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js new file mode 100644 index 000000000..56a4815be --- /dev/null +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import CutoffUnmetRow from './CutoffUnmetRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + (series) => { + return { + series + }; + } + ); +} + +export default connect(createMapStateToProps)(CutoffUnmetRow); diff --git a/frontend/src/Wanted/Missing/Missing.js b/frontend/src/Wanted/Missing/Missing.js new file mode 100644 index 000000000..a80886e60 --- /dev/null +++ b/frontend/src/Wanted/Missing/Missing.js @@ -0,0 +1,298 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import getFilterValue from 'Utilities/Filter/getFilterValue'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, kinds } from 'Helpers/Props'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TablePager from 'Components/Table/TablePager'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import MissingRowConnector from './MissingRowConnector'; + +function getMonitoredValue(props) { + const { + filters, + selectedFilterKey + } = props; + + return getFilterValue(filters, selectedFilterKey, 'monitored', false); +} + +class Missing extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, + isConfirmSearchAllMissingModalOpen: false, + isInteractiveImportModalOpen: false + }; + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + this.setState((state) => { + return removeOldSelectedState(state, prevProps.items); + }); + } + } + + // + // Control + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + + // + // Listeners + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey); + }); + } + + onSearchSelectedPress = () => { + const selected = this.getSelectedIds(); + + this.props.onSearchSelectedPress(selected); + } + + onToggleSelectedPress = () => { + const episodeIds = this.getSelectedIds(); + + this.props.batchToggleMissingEpisodes({ + episodeIds, + monitored: !getMonitoredValue(this.props) + }); + } + + onSearchAllMissingPress = () => { + this.setState({ isConfirmSearchAllMissingModalOpen: true }); + } + + onSearchAllMissingConfirmed = () => { + this.props.onSearchAllMissingPress(); + this.setState({ isConfirmSearchAllMissingModalOpen: false }); + } + + onConfirmSearchAllMissingModalClose = () => { + this.setState({ isConfirmSearchAllMissingModalOpen: false }); + } + + onInteractiveImportPress = () => { + this.setState({ isInteractiveImportModalOpen: true }); + } + + onInteractiveImportModalClose = () => { + this.setState({ isInteractiveImportModalOpen: false }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + items, + selectedFilterKey, + filters, + columns, + totalRecords, + isSearchingForMissingEpisodes, + isSaving, + onFilterSelect, + ...otherProps + } = this.props; + + const { + allSelected, + allUnselected, + selectedState, + isConfirmSearchAllMissingModalOpen, + isInteractiveImportModalOpen + } = this.state; + + const itemsSelected = !!this.getSelectedIds().length; + const isShowingMonitored = getMonitoredValue(this.props); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + { + isFetching && !isPopulated && + + } + + { + !isFetching && error && +
+ Error fetching missing items +
+ } + + { + isPopulated && !error && !items.length && +
+ No missing items +
+ } + + { + isPopulated && !error && !!items.length && +
+ + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ + + + +
+ Are you sure you want to search for all {totalRecords} missing episodes? +
+
+ This cannot be cancelled once started without restarting Sonarr. +
+
+ } + confirmLabel="Search" + onConfirm={this.onSearchAllMissingConfirmed} + onCancel={this.onConfirmSearchAllMissingModalClose} + /> + + +
+ } + + + ); + } +} + +Missing.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.string.isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + totalRecords: PropTypes.number, + isSearchingForMissingEpisodes: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onSearchSelectedPress: PropTypes.func.isRequired, + batchToggleMissingEpisodes: PropTypes.func.isRequired, + onSearchAllMissingPress: PropTypes.func.isRequired +}; + +export default Missing; diff --git a/frontend/src/Wanted/Missing/MissingConnector.js b/frontend/src/Wanted/Missing/MissingConnector.js new file mode 100644 index 000000000..a1632c6dd --- /dev/null +++ b/frontend/src/Wanted/Missing/MissingConnector.js @@ -0,0 +1,172 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import withCurrentPage from 'Components/withCurrentPage'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import * as wantedActions from 'Store/Actions/wantedActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; +import * as commandNames from 'Commands/commandNames'; +import Missing from './Missing'; + +function createMapStateToProps() { + return createSelector( + (state) => state.wanted.missing, + createCommandExecutingSelector(commandNames.MISSING_EPISODE_SEARCH), + (missing, isSearchingForMissingEpisodes) => { + return { + isSearchingForMissingEpisodes, + isSaving: missing.items.filter((m) => m.isSaving).length > 1, + ...missing + }; + } + ); +} + +const mapDispatchToProps = { + ...wantedActions, + executeCommand, + fetchQueueDetails, + clearQueueDetails +}; + +class MissingConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + useCurrentPage, + fetchMissing, + gotoMissingFirstPage + } = this.props; + + registerPagePopulator(this.repopulate, ['episodeFileUpdated']); + + if (useCurrentPage) { + fetchMissing(); + } else { + gotoMissingFirstPage(); + } + } + + componentDidUpdate(prevProps) { + if (hasDifferentItems(prevProps.items, this.props.items)) { + const episodeIds = selectUniqueIds(this.props.items, 'id'); + this.props.fetchQueueDetails({ episodeIds }); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + this.props.clearMissing(); + this.props.clearQueueDetails(); + } + + // + // Control + + repopulate = () => { + this.props.fetchMissing(); + } + + // + // Listeners + + onFirstPagePress = () => { + this.props.gotoMissingFirstPage(); + } + + onPreviousPagePress = () => { + this.props.gotoMissingPreviousPage(); + } + + onNextPagePress = () => { + this.props.gotoMissingNextPage(); + } + + onLastPagePress = () => { + this.props.gotoMissingLastPage(); + } + + onPageSelect = (page) => { + this.props.gotoMissingPage({ page }); + } + + onSortPress = (sortKey) => { + this.props.setMissingSort({ sortKey }); + } + + onFilterSelect = (selectedFilterKey) => { + this.props.setMissingFilter({ selectedFilterKey }); + } + + onTableOptionChange = (payload) => { + this.props.setMissingTableOption(payload); + + if (payload.pageSize) { + this.props.gotoMissingFirstPage(); + } + } + + onSearchSelectedPress = (selected) => { + this.props.executeCommand({ + name: commandNames.EPISODE_SEARCH, + episodeIds: selected + }); + } + + onSearchAllMissingPress = () => { + this.props.executeCommand({ + name: commandNames.MISSING_EPISODE_SEARCH + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MissingConnector.propTypes = { + useCurrentPage: PropTypes.bool.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + fetchMissing: PropTypes.func.isRequired, + gotoMissingFirstPage: PropTypes.func.isRequired, + gotoMissingPreviousPage: PropTypes.func.isRequired, + gotoMissingNextPage: PropTypes.func.isRequired, + gotoMissingLastPage: PropTypes.func.isRequired, + gotoMissingPage: PropTypes.func.isRequired, + setMissingSort: PropTypes.func.isRequired, + setMissingFilter: PropTypes.func.isRequired, + setMissingTableOption: PropTypes.func.isRequired, + clearMissing: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired, + fetchQueueDetails: PropTypes.func.isRequired, + clearQueueDetails: PropTypes.func.isRequired +}; + +export default withCurrentPage( + connect(createMapStateToProps, mapDispatchToProps)(MissingConnector) +); diff --git a/frontend/src/Wanted/Missing/MissingRow.css b/frontend/src/Wanted/Missing/MissingRow.css new file mode 100644 index 000000000..3ec895d66 --- /dev/null +++ b/frontend/src/Wanted/Missing/MissingRow.css @@ -0,0 +1,6 @@ +.episode, +.status { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Wanted/Missing/MissingRow.js b/frontend/src/Wanted/Missing/MissingRow.js new file mode 100644 index 000000000..a8de63bd4 --- /dev/null +++ b/frontend/src/Wanted/Missing/MissingRow.js @@ -0,0 +1,165 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import episodeEntities from 'Episode/episodeEntities'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import EpisodeStatusConnector from 'Episode/EpisodeStatusConnector'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import EpisodeSearchCellConnector from 'Episode/EpisodeSearchCellConnector'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import styles from './MissingRow.css'; + +function MissingRow(props) { + const { + id, + episodeFileId, + series, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + sceneSeasonNumber, + sceneEpisodeNumber, + sceneAbsoluteEpisodeNumber, + unverifiedSceneNumbering, + airDateUtc, + title, + isSelected, + columns, + onSelectedChange + } = props; + + if (!series) { + return null; + } + + return ( + + + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'episode') { + return ( + + + + ); + } + + if (name === 'episodeTitle') { + return ( + + + + ); + } + + if (name === 'airDateUtc') { + return ( + + ); + } + + if (name === 'status') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + ); + } + + return null; + }) + } + + ); +} + +MissingRow.propTypes = { + id: PropTypes.number.isRequired, + episodeFileId: PropTypes.number, + series: PropTypes.object.isRequired, + seasonNumber: PropTypes.number.isRequired, + episodeNumber: PropTypes.number.isRequired, + absoluteEpisodeNumber: PropTypes.number, + sceneSeasonNumber: PropTypes.number, + sceneEpisodeNumber: PropTypes.number, + sceneAbsoluteEpisodeNumber: PropTypes.number, + unverifiedSceneNumbering: PropTypes.bool.isRequired, + airDateUtc: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + isSelected: PropTypes.bool, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +export default MissingRow; diff --git a/frontend/src/Wanted/Missing/MissingRowConnector.js b/frontend/src/Wanted/Missing/MissingRowConnector.js new file mode 100644 index 000000000..f7eefca4d --- /dev/null +++ b/frontend/src/Wanted/Missing/MissingRowConnector.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; +import MissingRow from './MissingRow'; + +function createMapStateToProps() { + return createSelector( + createSeriesSelector(), + (series) => { + return { + series + }; + } + ); +} + +export default connect(createMapStateToProps)(MissingRow); diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 000000000..04e0e11ef --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,15 @@ +html, +body { + height: 100%; /* needed for proper layout */ +} + +body { + overflow: hidden; + background-color: #f5f7fa; +} + +@media only screen and (max-width: $breakpointSmall) { + body { + overflow-y: auto; + } +} diff --git a/frontend/src/index.html b/frontend/src/index.html new file mode 100644 index 000000000..99fbfb103 --- /dev/null +++ b/frontend/src/index.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Sonarr (Preview) + + + + + + + +
+ + + + + + + + + diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 000000000..396a7971c --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from 'react-dom'; +import createHistory from 'history/createBrowserHistory'; +import createAppStore from 'Store/createAppStore'; +import App from './App/App'; +import 'Styles/globals.css'; +import './index.css'; + +const history = createHistory(); +const store = createAppStore(history); + +render( + , + document.getElementById('root') +); diff --git a/frontend/src/jQuery/jquery.ajax.js b/frontend/src/jQuery/jquery.ajax.js new file mode 100644 index 000000000..9b217801e --- /dev/null +++ b/frontend/src/jQuery/jquery.ajax.js @@ -0,0 +1,47 @@ +import $ from 'jquery'; + +const absUrlRegex = /^(https?:)?\/\//i; +const apiRoot = window.Sonarr.apiRoot; +const urlBase = window.Sonarr.urlBase; + +function isRelative(xhr) { + return !absUrlRegex.test(xhr.url); +} + +function moveBodyToQuery(xhr) { + if (xhr.data && xhr.type === 'DELETE') { + if (xhr.url.contains('?')) { + xhr.url += '&'; + } else { + xhr.url += '?'; + } + xhr.url += $.param(xhr.data); + delete xhr.data; + } +} + +function addRootUrl(xhr) { + const url = xhr.url; + if (url.startsWith('/signalr')) { + xhr.url = urlBase + xhr.url; + } else { + xhr.url = apiRoot + xhr.url; + } +} + +function addApiKey(xhr) { + xhr.headers = xhr.headers || {}; + xhr.headers['X-Api-Key'] = window.Sonarr.apiKey; +} + +export default function() { + const originalAjax = $.ajax; + $.ajax = function(xhr) { + if (xhr && isRelative(xhr)) { + moveBodyToQuery(xhr); + addRootUrl(xhr); + addApiKey(xhr); + } + return originalAjax.apply(this, arguments); + }; +} diff --git a/frontend/src/login.html b/frontend/src/login.html new file mode 100644 index 000000000..930018edf --- /dev/null +++ b/frontend/src/login.html @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + Login - Sonarr + + + + + +
+
+
+
+ +
+ +
+ + +
+
+ +
+ +
+ +
+ +
+ + + + + + 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..674699db9 --- /dev/null +++ b/frontend/src/preload.js @@ -0,0 +1,4 @@ +/* eslint no-undef: 0 */ +import 'Shims/jquery'; + +__webpack_public_path__ = `${window.Sonarr.urlBase}/`; diff --git a/frontend/src/vendor.js b/frontend/src/vendor.js new file mode 100644 index 000000000..68394a5f7 --- /dev/null +++ b/frontend/src/vendor.js @@ -0,0 +1,28 @@ +/* Base */ +// require('jquery'); +require('lodash'); +require('moment'); +// require('signalR'); +// require('jquery-ui'); +// require('jquery.easypiechart'); +// require('jquery.dotdotdot'); +// require('typeahead'); +// require('zero.clipboard'); + +/* Bootstrap */ +// require('bootstrap'); +// require('bootstrap.tagsinput'); + +/* Backbone */ +// require('backbone'); +// require('backbone.deepmodel'); +// require('backbone.paginator'); + +// require('backbone.modelbinder'); +// require('backbone.collectionview'); +// require('backgrid'); +// require('backgrid.paginator'); +// require('backgrid.selectall'); + +// require('marionette'); // this brings in a bunch of our code into this chunk because of template helpers. +// require('vent'); diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json deleted file mode 100644 index dfdacfc11..000000000 --- a/npm-shrinkwrap.json +++ /dev/null @@ -1,4809 +0,0 @@ -{ - "name": "Sonarr", - "version": "2.0.0", - "dependencies": { - "acorn": { - "version": "3.1.0", - "from": "acorn@>=3.1.0 <4.0.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.1.0.tgz" - }, - "acorn-jsx": { - "version": "3.0.1", - "from": "acorn-jsx@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz" - }, - "acorn-to-esprima": { - "version": "2.0.8", - "from": "acorn-to-esprima@>=2.0.6 <3.0.0", - "resolved": "https://registry.npmjs.org/acorn-to-esprima/-/acorn-to-esprima-2.0.8.tgz" - }, - "align-text": { - "version": "0.1.4", - "from": "align-text@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz" - }, - "alphanum-sort": { - "version": "1.0.2", - "from": "alphanum-sort@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz" - }, - "amdefine": { - "version": "1.0.0", - "from": "amdefine@>=0.0.4", - "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.0.tgz" - }, - "ansi-escapes": { - "version": "1.4.0", - "from": "ansi-escapes@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz" - }, - "ansi-regex": { - "version": "2.0.0", - "from": "ansi-regex@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.0.0.tgz" - }, - "ansi-styles": { - "version": "2.2.1", - "from": "ansi-styles@>=2.2.1 <3.0.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz" - }, - "anymatch": { - "version": "1.3.0", - "from": "anymatch@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-1.3.0.tgz" - }, - "archy": { - "version": "1.0.0", - "from": "archy@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz" - }, - "argparse": { - "version": "1.0.7", - "from": "argparse@>=1.0.7 <2.0.0", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.7.tgz" - }, - "arr-diff": { - "version": "2.0.0", - "from": "arr-diff@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz" - }, - "arr-flatten": { - "version": "1.0.1", - "from": "arr-flatten@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.0.1.tgz" - }, - "array-differ": { - "version": "1.0.0", - "from": "array-differ@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz" - }, - "array-find-index": { - "version": "1.0.1", - "from": "array-find-index@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.1.tgz" - }, - "array-union": { - "version": "1.0.1", - "from": "array-union@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.1.tgz" - }, - "array-uniq": { - "version": "1.0.2", - "from": "array-uniq@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.2.tgz" - }, - "array-unique": { - "version": "0.2.1", - "from": "array-unique@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz" - }, - "arrify": { - "version": "1.0.1", - "from": "arrify@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz" - }, - "asap": { - "version": "2.0.4", - "from": "asap@>=2.0.3 <2.1.0", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.4.tgz" - }, - "assert": { - "version": "1.4.0", - "from": "assert@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.0.tgz" - }, - "assertion-error": { - "version": "1.0.1", - "from": "assertion-error@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.1.tgz" - }, - "async": { - "version": "0.2.10", - "from": "async@>=0.2.6 <0.3.0", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz" - }, - "async-each": { - "version": "1.0.0", - "from": "async-each@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.0.tgz" - }, - "autoprefixer": { - "version": "6.3.6", - "from": "autoprefixer@6.3.6", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-6.3.6.tgz" - }, - "babel-code-frame": { - "version": "6.8.0", - "from": "babel-code-frame@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.8.0.tgz" - }, - "babel-core": { - "version": "6.9.0", - "from": "babel-core@6.9.0", - "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.9.0.tgz" - }, - "babel-eslint": { - "version": "7.1.0", - "from": "babel-eslint@7.1.0", - "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-7.1.0.tgz", - "dependencies": { - "babel-code-frame": { - "version": "6.16.0", - "from": "babel-code-frame@>=6.16.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.16.0.tgz" - }, - "babel-traverse": { - "version": "6.18.0", - "from": "babel-traverse@>=6.15.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.18.0.tgz" - }, - "babel-types": { - "version": "6.18.0", - "from": "babel-types@>=6.15.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.18.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.18.0", - "from": "babel-runtime@>=6.9.1 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.18.0.tgz" - } - } - }, - "babylon": { - "version": "6.13.1", - "from": "babylon@>=6.11.2 <7.0.0", - "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.13.1.tgz" - }, - "globals": { - "version": "9.12.0", - "from": "globals@>=9.0.0 <10.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-9.12.0.tgz" - }, - "js-tokens": { - "version": "2.0.0", - "from": "js-tokens@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-2.0.0.tgz" - } - } - }, - "babel-generator": { - "version": "6.9.0", - "from": "babel-generator@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.9.0.tgz" - }, - "babel-helper-builder-binary-assignment-operator-visitor": { - "version": "6.8.0", - "from": "babel-helper-builder-binary-assignment-operator-visitor@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.8.0.tgz" - }, - "babel-helper-builder-react-jsx": { - "version": "6.22.0", - "from": "babel-helper-builder-react-jsx@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-builder-react-jsx/-/babel-helper-builder-react-jsx-6.22.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@^6.22.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "babel-types": { - "version": "6.22.0", - "from": "babel-types@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.22.0.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@^0.10.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "babel-helper-call-delegate": { - "version": "6.8.0", - "from": "babel-helper-call-delegate@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-call-delegate/-/babel-helper-call-delegate-6.8.0.tgz" - }, - "babel-helper-define-map": { - "version": "6.9.0", - "from": "babel-helper-define-map@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.9.0.tgz" - }, - "babel-helper-explode-assignable-expression": { - "version": "6.8.0", - "from": "babel-helper-explode-assignable-expression@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.8.0.tgz" - }, - "babel-helper-function-name": { - "version": "6.8.0", - "from": "babel-helper-function-name@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.8.0.tgz" - }, - "babel-helper-get-function-arity": { - "version": "6.8.0", - "from": "babel-helper-get-function-arity@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.8.0.tgz" - }, - "babel-helper-hoist-variables": { - "version": "6.8.0", - "from": "babel-helper-hoist-variables@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.8.0.tgz" - }, - "babel-helper-optimise-call-expression": { - "version": "6.8.0", - "from": "babel-helper-optimise-call-expression@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.8.0.tgz" - }, - "babel-helper-regex": { - "version": "6.9.0", - "from": "babel-helper-regex@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.9.0.tgz" - }, - "babel-helper-remap-async-to-generator": { - "version": "6.11.2", - "from": "babel-helper-remap-async-to-generator@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.11.2.tgz" - }, - "babel-helper-replace-supers": { - "version": "6.8.0", - "from": "babel-helper-replace-supers@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helper-replace-supers/-/babel-helper-replace-supers-6.8.0.tgz" - }, - "babel-helpers": { - "version": "6.8.0", - "from": "babel-helpers@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.8.0.tgz" - }, - "babel-loader": { - "version": "6.2.4", - "from": "babel-loader@6.2.4", - "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-6.2.4.tgz" - }, - "babel-messages": { - "version": "6.8.0", - "from": "babel-messages@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.8.0.tgz" - }, - "babel-plugin-check-es2015-constants": { - "version": "6.8.0", - "from": "babel-plugin-check-es2015-constants@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.8.0.tgz" - }, - "babel-plugin-syntax-async-functions": { - "version": "6.8.0", - "from": "babel-plugin-syntax-async-functions@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.8.0.tgz" - }, - "babel-plugin-syntax-class-properties": { - "version": "6.13.0", - "from": "babel-plugin-syntax-class-properties@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz" - }, - "babel-plugin-syntax-decorators": { - "version": "6.8.0", - "from": "babel-plugin-syntax-decorators@>=6.1.18 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-decorators/-/babel-plugin-syntax-decorators-6.8.0.tgz" - }, - "babel-plugin-syntax-exponentiation-operator": { - "version": "6.8.0", - "from": "babel-plugin-syntax-exponentiation-operator@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.8.0.tgz" - }, - "babel-plugin-syntax-flow": { - "version": "6.18.0", - "from": "babel-plugin-syntax-flow@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz" - }, - "babel-plugin-syntax-jsx": { - "version": "6.18.0", - "from": "babel-plugin-syntax-jsx@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz" - }, - "babel-plugin-syntax-object-rest-spread": { - "version": "6.8.0", - "from": "babel-plugin-syntax-object-rest-spread@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.8.0.tgz" - }, - "babel-plugin-syntax-trailing-function-commas": { - "version": "6.8.0", - "from": "babel-plugin-syntax-trailing-function-commas@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.8.0.tgz" - }, - "babel-plugin-transform-async-to-generator": { - "version": "6.8.0", - "from": "babel-plugin-transform-async-to-generator@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.8.0.tgz" - }, - "babel-plugin-transform-class-properties": { - "version": "6.16.0", - "from": "babel-plugin-transform-class-properties@6.16.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-class-properties/-/babel-plugin-transform-class-properties-6.16.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.11.6", - "from": "babel-runtime@>=6.9.1 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.11.6.tgz" - } - } - }, - "babel-plugin-transform-decorators-legacy": { - "version": "1.3.4", - "from": "babel-plugin-transform-decorators-legacy@>=1.3.4 <2.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-decorators-legacy/-/babel-plugin-transform-decorators-legacy-1.3.4.tgz" - }, - "babel-plugin-transform-es2015-arrow-functions": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-arrow-functions@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-block-scoped-functions": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-block-scoped-functions@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-block-scoping": { - "version": "6.9.0", - "from": "babel-plugin-transform-es2015-block-scoping@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.9.0.tgz" - }, - "babel-plugin-transform-es2015-classes": { - "version": "6.9.0", - "from": "babel-plugin-transform-es2015-classes@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.9.0.tgz" - }, - "babel-plugin-transform-es2015-computed-properties": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-computed-properties@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-destructuring": { - "version": "6.9.0", - "from": "babel-plugin-transform-es2015-destructuring@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.9.0.tgz" - }, - "babel-plugin-transform-es2015-duplicate-keys": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-duplicate-keys@>=6.6.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-for-of": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-for-of@>=6.6.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-function-name": { - "version": "6.9.0", - "from": "babel-plugin-transform-es2015-function-name@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.9.0.tgz" - }, - "babel-plugin-transform-es2015-literals": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-literals@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-modules-commonjs": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-modules-commonjs@>=6.6.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-object-super": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-object-super@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-parameters": { - "version": "6.9.0", - "from": "babel-plugin-transform-es2015-parameters@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.9.0.tgz" - }, - "babel-plugin-transform-es2015-shorthand-properties": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-shorthand-properties@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-spread": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-spread@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-sticky-regex": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-sticky-regex@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-template-literals": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-template-literals@>=6.6.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-typeof-symbol": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-typeof-symbol@>=6.6.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.8.0.tgz" - }, - "babel-plugin-transform-es2015-unicode-regex": { - "version": "6.8.0", - "from": "babel-plugin-transform-es2015-unicode-regex@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.8.0.tgz" - }, - "babel-plugin-transform-exponentiation-operator": { - "version": "6.8.0", - "from": "babel-plugin-transform-exponentiation-operator@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.8.0.tgz" - }, - "babel-plugin-transform-flow-strip-types": { - "version": "6.22.0", - "from": "babel-plugin-transform-flow-strip-types@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@>=0.10.0 <0.11.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "babel-plugin-transform-object-rest-spread": { - "version": "6.8.0", - "from": "babel-plugin-transform-object-rest-spread@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.8.0.tgz" - }, - "babel-plugin-transform-react-display-name": { - "version": "6.22.0", - "from": "babel-plugin-transform-react-display-name@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.22.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@^6.22.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@^0.10.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "babel-plugin-transform-react-jsx": { - "version": "6.22.0", - "from": "babel-plugin-transform-react-jsx@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx/-/babel-plugin-transform-react-jsx-6.22.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@^6.22.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@^0.10.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "babel-plugin-transform-react-jsx-self": { - "version": "6.22.0", - "from": "babel-plugin-transform-react-jsx-self@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-self/-/babel-plugin-transform-react-jsx-self-6.22.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@^6.22.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@^0.10.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "babel-plugin-transform-react-jsx-source": { - "version": "6.22.0", - "from": "babel-plugin-transform-react-jsx-source@>=6.22.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-source/-/babel-plugin-transform-react-jsx-source-6.22.0.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@^6.22.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@^0.10.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "babel-plugin-transform-regenerator": { - "version": "6.9.0", - "from": "babel-plugin-transform-regenerator@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.9.0.tgz" - }, - "babel-plugin-transform-strict-mode": { - "version": "6.8.0", - "from": "babel-plugin-transform-strict-mode@>=6.8.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.8.0.tgz" - }, - "babel-preset-decorators-legacy": { - "version": "1.0.0", - "from": "babel-preset-decorators-legacy@1.0.0", - "resolved": "https://registry.npmjs.org/babel-preset-decorators-legacy/-/babel-preset-decorators-legacy-1.0.0.tgz" - }, - "babel-preset-es2015": { - "version": "6.9.0", - "from": "babel-preset-es2015@6.9.0", - "resolved": "https://registry.npmjs.org/babel-preset-es2015/-/babel-preset-es2015-6.9.0.tgz" - }, - "babel-preset-react": { - "version": "6.22.0", - "from": "babel-preset-react@6.22.0", - "resolved": "https://registry.npmjs.org/babel-preset-react/-/babel-preset-react-6.22.0.tgz" - }, - "babel-preset-stage-2": { - "version": "6.5.0", - "from": "babel-preset-stage-2@6.5.0", - "resolved": "https://registry.npmjs.org/babel-preset-stage-2/-/babel-preset-stage-2-6.5.0.tgz" - }, - "babel-preset-stage-3": { - "version": "6.11.0", - "from": "babel-preset-stage-3@>=6.3.13 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-preset-stage-3/-/babel-preset-stage-3-6.11.0.tgz" - }, - "babel-register": { - "version": "6.9.0", - "from": "babel-register@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.9.0.tgz" - }, - "babel-runtime": { - "version": "6.9.0", - "from": "babel-runtime@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.9.0.tgz" - }, - "babel-template": { - "version": "6.9.0", - "from": "babel-template@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.9.0.tgz" - }, - "babel-traverse": { - "version": "6.9.0", - "from": "babel-traverse@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.9.0.tgz" - }, - "babel-types": { - "version": "6.9.0", - "from": "babel-types@>=6.9.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.9.0.tgz" - }, - "babylon": { - "version": "6.8.0", - "from": "babylon@>=6.7.0 <7.0.0", - "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.8.0.tgz" - }, - "balanced-match": { - "version": "0.4.1", - "from": "balanced-match@>=0.4.1 <0.5.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.1.tgz" - }, - "Base64": { - "version": "0.2.1", - "from": "Base64@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/Base64/-/Base64-0.2.1.tgz" - }, - "base64-js": { - "version": "0.0.8", - "from": "base64-js@0.0.8", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz" - }, - "beeper": { - "version": "1.1.0", - "from": "beeper@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/beeper/-/beeper-1.1.0.tgz" - }, - "big.js": { - "version": "3.1.3", - "from": "big.js@>=3.1.3 <4.0.0", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-3.1.3.tgz" - }, - "binary-extensions": { - "version": "1.4.0", - "from": "binary-extensions@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.4.0.tgz" - }, - "bl": { - "version": "0.7.0", - "from": "bl@>=0.7.0 <0.8.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-0.7.0.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.2 <1.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - } - } - }, - "block-stream": { - "version": "0.0.9", - "from": "block-stream@*", - "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz" - }, - "bluebird": { - "version": "3.4.0", - "from": "bluebird@>=3.1.1 <4.0.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.0.tgz" - }, - "body-parser": { - "version": "1.14.2", - "from": "body-parser@>=1.14.0 <1.15.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.14.2.tgz", - "dependencies": { - "qs": { - "version": "5.2.0", - "from": "qs@5.2.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-5.2.0.tgz" - } - } - }, - "brace-expansion": { - "version": "1.1.4", - "from": "brace-expansion@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.4.tgz" - }, - "braces": { - "version": "1.8.5", - "from": "braces@>=1.8.2 <2.0.0", - "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz" - }, - "browserify-zlib": { - "version": "0.1.4", - "from": "browserify-zlib@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz" - }, - "browserslist": { - "version": "1.3.4", - "from": "browserslist@>=1.3.1 <1.4.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-1.3.4.tgz" - }, - "buffer": { - "version": "3.6.0", - "from": "buffer@>=3.0.3 <4.0.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-3.6.0.tgz" - }, - "buffer-shims": { - "version": "1.0.0", - "from": "buffer-shims@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz" - }, - "bufferstreams": { - "version": "1.0.1", - "from": "bufferstreams@1.0.1", - "resolved": "https://registry.npmjs.org/bufferstreams/-/bufferstreams-1.0.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.0.33 <2.0.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "builtin-modules": { - "version": "1.1.1", - "from": "builtin-modules@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz" - }, - "bytes": { - "version": "2.2.0", - "from": "bytes@2.2.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.2.0.tgz" - }, - "caller-path": { - "version": "0.1.0", - "from": "caller-path@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz" - }, - "callsites": { - "version": "0.2.0", - "from": "callsites@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz" - }, - "camelcase": { - "version": "2.1.1", - "from": "camelcase@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz" - }, - "camelcase-keys": { - "version": "2.1.0", - "from": "camelcase-keys@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz" - }, - "caniuse-db": { - "version": "1.0.30000492", - "from": "caniuse-db@>=1.0.30000444 <2.0.0", - "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000492.tgz" - }, - "center-align": { - "version": "0.1.3", - "from": "center-align@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz" - }, - "chai": { - "version": "3.5.0", - "from": "chai@>=3.2.0 <4.0.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz" - }, - "chalk": { - "version": "1.1.3", - "from": "chalk@>=1.1.3 <2.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "dependencies": { - "supports-color": { - "version": "2.0.0", - "from": "supports-color@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz" - } - } - }, - "chokidar": { - "version": "1.5.1", - "from": "chokidar@>=1.0.3 <2.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.5.1.tgz" - }, - "circular-json": { - "version": "0.3.1", - "from": "circular-json@>=0.3.1 <0.4.0", - "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.1.tgz" - }, - "clap": { - "version": "1.1.1", - "from": "clap@>=1.0.9 <2.0.0", - "resolved": "https://registry.npmjs.org/clap/-/clap-1.1.1.tgz" - }, - "classnames": { - "version": "2.2.5", - "from": "classnames@2.2.5", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz" - }, - "cli-cursor": { - "version": "1.0.2", - "from": "cli-cursor@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz" - }, - "cli-width": { - "version": "2.1.0", - "from": "cli-width@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.1.0.tgz" - }, - "cliui": { - "version": "2.1.0", - "from": "cliui@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", - "dependencies": { - "wordwrap": { - "version": "0.0.2", - "from": "wordwrap@0.0.2", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz" - } - } - }, - "clone": { - "version": "1.0.2", - "from": "clone@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.2.tgz" - }, - "clone-regexp": { - "version": "1.0.0", - "from": "clone-regexp@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-1.0.0.tgz" - }, - "clone-stats": { - "version": "0.0.1", - "from": "clone-stats@>=0.0.1 <0.0.2", - "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-0.0.1.tgz" - }, - "coa": { - "version": "1.0.1", - "from": "coa@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/coa/-/coa-1.0.1.tgz" - }, - "code-point-at": { - "version": "1.0.0", - "from": "code-point-at@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.0.0.tgz" - }, - "color": { - "version": "0.11.3", - "from": "color@>=0.11.0 <0.12.0", - "resolved": "https://registry.npmjs.org/color/-/color-0.11.3.tgz" - }, - "color-convert": { - "version": "1.3.1", - "from": "color-convert@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.3.1.tgz" - }, - "color-diff": { - "version": "0.1.7", - "from": "color-diff@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/color-diff/-/color-diff-0.1.7.tgz" - }, - "color-name": { - "version": "1.1.1", - "from": "color-name@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.1.tgz" - }, - "color-string": { - "version": "0.3.0", - "from": "color-string@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-0.3.0.tgz" - }, - "colorguard": { - "version": "1.2.0", - "from": "colorguard@>=1.2.0 <2.0.0", - "resolved": "https://registry.npmjs.org/colorguard/-/colorguard-1.2.0.tgz", - "dependencies": { - "yargs": { - "version": "1.3.3", - "from": "yargs@>=1.2.6 <2.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-1.3.3.tgz" - } - } - }, - "colormin": { - "version": "1.1.0", - "from": "colormin@>=1.0.5 <2.0.0", - "resolved": "https://registry.npmjs.org/colormin/-/colormin-1.1.0.tgz" - }, - "colors": { - "version": "1.1.2", - "from": "colors@>=1.1.2 <1.2.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz" - }, - "commander": { - "version": "2.9.0", - "from": "commander@>=2.2.0 <3.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz" - }, - "concat-map": { - "version": "0.0.1", - "from": "concat-map@0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" - }, - "concat-stream": { - "version": "1.5.1", - "from": "concat-stream@>=1.4.7 <2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.5.1.tgz" - }, - "concat-with-sourcemaps": { - "version": "1.0.4", - "from": "concat-with-sourcemaps@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.0.4.tgz" - }, - "console-browserify": { - "version": "1.1.0", - "from": "console-browserify@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz" - }, - "consolidate": { - "version": "0.14.1", - "from": "consolidate@>=0.14.1 <0.15.0", - "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.14.1.tgz" - }, - "constants-browserify": { - "version": "0.0.1", - "from": "constants-browserify@0.0.1", - "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-0.0.1.tgz" - }, - "content-type": { - "version": "1.0.2", - "from": "content-type@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.2.tgz" - }, - "convert-source-map": { - "version": "1.2.0", - "from": "convert-source-map@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.2.0.tgz" - }, - "core-js": { - "version": "2.4.0", - "from": "core-js@>=2.4.0 <3.0.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.4.0.tgz" - }, - "core-util-is": { - "version": "1.0.2", - "from": "core-util-is@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" - }, - "cosmiconfig": { - "version": "1.1.0", - "from": "cosmiconfig@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-1.1.0.tgz" - }, - "crypto-browserify": { - "version": "3.2.8", - "from": "crypto-browserify@>=3.2.6 <3.3.0", - "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.2.8.tgz" - }, - "css-color-names": { - "version": "0.0.3", - "from": "css-color-names@0.0.3", - "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.3.tgz" - }, - "css-loader": { - "version": "0.23.1", - "from": "css-loader@0.23.1", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-0.23.1.tgz" - }, - "css-rule-stream": { - "version": "1.1.0", - "from": "css-rule-stream@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/css-rule-stream/-/css-rule-stream-1.1.0.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.3 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "css-selector-tokenizer": { - "version": "0.5.4", - "from": "css-selector-tokenizer@>=0.5.1 <0.6.0", - "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.5.4.tgz" - }, - "css-tokenize": { - "version": "1.0.1", - "from": "css-tokenize@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/css-tokenize/-/css-tokenize-1.0.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.0.33 <2.0.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "cssesc": { - "version": "0.1.0", - "from": "cssesc@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-0.1.0.tgz" - }, - "cssnano": { - "version": "3.7.1", - "from": "cssnano@>=2.6.1 <4.0.0", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-3.7.1.tgz" - }, - "csso": { - "version": "2.0.0", - "from": "csso@>=2.0.0 <2.1.0", - "resolved": "https://registry.npmjs.org/csso/-/csso-2.0.0.tgz" - }, - "d": { - "version": "0.1.1", - "from": "d@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/d/-/d-0.1.1.tgz" - }, - "date-now": { - "version": "0.1.4", - "from": "date-now@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz" - }, - "dateformat": { - "version": "1.0.12", - "from": "dateformat@>=1.0.11 <2.0.0", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.12.tgz" - }, - "debug": { - "version": "2.2.0", - "from": "debug@>=2.1.1 <3.0.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz" - }, - "decamelize": { - "version": "1.2.0", - "from": "decamelize@>=1.1.2 <2.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" - }, - "deep-eql": { - "version": "0.1.3", - "from": "deep-eql@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", - "dependencies": { - "type-detect": { - "version": "0.1.1", - "from": "type-detect@0.1.1", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz" - } - } - }, - "deep-is": { - "version": "0.1.3", - "from": "deep-is@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz" - }, - "defaults": { - "version": "1.0.3", - "from": "defaults@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz" - }, - "defined": { - "version": "1.0.0", - "from": "defined@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz" - }, - "del": { - "version": "2.2.0", - "from": "del@2.2.0", - "resolved": "https://registry.npmjs.org/del/-/del-2.2.0.tgz" - }, - "depd": { - "version": "1.1.0", - "from": "depd@>=1.1.0 <1.2.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz" - }, - "deprecated": { - "version": "0.0.1", - "from": "deprecated@>=0.0.1 <0.0.2", - "resolved": "https://registry.npmjs.org/deprecated/-/deprecated-0.0.1.tgz" - }, - "detect-indent": { - "version": "3.0.1", - "from": "detect-indent@>=3.0.1 <4.0.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-3.0.1.tgz" - }, - "diff": { - "version": "1.4.0", - "from": "diff@>=1.3.2 <2.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-1.4.0.tgz" - }, - "disparity": { - "version": "2.0.0", - "from": "disparity@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/disparity/-/disparity-2.0.0.tgz" - }, - "disposables": { - "version": "1.0.1", - "from": "disposables@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/disposables/-/disposables-1.0.1.tgz" - }, - "dnd-core": { - "version": "2.0.2", - "from": "dnd-core@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-2.0.2.tgz" - }, - "doctrine": { - "version": "1.2.2", - "from": "doctrine@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-1.2.2.tgz", - "dependencies": { - "esutils": { - "version": "1.1.6", - "from": "esutils@>=1.1.6 <2.0.0", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-1.1.6.tgz" - } - } - }, - "doiuse": { - "version": "2.4.1", - "from": "doiuse@>=2.4.1 <3.0.0", - "resolved": "https://registry.npmjs.org/doiuse/-/doiuse-2.4.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "source-map": { - "version": "0.4.4", - "from": "source-map@>=0.4.2 <0.5.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.3 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "dom-helpers": { - "version": "3.2.0", - "from": "dom-helpers@>=2.4.0 <3.0.0||>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.2.0.tgz" - }, - "domain-browser": { - "version": "1.1.7", - "from": "domain-browser@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz" - }, - "duplexer": { - "version": "0.1.1", - "from": "duplexer@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz" - }, - "duplexer2": { - "version": "0.0.2", - "from": "duplexer2@0.0.2", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.0.2.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.1.9 <1.2.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "ee-first": { - "version": "1.1.1", - "from": "ee-first@1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" - }, - "element-class": { - "version": "0.2.2", - "from": "element-class@latest", - "resolved": "https://registry.npmjs.org/element-class/-/element-class-0.2.2.tgz" - }, - "emojis-list": { - "version": "2.0.1", - "from": "emojis-list@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.0.1.tgz" - }, - "encoding": { - "version": "0.1.12", - "from": "encoding@>=0.1.11 <0.2.0", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz" - }, - "end-of-stream": { - "version": "0.1.5", - "from": "end-of-stream@>=0.1.5 <0.2.0", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-0.1.5.tgz" - }, - "enhanced-resolve": { - "version": "0.9.1", - "from": "enhanced-resolve@>=0.9.0 <0.10.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz", - "dependencies": { - "memory-fs": { - "version": "0.2.0", - "from": "memory-fs@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz" - } - } - }, - "errno": { - "version": "0.1.4", - "from": "errno@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.4.tgz" - }, - "error-ex": { - "version": "1.3.0", - "from": "error-ex@>=1.2.0 <2.0.0", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.0.tgz" - }, - "es5-ext": { - "version": "0.10.11", - "from": "es5-ext@>=0.10.8 <0.11.0", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.11.tgz" - }, - "es6-iterator": { - "version": "2.0.0", - "from": "es6-iterator@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.0.tgz" - }, - "es6-map": { - "version": "0.1.4", - "from": "es6-map@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/es6-map/-/es6-map-0.1.4.tgz", - "dependencies": { - "es6-symbol": { - "version": "3.1.0", - "from": "es6-symbol@>=3.1.0 <3.2.0", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.0.tgz" - } - } - }, - "es6-promise": { - "version": "3.2.1", - "from": "es6-promise@>=3.1.2 <4.0.0", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz" - }, - "es6-set": { - "version": "0.1.4", - "from": "es6-set@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.4.tgz" - }, - "es6-symbol": { - "version": "3.0.2", - "from": "es6-symbol@>=3.0.1 <3.1.0", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.0.2.tgz" - }, - "es6-weak-map": { - "version": "2.0.1", - "from": "es6-weak-map@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.1.tgz" - }, - "escape-string-regexp": { - "version": "1.0.5", - "from": "escape-string-regexp@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz" - }, - "escope": { - "version": "3.6.0", - "from": "escope@>=3.6.0 <4.0.0", - "resolved": "https://registry.npmjs.org/escope/-/escope-3.6.0.tgz" - }, - "esformatter": { - "version": "0.9.3", - "from": "esformatter@0.9.3", - "resolved": "https://registry.npmjs.org/esformatter/-/esformatter-0.9.3.tgz", - "dependencies": { - "debug": { - "version": "0.7.4", - "from": "debug@>=0.7.4 <0.8.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz" - }, - "glob": { - "version": "5.0.15", - "from": "glob@>=5.0.3 <6.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz" - }, - "supports-color": { - "version": "1.3.1", - "from": "supports-color@>=1.3.1 <2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-1.3.1.tgz" - }, - "user-home": { - "version": "2.0.0", - "from": "user-home@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz" - } - } - }, - "eslint": { - "version": "2.10.2", - "from": "eslint@2.10.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-2.10.2.tgz", - "dependencies": { - "glob": { - "version": "7.1.1", - "from": "glob@>=7.0.3 <8.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.1.tgz" - }, - "globals": { - "version": "9.14.0", - "from": "globals@>=9.2.0 <10.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-9.14.0.tgz" - }, - "minimatch": { - "version": "3.0.3", - "from": "minimatch@>=3.0.2 <4.0.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz" - }, - "strip-json-comments": { - "version": "1.0.4", - "from": "strip-json-comments@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz" - }, - "user-home": { - "version": "2.0.0", - "from": "user-home@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz" - } - } - }, - "eslint-loader": { - "version": "1.3.0", - "from": "eslint-loader@1.3.0", - "resolved": "https://registry.npmjs.org/eslint-loader/-/eslint-loader-1.3.0.tgz" - }, - "eslint-plugin-filenames": { - "version": "1.0.0", - "from": "eslint-plugin-filenames@1.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-filenames/-/eslint-plugin-filenames-1.0.0.tgz" - }, - "eslint-plugin-react": { - "version": "5.2.2", - "from": "eslint-plugin-react@5.2.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-5.2.2.tgz" - }, - "espree": { - "version": "3.1.4", - "from": "espree@3.1.4", - "resolved": "https://registry.npmjs.org/espree/-/espree-3.1.4.tgz" - }, - "esprima": { - "version": "2.7.2", - "from": "esprima@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.2.tgz" - }, - "esrecurse": { - "version": "4.1.0", - "from": "esrecurse@>=4.1.0 <5.0.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.1.0.tgz", - "dependencies": { - "estraverse": { - "version": "4.1.1", - "from": "estraverse@>=4.1.0 <4.2.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.1.1.tgz" - } - } - }, - "estraverse": { - "version": "4.2.0", - "from": "estraverse@>=4.2.0 <5.0.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz" - }, - "esutils": { - "version": "2.0.2", - "from": "esutils@>=2.0.2 <3.0.0", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz" - }, - "event-emitter": { - "version": "0.3.4", - "from": "event-emitter@>=0.3.4 <0.4.0", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.4.tgz" - }, - "event-stream": { - "version": "3.3.2", - "from": "event-stream@>=3.1.7 <4.0.0", - "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.2.tgz" - }, - "events": { - "version": "1.1.0", - "from": "events@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/events/-/events-1.1.0.tgz" - }, - "execall": { - "version": "1.0.0", - "from": "execall@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/execall/-/execall-1.0.0.tgz" - }, - "exenv": { - "version": "1.2.1", - "from": "exenv@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.1.tgz" - }, - "exit-hook": { - "version": "1.1.1", - "from": "exit-hook@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz" - }, - "expand-brackets": { - "version": "0.1.5", - "from": "expand-brackets@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz" - }, - "expand-range": { - "version": "1.8.2", - "from": "expand-range@>=1.8.1 <2.0.0", - "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz" - }, - "extend": { - "version": "2.0.1", - "from": "extend@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/extend/-/extend-2.0.1.tgz" - }, - "extglob": { - "version": "0.3.2", - "from": "extglob@>=0.3.1 <0.4.0", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz" - }, - "extract-text-webpack-plugin": { - "version": "1.0.1", - "from": "extract-text-webpack-plugin@1.0.1", - "resolved": "https://registry.npmjs.org/extract-text-webpack-plugin/-/extract-text-webpack-plugin-1.0.1.tgz", - "dependencies": { - "async": { - "version": "1.5.2", - "from": "async@>=1.5.0 <2.0.0", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" - } - } - }, - "fancy-log": { - "version": "1.2.0", - "from": "fancy-log@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.2.0.tgz" - }, - "fast-levenshtein": { - "version": "2.0.5", - "from": "fast-levenshtein@>=2.0.4 <2.1.0", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.5.tgz" - }, - "fastparse": { - "version": "1.1.1", - "from": "fastparse@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.1.tgz" - }, - "faye-websocket": { - "version": "0.7.3", - "from": "faye-websocket@>=0.7.2 <0.8.0", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.7.3.tgz" - }, - "fbjs": { - "version": "0.8.8", - "from": "fbjs@>=0.8.4 <0.9.0", - "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.8.tgz", - "dependencies": { - "core-js": { - "version": "1.2.7", - "from": "core-js@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz" - } - } - }, - "figures": { - "version": "1.7.0", - "from": "figures@>=1.3.5 <2.0.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz" - }, - "file-entry-cache": { - "version": "1.3.1", - "from": "file-entry-cache@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-1.3.1.tgz" - }, - "file-loader": { - "version": "0.9.0", - "from": "file-loader@0.9.0", - "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-0.9.0.tgz" - }, - "filename-regex": { - "version": "2.0.0", - "from": "filename-regex@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.0.tgz" - }, - "filesize": { - "version": "3.5.4", - "from": "filesize@latest", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-3.5.4.tgz" - }, - "fill-range": { - "version": "2.2.3", - "from": "fill-range@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.3.tgz" - }, - "find-index": { - "version": "0.1.1", - "from": "find-index@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/find-index/-/find-index-0.1.1.tgz" - }, - "find-up": { - "version": "1.1.2", - "from": "find-up@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", - "dependencies": { - "path-exists": { - "version": "2.1.0", - "from": "path-exists@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz" - } - } - }, - "findup-sync": { - "version": "0.3.0", - "from": "findup-sync@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.3.0.tgz", - "dependencies": { - "glob": { - "version": "5.0.15", - "from": "glob@>=5.0.0 <5.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz" - } - } - }, - "first-chunk-stream": { - "version": "1.0.0", - "from": "first-chunk-stream@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz" - }, - "flagged-respawn": { - "version": "0.3.2", - "from": "flagged-respawn@>=0.3.2 <0.4.0", - "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-0.3.2.tgz" - }, - "flat-cache": { - "version": "1.2.1", - "from": "flat-cache@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.2.1.tgz" - }, - "flatten": { - "version": "1.0.2", - "from": "flatten@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/flatten/-/flatten-1.0.2.tgz" - }, - "fobject": { - "version": "0.0.4", - "from": "fobject@0.0.4", - "resolved": "https://registry.npmjs.org/fobject/-/fobject-0.0.4.tgz", - "dependencies": { - "semver": { - "version": "5.1.0", - "from": "semver@>=5.1.0 <6.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.1.0.tgz" - } - } - }, - "for-in": { - "version": "0.1.5", - "from": "for-in@>=0.1.5 <0.2.0", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.5.tgz" - }, - "for-own": { - "version": "0.1.4", - "from": "for-own@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.4.tgz" - }, - "from": { - "version": "0.1.3", - "from": "from@>=0.0.0 <1.0.0", - "resolved": "https://registry.npmjs.org/from/-/from-0.1.3.tgz" - }, - "fs-readfile-promise": { - "version": "2.0.1", - "from": "fs-readfile-promise@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/fs-readfile-promise/-/fs-readfile-promise-2.0.1.tgz" - }, - "fs.realpath": { - "version": "1.0.0", - "from": "fs.realpath@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" - }, - "fstream": { - "version": "1.0.9", - "from": "fstream@>=1.0.7 <2.0.0", - "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.9.tgz" - }, - "gather-stream": { - "version": "1.0.0", - "from": "gather-stream@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/gather-stream/-/gather-stream-1.0.0.tgz" - }, - "gaze": { - "version": "0.5.2", - "from": "gaze@>=0.5.1 <0.6.0", - "resolved": "https://registry.npmjs.org/gaze/-/gaze-0.5.2.tgz" - }, - "generate-function": { - "version": "2.0.0", - "from": "generate-function@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz" - }, - "generate-object-property": { - "version": "1.2.0", - "from": "generate-object-property@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz" - }, - "get-node-dimensions": { - "version": "1.2.0", - "from": "get-node-dimensions@>=1.2.0 <2.0.0", - "resolved": "https://registry.npmjs.org/get-node-dimensions/-/get-node-dimensions-1.2.0.tgz" - }, - "get-stdin": { - "version": "4.0.1", - "from": "get-stdin@>=4.0.1 <5.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz" - }, - "glob": { - "version": "6.0.4", - "from": "glob@>=6.0.1 <7.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz" - }, - "glob-base": { - "version": "0.3.0", - "from": "glob-base@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz" - }, - "glob-parent": { - "version": "2.0.0", - "from": "glob-parent@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz" - }, - "glob-stream": { - "version": "3.1.18", - "from": "glob-stream@>=3.1.5 <4.0.0", - "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-3.1.18.tgz", - "dependencies": { - "glob": { - "version": "4.5.3", - "from": "glob@>=4.3.1 <5.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-4.5.3.tgz" - }, - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "glob-watcher": { - "version": "0.0.6", - "from": "glob-watcher@>=0.0.6 <0.0.7", - "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-0.0.6.tgz" - }, - "glob2base": { - "version": "0.0.12", - "from": "glob2base@>=0.0.12 <0.0.13", - "resolved": "https://registry.npmjs.org/glob2base/-/glob2base-0.0.12.tgz" - }, - "globals": { - "version": "8.18.0", - "from": "globals@>=8.3.0 <9.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-8.18.0.tgz" - }, - "globby": { - "version": "4.1.0", - "from": "globby@>=4.0.0 <5.0.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-4.1.0.tgz" - }, - "globjoin": { - "version": "0.1.4", - "from": "globjoin@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz" - }, - "globule": { - "version": "0.1.0", - "from": "globule@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/globule/-/globule-0.1.0.tgz", - "dependencies": { - "glob": { - "version": "3.1.21", - "from": "glob@>=3.1.21 <3.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-3.1.21.tgz" - }, - "graceful-fs": { - "version": "1.2.3", - "from": "graceful-fs@>=1.2.0 <1.3.0", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-1.2.3.tgz" - }, - "inherits": { - "version": "1.0.2", - "from": "inherits@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-1.0.2.tgz" - }, - "lodash": { - "version": "1.0.2", - "from": "lodash@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-1.0.2.tgz" - }, - "minimatch": { - "version": "0.2.14", - "from": "minimatch@>=0.2.11 <0.3.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz" - } - } - }, - "glogg": { - "version": "1.0.0", - "from": "glogg@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/glogg/-/glogg-1.0.0.tgz" - }, - "graceful-fs": { - "version": "4.1.4", - "from": "graceful-fs@>=4.1.2 <5.0.0", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.4.tgz" - }, - "graceful-readlink": { - "version": "1.0.1", - "from": "graceful-readlink@>=1.0.0", - "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz" - }, - "growl": { - "version": "1.8.1", - "from": "growl@1.8.1", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.8.1.tgz" - }, - "gulp": { - "version": "3.9.1", - "from": "gulp@3.9.1", - "resolved": "https://registry.npmjs.org/gulp/-/gulp-3.9.1.tgz" - }, - "gulp-cached": { - "version": "1.1.0", - "from": "gulp-cached@1.1.0", - "resolved": "https://registry.npmjs.org/gulp-cached/-/gulp-cached-1.1.0.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.17 <1.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.5.1", - "from": "through2@>=0.5.1 <0.6.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.5.1.tgz" - }, - "xtend": { - "version": "3.0.0", - "from": "xtend@>=3.0.0 <3.1.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz" - } - } - }, - "gulp-concat": { - "version": "2.6.0", - "from": "gulp-concat@2.6.0", - "resolved": "https://registry.npmjs.org/gulp-concat/-/gulp-concat-2.6.0.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.3 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "gulp-declare": { - "version": "0.3.0", - "from": "gulp-declare@0.3.0", - "resolved": "https://registry.npmjs.org/gulp-declare/-/gulp-declare-0.3.0.tgz" - }, - "gulp-handlebars": { - "version": "3.0.1", - "from": "gulp-handlebars@3.0.1", - "resolved": "https://registry.npmjs.org/gulp-handlebars/-/gulp-handlebars-3.0.1.tgz", - "dependencies": { - "handlebars": { - "version": "2.0.0", - "from": "handlebars@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-2.0.0.tgz" - }, - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "optimist": { - "version": "0.3.7", - "from": "optimist@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - }, - "wordwrap": { - "version": "0.0.3", - "from": "wordwrap@>=0.0.2 <0.1.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz" - } - } - }, - "gulp-less": { - "version": "3.0.3", - "from": "gulp-less@3.0.3", - "resolved": "https://registry.npmjs.org/gulp-less/-/gulp-less-3.0.3.tgz", - "dependencies": { - "accord": { - "version": "0.15.2", - "from": "accord@>=0.15.2 <0.16.0", - "resolved": "https://registry.npmjs.org/accord/-/accord-0.15.2.tgz" - }, - "convert-source-map": { - "version": "0.4.1", - "from": "convert-source-map@>=0.4.1 <0.5.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-0.4.1.tgz" - }, - "glob": { - "version": "4.5.3", - "from": "glob@>=4.0.0 <5.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-4.5.3.tgz" - }, - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "lodash": { - "version": "3.10.1", - "from": "lodash@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz" - }, - "object-assign": { - "version": "2.1.1", - "from": "object-assign@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-2.1.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "source-map": { - "version": "0.1.43", - "from": "source-map@>=0.1.39 <0.2.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.3 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - }, - "vinyl-sourcemaps-apply": { - "version": "0.1.4", - "from": "vinyl-sourcemaps-apply@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.1.4.tgz" - } - } - }, - "gulp-livereload": { - "version": "3.8.1", - "from": "gulp-livereload@3.8.1", - "resolved": "https://registry.npmjs.org/gulp-livereload/-/gulp-livereload-3.8.1.tgz", - "dependencies": { - "ansi-regex": { - "version": "0.2.1", - "from": "ansi-regex@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz" - }, - "ansi-styles": { - "version": "1.1.0", - "from": "ansi-styles@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.1.0.tgz" - }, - "chalk": { - "version": "0.5.1", - "from": "chalk@>=0.5.1 <0.6.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.5.1.tgz" - }, - "has-ansi": { - "version": "0.1.0", - "from": "has-ansi@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-0.1.0.tgz" - }, - "lodash.assign": { - "version": "3.2.0", - "from": "lodash.assign@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-3.2.0.tgz" - }, - "lodash.keys": { - "version": "3.1.2", - "from": "lodash.keys@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz" - }, - "strip-ansi": { - "version": "0.3.0", - "from": "strip-ansi@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.3.0.tgz" - }, - "supports-color": { - "version": "0.2.0", - "from": "supports-color@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-0.2.0.tgz" - } - } - }, - "gulp-postcss": { - "version": "6.1.1", - "from": "gulp-postcss@6.1.1", - "resolved": "https://registry.npmjs.org/gulp-postcss/-/gulp-postcss-6.1.1.tgz" - }, - "gulp-print": { - "version": "2.0.1", - "from": "gulp-print@2.0.1", - "resolved": "https://registry.npmjs.org/gulp-print/-/gulp-print-2.0.1.tgz", - "dependencies": { - "map-stream": { - "version": "0.0.6", - "from": "map-stream@>=0.0.6 <0.1.0", - "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.6.tgz" - } - } - }, - "gulp-sourcemaps": { - "version": "1.6.0", - "from": "gulp-sourcemaps@1.6.0", - "resolved": "https://registry.npmjs.org/gulp-sourcemaps/-/gulp-sourcemaps-1.6.0.tgz", - "dependencies": { - "vinyl": { - "version": "1.1.1", - "from": "vinyl@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-1.1.1.tgz" - } - } - }, - "gulp-stripbom": { - "version": "1.0.4", - "from": "gulp-stripbom@1.0.4", - "resolved": "https://registry.npmjs.org/gulp-stripbom/-/gulp-stripbom-1.0.4.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.17 <1.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "strip-bom": { - "version": "1.0.0", - "from": "strip-bom@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-1.0.0.tgz" - }, - "through2": { - "version": "0.5.1", - "from": "through2@>=0.5.1 <0.6.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.5.1.tgz" - }, - "xtend": { - "version": "3.0.0", - "from": "xtend@>=3.0.0 <3.1.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz" - } - } - }, - "gulp-util": { - "version": "3.0.7", - "from": "gulp-util@3.0.7", - "resolved": "https://registry.npmjs.org/gulp-util/-/gulp-util-3.0.7.tgz", - "dependencies": { - "object-assign": { - "version": "3.0.0", - "from": "object-assign@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz" - } - } - }, - "gulp-watch": { - "version": "4.3.5", - "from": "gulp-watch@4.3.5", - "resolved": "https://registry.npmjs.org/gulp-watch/-/gulp-watch-4.3.5.tgz", - "dependencies": { - "glob": { - "version": "5.0.15", - "from": "glob@>=5.0.13 <6.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz" - } - } - }, - "gulp-wrap": { - "version": "0.13.0", - "from": "gulp-wrap@0.13.0", - "resolved": "https://registry.npmjs.org/gulp-wrap/-/gulp-wrap-0.13.0.tgz" - }, - "gulplog": { - "version": "1.0.0", - "from": "gulplog@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-1.0.0.tgz" - }, - "handlebars": { - "version": "3.0.3", - "from": "handlebars@3.0.3", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-3.0.3.tgz", - "dependencies": { - "source-map": { - "version": "0.1.43", - "from": "source-map@>=0.1.40 <0.2.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz" - } - } - }, - "has-ansi": { - "version": "2.0.0", - "from": "has-ansi@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz" - }, - "has-flag": { - "version": "1.0.0", - "from": "has-flag@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz" - }, - "has-gulplog": { - "version": "0.1.0", - "from": "has-gulplog@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/has-gulplog/-/has-gulplog-0.1.0.tgz" - }, - "has-own": { - "version": "1.0.0", - "from": "has-own@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/has-own/-/has-own-1.0.0.tgz" - }, - "history": { - "version": "3.2.1", - "from": "history@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/history/-/history-3.2.1.tgz" - }, - "hoist-non-react-statics": { - "version": "1.2.0", - "from": "hoist-non-react-statics@>=1.0.3 <2.0.0", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz" - }, - "home-or-tmp": { - "version": "1.0.0", - "from": "home-or-tmp@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-1.0.0.tgz" - }, - "hosted-git-info": { - "version": "2.1.5", - "from": "hosted-git-info@>=2.1.4 <3.0.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.1.5.tgz" - }, - "html-comment-regex": { - "version": "1.1.0", - "from": "html-comment-regex@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.0.tgz" - }, - "html-tags": { - "version": "1.1.1", - "from": "html-tags@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-1.1.1.tgz" - }, - "http-browserify": { - "version": "1.7.0", - "from": "http-browserify@>=1.3.2 <2.0.0", - "resolved": "https://registry.npmjs.org/http-browserify/-/http-browserify-1.7.0.tgz" - }, - "http-errors": { - "version": "1.3.1", - "from": "http-errors@>=1.3.1 <1.4.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz" - }, - "https-browserify": { - "version": "0.0.0", - "from": "https-browserify@0.0.0", - "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-0.0.0.tgz" - }, - "iconv-lite": { - "version": "0.4.13", - "from": "iconv-lite@0.4.13", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz" - }, - "icss-replace-symbols": { - "version": "1.0.2", - "from": "icss-replace-symbols@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.0.2.tgz" - }, - "ieee754": { - "version": "1.1.6", - "from": "ieee754@>=1.1.4 <2.0.0", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.6.tgz" - }, - "ignore": { - "version": "3.2.0", - "from": "ignore@>=3.1.2 <4.0.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.2.0.tgz" - }, - "image-size": { - "version": "0.5.0", - "from": "image-size@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.0.tgz", - "optional": true - }, - "imurmurhash": { - "version": "0.1.4", - "from": "imurmurhash@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" - }, - "indent-string": { - "version": "2.1.0", - "from": "indent-string@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", - "dependencies": { - "repeating": { - "version": "2.0.1", - "from": "repeating@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz" - } - } - }, - "indexes-of": { - "version": "1.0.1", - "from": "indexes-of@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz" - }, - "indexof": { - "version": "0.0.1", - "from": "indexof@0.0.1", - "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz" - }, - "indx": { - "version": "0.2.3", - "from": "indx@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/indx/-/indx-0.2.3.tgz" - }, - "inflight": { - "version": "1.0.5", - "from": "inflight@>=1.0.4 <2.0.0", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.5.tgz" - }, - "inherits": { - "version": "2.0.1", - "from": "inherits@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" - }, - "inquirer": { - "version": "0.12.0", - "from": "inquirer@>=0.12.0 <0.13.0", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz" - }, - "interpret": { - "version": "1.0.1", - "from": "interpret@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.0.1.tgz" - }, - "invariant": { - "version": "2.2.1", - "from": "invariant@>=2.2.0 <3.0.0", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.1.tgz" - }, - "irregular-plurals": { - "version": "1.2.0", - "from": "irregular-plurals@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-1.2.0.tgz" - }, - "is": { - "version": "3.1.0", - "from": "is@>=3.0.1 <4.0.0", - "resolved": "https://registry.npmjs.org/is/-/is-3.1.0.tgz" - }, - "is-absolute-url": { - "version": "2.0.0", - "from": "is-absolute-url@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-2.0.0.tgz" - }, - "is-arrayish": { - "version": "0.2.1", - "from": "is-arrayish@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" - }, - "is-binary-path": { - "version": "1.0.1", - "from": "is-binary-path@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz" - }, - "is-buffer": { - "version": "1.1.3", - "from": "is-buffer@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.3.tgz" - }, - "is-builtin-module": { - "version": "1.0.0", - "from": "is-builtin-module@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz" - }, - "is-dotfile": { - "version": "1.0.2", - "from": "is-dotfile@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.2.tgz" - }, - "is-equal-shallow": { - "version": "0.1.3", - "from": "is-equal-shallow@>=0.1.3 <0.2.0", - "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz" - }, - "is-extendable": { - "version": "0.1.1", - "from": "is-extendable@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz" - }, - "is-extglob": { - "version": "1.0.0", - "from": "is-extglob@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz" - }, - "is-finite": { - "version": "1.0.1", - "from": "is-finite@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.1.tgz" - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "from": "is-fullwidth-code-point@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz" - }, - "is-glob": { - "version": "2.0.1", - "from": "is-glob@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz" - }, - "is-my-json-valid": { - "version": "2.15.0", - "from": "is-my-json-valid@>=2.10.0 <3.0.0", - "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.15.0.tgz" - }, - "is-number": { - "version": "2.1.0", - "from": "is-number@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz" - }, - "is-path-cwd": { - "version": "1.0.0", - "from": "is-path-cwd@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz" - }, - "is-path-in-cwd": { - "version": "1.0.0", - "from": "is-path-in-cwd@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.0.tgz" - }, - "is-path-inside": { - "version": "1.0.0", - "from": "is-path-inside@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.0.tgz" - }, - "is-plain-obj": { - "version": "1.1.0", - "from": "is-plain-obj@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz" - }, - "is-posix-bracket": { - "version": "0.1.1", - "from": "is-posix-bracket@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz" - }, - "is-primitive": { - "version": "2.0.0", - "from": "is-primitive@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz" - }, - "is-property": { - "version": "1.0.2", - "from": "is-property@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz" - }, - "is-regexp": { - "version": "1.0.0", - "from": "is-regexp@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz" - }, - "is-resolvable": { - "version": "1.0.0", - "from": "is-resolvable@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.0.0.tgz" - }, - "is-stream": { - "version": "1.1.0", - "from": "is-stream@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz" - }, - "is-supported-regexp-flag": { - "version": "1.0.0", - "from": "is-supported-regexp-flag@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/is-supported-regexp-flag/-/is-supported-regexp-flag-1.0.0.tgz" - }, - "is-svg": { - "version": "2.0.1", - "from": "is-svg@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-2.0.1.tgz" - }, - "is-utf8": { - "version": "0.2.1", - "from": "is-utf8@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz" - }, - "isarray": { - "version": "1.0.0", - "from": "isarray@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" - }, - "isexe": { - "version": "1.1.2", - "from": "isexe@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-1.1.2.tgz" - }, - "isobject": { - "version": "2.1.0", - "from": "isobject@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz" - }, - "isomorphic-fetch": { - "version": "2.2.1", - "from": "isomorphic-fetch@>=2.1.1 <3.0.0", - "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz" - }, - "isstream": { - "version": "0.1.2", - "from": "isstream@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz" - }, - "jade": { - "version": "0.26.3", - "from": "jade@0.26.3", - "resolved": "https://registry.npmjs.org/jade/-/jade-0.26.3.tgz", - "dependencies": { - "commander": { - "version": "0.6.1", - "from": "commander@0.6.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-0.6.1.tgz" - }, - "mkdirp": { - "version": "0.3.0", - "from": "mkdirp@0.3.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz" - } - } - }, - "js-base64": { - "version": "2.1.9", - "from": "js-base64@>=2.1.9 <3.0.0", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.1.9.tgz" - }, - "js-stylesheet": { - "version": "0.0.1", - "from": "js-stylesheet@>=0.0.1 <0.0.2", - "resolved": "https://registry.npmjs.org/js-stylesheet/-/js-stylesheet-0.0.1.tgz" - }, - "js-tokens": { - "version": "1.0.3", - "from": "js-tokens@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-1.0.3.tgz" - }, - "js-yaml": { - "version": "3.6.1", - "from": "js-yaml@>=3.6.1 <3.7.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.6.1.tgz" - }, - "jsesc": { - "version": "0.5.0", - "from": "jsesc@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz" - }, - "json-stable-stringify": { - "version": "1.0.1", - "from": "json-stable-stringify@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz" - }, - "json-stringify-safe": { - "version": "5.0.1", - "from": "json-stringify-safe@>=5.0.1 <6.0.0", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz" - }, - "json5": { - "version": "0.4.0", - "from": "json5@>=0.4.0 <0.5.0", - "resolved": "https://registry.npmjs.org/json5/-/json5-0.4.0.tgz" - }, - "jsonfilter": { - "version": "1.1.2", - "from": "jsonfilter@>=1.1.2 <2.0.0", - "resolved": "https://registry.npmjs.org/jsonfilter/-/jsonfilter-1.1.2.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "stream-combiner": { - "version": "0.2.2", - "from": "stream-combiner@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.3 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "jsonify": { - "version": "0.0.0", - "from": "jsonify@>=0.0.0 <0.1.0", - "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz" - }, - "jsonparse": { - "version": "0.0.5", - "from": "jsonparse@0.0.5", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-0.0.5.tgz" - }, - "jsonpointer": { - "version": "4.0.0", - "from": "jsonpointer@>=4.0.0 <5.0.0", - "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.0.tgz" - }, - "JSONStream": { - "version": "0.8.4", - "from": "JSONStream@>=0.8.4 <0.9.0", - "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-0.8.4.tgz" - }, - "jsx-ast-utils": { - "version": "1.3.1", - "from": "jsx-ast-utils@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-1.3.1.tgz" - }, - "kind-of": { - "version": "3.0.3", - "from": "kind-of@>=3.0.2 <4.0.0", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.0.3.tgz" - }, - "known-css-properties": { - "version": "0.0.4", - "from": "known-css-properties@>=0.0.4 <0.0.5", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.0.4.tgz" - }, - "lazy-cache": { - "version": "1.0.4", - "from": "lazy-cache@>=1.0.3 <2.0.0", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz" - }, - "ldjson-stream": { - "version": "1.2.1", - "from": "ldjson-stream@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/ldjson-stream/-/ldjson-stream-1.2.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "less": { - "version": "2.7.1", - "from": "less@>=2.6.0 <3.0.0", - "resolved": "https://registry.npmjs.org/less/-/less-2.7.1.tgz" - }, - "levn": { - "version": "0.3.0", - "from": "levn@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz" - }, - "liftoff": { - "version": "2.2.1", - "from": "liftoff@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-2.2.1.tgz" - }, - "livereload-js": { - "version": "2.2.2", - "from": "livereload-js@>=2.2.0 <3.0.0", - "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-2.2.2.tgz" - }, - "load-json-file": { - "version": "1.1.0", - "from": "load-json-file@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz" - }, - "loader-utils": { - "version": "0.2.15", - "from": "loader-utils@>=0.2.11 <0.3.0", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-0.2.15.tgz", - "dependencies": { - "json5": { - "version": "0.5.0", - "from": "json5@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.0.tgz" - } - } - }, - "lodash": { - "version": "4.17.4", - "from": "lodash@4.17.4", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz" - }, - "lodash-es": { - "version": "4.13.1", - "from": "lodash-es@>=4.2.1 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.13.1.tgz" - }, - "lodash._arraycopy": { - "version": "3.0.0", - "from": "lodash._arraycopy@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._arraycopy/-/lodash._arraycopy-3.0.0.tgz" - }, - "lodash._arrayeach": { - "version": "3.0.0", - "from": "lodash._arrayeach@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._arrayeach/-/lodash._arrayeach-3.0.0.tgz" - }, - "lodash._baseassign": { - "version": "3.2.0", - "from": "lodash._baseassign@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", - "dependencies": { - "lodash.keys": { - "version": "3.1.2", - "from": "lodash.keys@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz" - } - } - }, - "lodash._baseclone": { - "version": "3.3.0", - "from": "lodash._baseclone@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._baseclone/-/lodash._baseclone-3.3.0.tgz", - "dependencies": { - "lodash.keys": { - "version": "3.1.2", - "from": "lodash.keys@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz" - } - } - }, - "lodash._basecopy": { - "version": "3.0.1", - "from": "lodash._basecopy@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz" - }, - "lodash._basefor": { - "version": "3.0.3", - "from": "lodash._basefor@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._basefor/-/lodash._basefor-3.0.3.tgz" - }, - "lodash._basevalues": { - "version": "3.0.0", - "from": "lodash._basevalues@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._basevalues/-/lodash._basevalues-3.0.0.tgz" - }, - "lodash._bindcallback": { - "version": "3.0.1", - "from": "lodash._bindcallback@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz" - }, - "lodash._createassigner": { - "version": "3.1.1", - "from": "lodash._createassigner@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz" - }, - "lodash._createcompounder": { - "version": "3.0.0", - "from": "lodash._createcompounder@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._createcompounder/-/lodash._createcompounder-3.0.0.tgz" - }, - "lodash._getnative": { - "version": "3.9.1", - "from": "lodash._getnative@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz" - }, - "lodash._isiterateecall": { - "version": "3.0.9", - "from": "lodash._isiterateecall@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz" - }, - "lodash._isnative": { - "version": "2.4.1", - "from": "lodash._isnative@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash._isnative/-/lodash._isnative-2.4.1.tgz" - }, - "lodash._objecttypes": { - "version": "2.4.1", - "from": "lodash._objecttypes@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash._objecttypes/-/lodash._objecttypes-2.4.1.tgz" - }, - "lodash._reescape": { - "version": "3.0.0", - "from": "lodash._reescape@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._reescape/-/lodash._reescape-3.0.0.tgz" - }, - "lodash._reevaluate": { - "version": "3.0.0", - "from": "lodash._reevaluate@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._reevaluate/-/lodash._reevaluate-3.0.0.tgz" - }, - "lodash._reinterpolate": { - "version": "3.0.0", - "from": "lodash._reinterpolate@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz" - }, - "lodash._root": { - "version": "3.0.1", - "from": "lodash._root@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz" - }, - "lodash._shimkeys": { - "version": "2.4.1", - "from": "lodash._shimkeys@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash._shimkeys/-/lodash._shimkeys-2.4.1.tgz" - }, - "lodash.camelcase": { - "version": "3.0.1", - "from": "lodash.camelcase@>=3.0.1 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-3.0.1.tgz" - }, - "lodash.clone": { - "version": "3.0.3", - "from": "lodash.clone@>=3.0.0 <3.1.0-0", - "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-3.0.3.tgz" - }, - "lodash.deburr": { - "version": "3.2.0", - "from": "lodash.deburr@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.deburr/-/lodash.deburr-3.2.0.tgz" - }, - "lodash.defaults": { - "version": "2.4.1", - "from": "lodash.defaults@>=2.4.1 <3.0.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-2.4.1.tgz", - "dependencies": { - "lodash.keys": { - "version": "2.4.1", - "from": "lodash.keys@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-2.4.1.tgz" - } - } - }, - "lodash.escape": { - "version": "3.2.0", - "from": "lodash.escape@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-3.2.0.tgz" - }, - "lodash.isarguments": { - "version": "3.0.8", - "from": "lodash.isarguments@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.0.8.tgz" - }, - "lodash.isarray": { - "version": "3.0.4", - "from": "lodash.isarray@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz" - }, - "lodash.isobject": { - "version": "2.4.1", - "from": "lodash.isobject@>=2.4.1 <2.5.0", - "resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-2.4.1.tgz" - }, - "lodash.keys": { - "version": "3.1.2", - "from": "lodash.keys@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz" - }, - "lodash.pickby": { - "version": "4.6.0", - "from": "lodash.pickby@>=4.6.0 <5.0.0", - "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz" - }, - "lodash.restparam": { - "version": "3.6.1", - "from": "lodash.restparam@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.restparam/-/lodash.restparam-3.6.1.tgz" - }, - "lodash.template": { - "version": "3.6.2", - "from": "lodash.template@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-3.6.2.tgz", - "dependencies": { - "lodash._basetostring": { - "version": "3.0.1", - "from": "lodash._basetostring@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-3.0.1.tgz" - }, - "lodash.keys": { - "version": "3.1.2", - "from": "lodash.keys@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz" - } - } - }, - "lodash.templatesettings": { - "version": "3.1.1", - "from": "lodash.templatesettings@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-3.1.1.tgz" - }, - "lodash.words": { - "version": "3.2.0", - "from": "lodash.words@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/lodash.words/-/lodash.words-3.2.0.tgz" - }, - "log-symbols": { - "version": "1.0.2", - "from": "log-symbols@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz" - }, - "longest": { - "version": "1.0.1", - "from": "longest@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz" - }, - "loose-envify": { - "version": "1.2.0", - "from": "loose-envify@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.2.0.tgz" - }, - "loud-rejection": { - "version": "1.3.0", - "from": "loud-rejection@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.3.0.tgz" - }, - "lru-cache": { - "version": "2.7.3", - "from": "lru-cache@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz" - }, - "map-obj": { - "version": "1.0.1", - "from": "map-obj@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz" - }, - "map-stream": { - "version": "0.1.0", - "from": "map-stream@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz" - }, - "media-typer": { - "version": "0.3.0", - "from": "media-typer@0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" - }, - "memory-fs": { - "version": "0.3.0", - "from": "memory-fs@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.3.0.tgz" - }, - "meow": { - "version": "3.7.0", - "from": "meow@>=3.3.0 <4.0.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz" - }, - "micromatch": { - "version": "2.3.8", - "from": "micromatch@>=2.1.5 <3.0.0", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.8.tgz" - }, - "mime": { - "version": "1.3.4", - "from": "mime@>=1.2.11 <2.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz", - "optional": true - }, - "mime-db": { - "version": "1.23.0", - "from": "mime-db@>=1.23.0 <1.24.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.23.0.tgz" - }, - "mime-types": { - "version": "2.1.11", - "from": "mime-types@>=2.1.11 <2.2.0", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.11.tgz" - }, - "mini-lr": { - "version": "0.1.9", - "from": "mini-lr@>=0.1.8 <0.2.0", - "resolved": "https://registry.npmjs.org/mini-lr/-/mini-lr-0.1.9.tgz" - }, - "minimatch": { - "version": "2.0.10", - "from": "minimatch@>=2.0.3 <3.0.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-2.0.10.tgz" - }, - "minimist": { - "version": "1.2.0", - "from": "minimist@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz" - }, - "mkdirp": { - "version": "0.5.1", - "from": "mkdirp@>=0.5.1 <0.6.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "dependencies": { - "minimist": { - "version": "0.0.8", - "from": "minimist@0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" - } - } - }, - "mocha": { - "version": "2.4.5", - "from": "mocha@>=2.2.5 <3.0.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-2.4.5.tgz", - "dependencies": { - "commander": { - "version": "2.3.0", - "from": "commander@2.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.3.0.tgz" - }, - "escape-string-regexp": { - "version": "1.0.2", - "from": "escape-string-regexp@1.0.2", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.2.tgz" - }, - "glob": { - "version": "3.2.3", - "from": "glob@3.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-3.2.3.tgz" - }, - "graceful-fs": { - "version": "2.0.3", - "from": "graceful-fs@>=2.0.0 <2.1.0", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-2.0.3.tgz" - }, - "minimatch": { - "version": "0.2.14", - "from": "minimatch@>=0.2.11 <0.3.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz" - }, - "supports-color": { - "version": "1.2.0", - "from": "supports-color@1.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-1.2.0.tgz" - } - } - }, - "moment": { - "version": "2.17.1", - "from": "moment@latest", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.17.1.tgz" - }, - "mout": { - "version": "1.0.0", - "from": "mout@>=0.9.0 <2.0.0", - "resolved": "https://registry.npmjs.org/mout/-/mout-1.0.0.tgz" - }, - "ms": { - "version": "0.7.1", - "from": "ms@0.7.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" - }, - "multimatch": { - "version": "2.1.0", - "from": "multimatch@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-2.1.0.tgz", - "dependencies": { - "minimatch": { - "version": "3.0.3", - "from": "minimatch@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz" - } - } - }, - "multipipe": { - "version": "0.1.2", - "from": "multipipe@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/multipipe/-/multipipe-0.1.2.tgz" - }, - "mute-stream": { - "version": "0.0.5", - "from": "mute-stream@0.0.5", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz" - }, - "new-from": { - "version": "0.0.3", - "from": "new-from@0.0.3", - "resolved": "https://registry.npmjs.org/new-from/-/new-from-0.0.3.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.1.8 <1.2.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "node-fetch": { - "version": "1.6.3", - "from": "node-fetch@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.6.3.tgz" - }, - "node-libs-browser": { - "version": "0.5.3", - "from": "node-libs-browser@>=0.4.0 <=0.6.0", - "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-0.5.3.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.1.13 <2.0.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "node.extend": { - "version": "1.1.5", - "from": "node.extend@>=1.1.2 <2.0.0", - "resolved": "https://registry.npmjs.org/node.extend/-/node.extend-1.1.5.tgz" - }, - "normalize-package-data": { - "version": "2.3.5", - "from": "normalize-package-data@>=2.3.4 <3.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.3.5.tgz" - }, - "normalize-path": { - "version": "2.0.1", - "from": "normalize-path@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.0.1.tgz" - }, - "normalize-range": { - "version": "0.1.2", - "from": "normalize-range@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz" - }, - "normalize-selector": { - "version": "0.2.0", - "from": "normalize-selector@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/normalize-selector/-/normalize-selector-0.2.0.tgz" - }, - "normalize-url": { - "version": "1.5.3", - "from": "normalize-url@>=1.4.0 <2.0.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-1.5.3.tgz" - }, - "normalize.css": { - "version": "5.0.0", - "from": "normalize.css@5.0.0", - "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-5.0.0.tgz" - }, - "npm-path": { - "version": "1.1.0", - "from": "npm-path@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/npm-path/-/npm-path-1.1.0.tgz" - }, - "npm-run": { - "version": "2.0.0", - "from": "npm-run@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/npm-run/-/npm-run-2.0.0.tgz" - }, - "npm-which": { - "version": "2.0.0", - "from": "npm-which@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/npm-which/-/npm-which-2.0.0.tgz" - }, - "nsdeclare": { - "version": "0.1.0", - "from": "nsdeclare@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/nsdeclare/-/nsdeclare-0.1.0.tgz" - }, - "num2fraction": { - "version": "1.2.2", - "from": "num2fraction@>=1.2.2 <2.0.0", - "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz" - }, - "number-is-nan": { - "version": "1.0.0", - "from": "number-is-nan@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.0.tgz" - }, - "object-assign": { - "version": "4.1.0", - "from": "object-assign@>=4.0.1 <5.0.0", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz" - }, - "object-keys": { - "version": "0.4.0", - "from": "object-keys@>=0.4.0 <0.5.0", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz" - }, - "object.omit": { - "version": "2.0.0", - "from": "object.omit@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.0.tgz" - }, - "on-finished": { - "version": "2.3.0", - "from": "on-finished@>=2.3.0 <2.4.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz" - }, - "once": { - "version": "1.3.3", - "from": "once@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz" - }, - "onecolor": { - "version": "3.0.4", - "from": "onecolor@>=3.0.4 <4.0.0", - "resolved": "https://registry.npmjs.org/onecolor/-/onecolor-3.0.4.tgz" - }, - "onetime": { - "version": "1.1.0", - "from": "onetime@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz" - }, - "optimist": { - "version": "0.6.1", - "from": "optimist@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", - "dependencies": { - "minimist": { - "version": "0.0.10", - "from": "minimist@>=0.0.1 <0.1.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz" - }, - "wordwrap": { - "version": "0.0.3", - "from": "wordwrap@>=0.0.2 <0.1.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz" - } - } - }, - "optionator": { - "version": "0.8.2", - "from": "optionator@>=0.8.1 <0.9.0", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz" - }, - "orchestrator": { - "version": "0.3.7", - "from": "orchestrator@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/orchestrator/-/orchestrator-0.3.7.tgz" - }, - "ordered-read-streams": { - "version": "0.1.0", - "from": "ordered-read-streams@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-0.1.0.tgz" - }, - "os-browserify": { - "version": "0.1.2", - "from": "os-browserify@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.1.2.tgz" - }, - "os-homedir": { - "version": "1.0.1", - "from": "os-homedir@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.1.tgz" - }, - "os-shim": { - "version": "0.1.3", - "from": "os-shim@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/os-shim/-/os-shim-0.1.3.tgz" - }, - "os-tmpdir": { - "version": "1.0.1", - "from": "os-tmpdir@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.1.tgz" - }, - "pako": { - "version": "0.2.8", - "from": "pako@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.8.tgz" - }, - "parse-glob": { - "version": "3.0.4", - "from": "parse-glob@>=3.0.4 <4.0.0", - "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz" - }, - "parse-json": { - "version": "2.2.0", - "from": "parse-json@>=2.2.0 <3.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz" - }, - "parseurl": { - "version": "1.3.1", - "from": "parseurl@>=1.3.0 <1.4.0", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz" - }, - "path-browserify": { - "version": "0.0.0", - "from": "path-browserify@0.0.0", - "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.0.tgz" - }, - "path-exists": { - "version": "1.0.0", - "from": "path-exists@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-1.0.0.tgz" - }, - "path-is-absolute": { - "version": "1.0.0", - "from": "path-is-absolute@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.0.tgz" - }, - "path-is-inside": { - "version": "1.0.1", - "from": "path-is-inside@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.1.tgz" - }, - "path-type": { - "version": "1.1.0", - "from": "path-type@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz" - }, - "pause-stream": { - "version": "0.0.11", - "from": "pause-stream@0.0.11", - "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz" - }, - "pbkdf2-compat": { - "version": "2.0.1", - "from": "pbkdf2-compat@2.0.1", - "resolved": "https://registry.npmjs.org/pbkdf2-compat/-/pbkdf2-compat-2.0.1.tgz" - }, - "pify": { - "version": "2.3.0", - "from": "pify@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz" - }, - "pinkie": { - "version": "2.0.4", - "from": "pinkie@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz" - }, - "pinkie-promise": { - "version": "2.0.1", - "from": "pinkie-promise@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz" - }, - "pipetteur": { - "version": "2.0.3", - "from": "pipetteur@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/pipetteur/-/pipetteur-2.0.3.tgz" - }, - "plur": { - "version": "2.1.2", - "from": "plur@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/plur/-/plur-2.1.2.tgz" - }, - "pluralize": { - "version": "1.2.1", - "from": "pluralize@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-1.2.1.tgz" - }, - "postcss": { - "version": "5.0.21", - "from": "postcss@>=5.0.19 <6.0.0", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.0.21.tgz" - }, - "postcss-calc": { - "version": "5.2.1", - "from": "postcss-calc@>=5.2.0 <6.0.0", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-5.2.1.tgz" - }, - "postcss-colormin": { - "version": "2.2.0", - "from": "postcss-colormin@>=2.1.8 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-2.2.0.tgz" - }, - "postcss-convert-values": { - "version": "2.4.0", - "from": "postcss-convert-values@>=2.3.4 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-2.4.0.tgz" - }, - "postcss-discard-comments": { - "version": "2.0.4", - "from": "postcss-discard-comments@>=2.0.4 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-2.0.4.tgz" - }, - "postcss-discard-duplicates": { - "version": "2.0.1", - "from": "postcss-discard-duplicates@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-2.0.1.tgz" - }, - "postcss-discard-empty": { - "version": "2.1.0", - "from": "postcss-discard-empty@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-2.1.0.tgz" - }, - "postcss-discard-overridden": { - "version": "0.1.1", - "from": "postcss-discard-overridden@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-0.1.1.tgz" - }, - "postcss-discard-unused": { - "version": "2.2.1", - "from": "postcss-discard-unused@>=2.2.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-2.2.1.tgz" - }, - "postcss-filter-plugins": { - "version": "2.0.0", - "from": "postcss-filter-plugins@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-filter-plugins/-/postcss-filter-plugins-2.0.0.tgz" - }, - "postcss-less": { - "version": "0.14.0", - "from": "postcss-less@>=0.14.0 <0.15.0", - "resolved": "https://registry.npmjs.org/postcss-less/-/postcss-less-0.14.0.tgz" - }, - "postcss-loader": { - "version": "0.9.1", - "from": "postcss-loader@0.9.1", - "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-0.9.1.tgz" - }, - "postcss-media-query-parser": { - "version": "0.2.1", - "from": "postcss-media-query-parser@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.1.tgz" - }, - "postcss-merge-idents": { - "version": "2.1.6", - "from": "postcss-merge-idents@>=2.1.5 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-2.1.6.tgz" - }, - "postcss-merge-longhand": { - "version": "2.0.1", - "from": "postcss-merge-longhand@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-2.0.1.tgz" - }, - "postcss-merge-rules": { - "version": "2.0.9", - "from": "postcss-merge-rules@>=2.0.3 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-2.0.9.tgz" - }, - "postcss-message-helpers": { - "version": "2.0.0", - "from": "postcss-message-helpers@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz" - }, - "postcss-minify-font-values": { - "version": "1.0.5", - "from": "postcss-minify-font-values@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-1.0.5.tgz" - }, - "postcss-minify-gradients": { - "version": "1.0.3", - "from": "postcss-minify-gradients@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-1.0.3.tgz" - }, - "postcss-minify-params": { - "version": "1.0.4", - "from": "postcss-minify-params@>=1.0.4 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-1.0.4.tgz" - }, - "postcss-minify-selectors": { - "version": "2.0.5", - "from": "postcss-minify-selectors@>=2.0.4 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-2.0.5.tgz" - }, - "postcss-modules-extract-imports": { - "version": "1.0.1", - "from": "postcss-modules-extract-imports@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.0.1.tgz" - }, - "postcss-modules-local-by-default": { - "version": "1.1.0", - "from": "postcss-modules-local-by-default@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.1.0.tgz" - }, - "postcss-modules-scope": { - "version": "1.0.1", - "from": "postcss-modules-scope@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-1.0.1.tgz" - }, - "postcss-modules-values": { - "version": "1.1.3", - "from": "postcss-modules-values@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-1.1.3.tgz" - }, - "postcss-nested": { - "version": "1.0.0", - "from": "postcss-nested@1.0.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-1.0.0.tgz" - }, - "postcss-normalize-charset": { - "version": "1.1.0", - "from": "postcss-normalize-charset@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-1.1.0.tgz" - }, - "postcss-normalize-url": { - "version": "3.0.7", - "from": "postcss-normalize-url@>=3.0.7 <4.0.0", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-3.0.7.tgz" - }, - "postcss-ordered-values": { - "version": "2.2.1", - "from": "postcss-ordered-values@>=2.1.0 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-2.2.1.tgz" - }, - "postcss-reduce-idents": { - "version": "2.3.0", - "from": "postcss-reduce-idents@>=2.2.2 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-2.3.0.tgz" - }, - "postcss-reduce-initial": { - "version": "1.0.0", - "from": "postcss-reduce-initial@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-1.0.0.tgz" - }, - "postcss-reduce-transforms": { - "version": "1.0.3", - "from": "postcss-reduce-transforms@>=1.0.3 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-1.0.3.tgz" - }, - "postcss-reporter": { - "version": "1.4.1", - "from": "postcss-reporter@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/postcss-reporter/-/postcss-reporter-1.4.1.tgz" - }, - "postcss-resolve-nested-selector": { - "version": "0.1.1", - "from": "postcss-resolve-nested-selector@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz" - }, - "postcss-scss": { - "version": "0.3.0", - "from": "postcss-scss@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-0.3.0.tgz", - "dependencies": { - "postcss": { - "version": "5.2.0", - "from": "postcss@>=5.2.0 <6.0.0", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.0.tgz" - } - } - }, - "postcss-selector-parser": { - "version": "2.1.0", - "from": "postcss-selector-parser@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-2.1.0.tgz" - }, - "postcss-simple-vars": { - "version": "3.0.0", - "from": "postcss-simple-vars@3.0.0", - "resolved": "https://registry.npmjs.org/postcss-simple-vars/-/postcss-simple-vars-3.0.0.tgz" - }, - "postcss-svgo": { - "version": "2.1.3", - "from": "postcss-svgo@>=2.1.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-2.1.3.tgz" - }, - "postcss-unique-selectors": { - "version": "2.0.2", - "from": "postcss-unique-selectors@>=2.0.2 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-2.0.2.tgz" - }, - "postcss-value-parser": { - "version": "3.3.0", - "from": "postcss-value-parser@>=3.2.3 <4.0.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz" - }, - "postcss-zindex": { - "version": "2.1.1", - "from": "postcss-zindex@>=2.0.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-2.1.1.tgz" - }, - "prelude-ls": { - "version": "1.1.2", - "from": "prelude-ls@>=1.1.2 <1.2.0", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz" - }, - "prepend-http": { - "version": "1.0.4", - "from": "prepend-http@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz" - }, - "preserve": { - "version": "0.2.0", - "from": "preserve@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz" - }, - "pretty-hrtime": { - "version": "1.0.2", - "from": "pretty-hrtime@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.2.tgz" - }, - "private": { - "version": "0.1.6", - "from": "private@>=0.1.6 <0.2.0", - "resolved": "https://registry.npmjs.org/private/-/private-0.1.6.tgz" - }, - "process": { - "version": "0.11.3", - "from": "process@>=0.11.0 <0.12.0", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.3.tgz" - }, - "process-nextick-args": { - "version": "1.0.7", - "from": "process-nextick-args@>=1.0.6 <1.1.0", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz" - }, - "progress": { - "version": "1.1.8", - "from": "progress@>=1.1.8 <2.0.0", - "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz" - }, - "promise": { - "version": "7.1.1", - "from": "promise@>=7.1.1 <8.0.0", - "resolved": "https://registry.npmjs.org/promise/-/promise-7.1.1.tgz" - }, - "protochain": { - "version": "1.0.3", - "from": "protochain@>=1.0.3 <2.0.0", - "resolved": "https://registry.npmjs.org/protochain/-/protochain-1.0.3.tgz" - }, - "prr": { - "version": "0.0.0", - "from": "prr@>=0.0.0 <0.1.0", - "resolved": "https://registry.npmjs.org/prr/-/prr-0.0.0.tgz" - }, - "punycode": { - "version": "1.4.1", - "from": "punycode@>=1.4.1 <2.0.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz" - }, - "q": { - "version": "1.4.1", - "from": "q@>=1.1.2 <2.0.0", - "resolved": "https://registry.npmjs.org/q/-/q-1.4.1.tgz" - }, - "qs": { - "version": "2.2.5", - "from": "qs@>=2.2.3 <2.3.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-2.2.5.tgz" - }, - "query-string": { - "version": "4.2.2", - "from": "query-string@>=4.1.0 <5.0.0", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.2.2.tgz" - }, - "querystring": { - "version": "0.2.0", - "from": "querystring@0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz" - }, - "querystring-es3": { - "version": "0.2.1", - "from": "querystring-es3@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz" - }, - "randomatic": { - "version": "1.1.5", - "from": "randomatic@>=1.1.3 <2.0.0", - "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.5.tgz" - }, - "raven-js": { - "version": "3.9.2", - "from": "raven-js@>=3.1.1 <4.0.0", - "resolved": "https://registry.npmjs.org/raven-js/-/raven-js-3.9.2.tgz" - }, - "raw-body": { - "version": "2.1.6", - "from": "raw-body@>=2.1.5 <2.2.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.1.6.tgz", - "dependencies": { - "bytes": { - "version": "2.3.0", - "from": "bytes@2.3.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.3.0.tgz" - } - } - }, - "react": { - "version": "15.4.2", - "from": "react@15.4.2", - "resolved": "https://registry.npmjs.org/react/-/react-15.4.2.tgz" - }, - "react-addons-shallow-compare": { - "version": "15.4.2", - "from": "react-addons-shallow-compare@15.4.2", - "resolved": "https://registry.npmjs.org/react-addons-shallow-compare/-/react-addons-shallow-compare-15.4.2.tgz" - }, - "react-async-script": { - "version": "0.5.1", - "from": "react-async-script@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-0.5.1.tgz", - "dependencies": { - "babel-runtime": { - "version": "5.8.38", - "from": "babel-runtime@>=5.8.0 <6.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-5.8.38.tgz" - }, - "core-js": { - "version": "1.2.7", - "from": "core-js@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz" - } - } - }, - "react-autosuggest": { - "version": "8.0.0", - "from": "react-autosuggest@8.0.0", - "resolved": "https://registry.npmjs.org/react-autosuggest/-/react-autosuggest-8.0.0.tgz" - }, - "react-autowhatever": { - "version": "7.0.0", - "from": "react-autowhatever@>=7.0.0 <8.0.0", - "resolved": "https://registry.npmjs.org/react-autowhatever/-/react-autowhatever-7.0.0.tgz" - }, - "react-dnd": { - "version": "2.1.4", - "from": "react-dnd@2.1.4", - "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-2.1.4.tgz" - }, - "react-dnd-html5-backend": { - "version": "2.1.2", - "from": "react-dnd-html5-backend@2.1.2", - "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-2.1.2.tgz" - }, - "react-document-title": { - "version": "2.0.2", - "from": "react-document-title@2.0.2", - "resolved": "https://registry.npmjs.org/react-document-title/-/react-document-title-2.0.2.tgz" - }, - "react-dom": { - "version": "15.4.2", - "from": "react-dom@15.4.2", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-15.4.2.tgz" - }, - "react-google-recaptcha": { - "version": "0.5.4", - "from": "react-google-recaptcha@0.5.4", - "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-0.5.4.tgz", - "dependencies": { - "babel-runtime": { - "version": "5.8.38", - "from": "babel-runtime@>=5.8.0 <6.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-5.8.38.tgz" - }, - "core-js": { - "version": "1.2.7", - "from": "core-js@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz" - } - } - }, - "react-lazyload": { - "version": "2.2.0", - "from": "react-lazyload@2.2.0", - "resolved": "https://registry.npmjs.org/react-lazyload/-/react-lazyload-2.2.0.tgz" - }, - "react-measure": { - "version": "1.4.5", - "from": "react-measure@1.4.5", - "resolved": "https://registry.npmjs.org/react-measure/-/react-measure-1.4.5.tgz" - }, - "react-portal": { - "version": "3.0.0", - "from": "react-portal@3.0.0", - "resolved": "https://registry.npmjs.org/react-portal/-/react-portal-3.0.0.tgz" - }, - "react-redux": { - "version": "5.0.2", - "from": "react-redux@5.0.2", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.0.2.tgz" - }, - "react-router": { - "version": "3.0.2", - "from": "react-router@3.0.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-3.0.2.tgz" - }, - "react-router-redux": { - "version": "4.0.7", - "from": "react-router-redux@4.0.7", - "resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-4.0.7.tgz" - }, - "react-side-effect": { - "version": "1.1.0", - "from": "react-side-effect@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.1.0.tgz" - }, - "react-slider": { - "version": "0.7.0", - "from": "react-slider@0.7.0", - "resolved": "https://registry.npmjs.org/react-slider/-/react-slider-0.7.0.tgz" - }, - "react-tabs": { - "version": "0.8.2", - "from": "react-tabs@0.8.2", - "resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-0.8.2.tgz" - }, - "react-tag-autocomplete": { - "version": "5.1.0", - "from": "react-tag-autocomplete@5.1.0", - "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-5.1.0.tgz" - }, - "react-tether": { - "version": "0.5.5", - "from": "react-tether@0.5.5", - "resolved": "https://registry.npmjs.org/react-tether/-/react-tether-0.5.5.tgz" - }, - "react-themeable": { - "version": "1.1.0", - "from": "react-themeable@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz", - "dependencies": { - "object-assign": { - "version": "3.0.0", - "from": "object-assign@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz" - } - } - }, - "react-virtualized": { - "version": "8.11.3", - "from": "react-virtualized@latest", - "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-8.11.3.tgz", - "dependencies": { - "babel-runtime": { - "version": "6.22.0", - "from": "babel-runtime@>=6.11.6 <7.0.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.22.0.tgz" - }, - "js-tokens": { - "version": "3.0.0", - "from": "js-tokens@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.0.tgz" - }, - "loose-envify": { - "version": "1.3.1", - "from": "loose-envify@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz" - }, - "regenerator-runtime": { - "version": "0.10.1", - "from": "regenerator-runtime@>=0.10.0 <0.11.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.1.tgz" - } - } - }, - "read-file-stdin": { - "version": "0.2.1", - "from": "read-file-stdin@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/read-file-stdin/-/read-file-stdin-0.2.1.tgz" - }, - "read-pkg": { - "version": "1.1.0", - "from": "read-pkg@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz" - }, - "read-pkg-up": { - "version": "1.0.1", - "from": "read-pkg-up@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz" - }, - "readable-stream": { - "version": "2.0.6", - "from": "readable-stream@>=2.0.0 <2.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz" - }, - "readdirp": { - "version": "2.0.0", - "from": "readdirp@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.0.0.tgz" - }, - "readline2": { - "version": "1.0.1", - "from": "readline2@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/readline2/-/readline2-1.0.1.tgz" - }, - "rechoir": { - "version": "0.6.2", - "from": "rechoir@>=0.6.2 <0.7.0", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz" - }, - "redent": { - "version": "1.0.0", - "from": "redent@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz" - }, - "reduce-css-calc": { - "version": "1.2.4", - "from": "reduce-css-calc@>=1.2.0 <2.0.0", - "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.2.4.tgz", - "dependencies": { - "balanced-match": { - "version": "0.1.0", - "from": "balanced-match@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.1.0.tgz" - } - } - }, - "reduce-function-call": { - "version": "1.0.1", - "from": "reduce-function-call@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.1.tgz", - "dependencies": { - "balanced-match": { - "version": "0.1.0", - "from": "balanced-match@~0.1.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.1.0.tgz" - } - } - }, - "reduce-reducers": { - "version": "0.1.2", - "from": "reduce-reducers@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/reduce-reducers/-/reduce-reducers-0.1.2.tgz" - }, - "redux": { - "version": "3.6.0", - "from": "redux@3.6.0", - "resolved": "https://registry.npmjs.org/redux/-/redux-3.6.0.tgz" - }, - "redux-actions": { - "version": "1.2.0", - "from": "redux-actions@1.2.0", - "resolved": "https://registry.npmjs.org/redux-actions/-/redux-actions-1.2.0.tgz" - }, - "redux-batched-actions": { - "version": "0.1.5", - "from": "redux-batched-actions@latest", - "resolved": "https://registry.npmjs.org/redux-batched-actions/-/redux-batched-actions-0.1.5.tgz" - }, - "redux-localstorage": { - "version": "0.4.1", - "from": "redux-localstorage@0.4.1", - "resolved": "https://registry.npmjs.org/redux-localstorage/-/redux-localstorage-0.4.1.tgz" - }, - "redux-raven-middleware": { - "version": "1.2.0", - "from": "redux-raven-middleware@latest", - "resolved": "https://registry.npmjs.org/redux-raven-middleware/-/redux-raven-middleware-1.2.0.tgz" - }, - "redux-thunk": { - "version": "2.2.0", - "from": "redux-thunk@2.2.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.2.0.tgz" - }, - "regenerate": { - "version": "1.2.1", - "from": "regenerate@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.2.1.tgz" - }, - "regenerator-runtime": { - "version": "0.9.5", - "from": "regenerator-runtime@>=0.9.5 <0.10.0", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.9.5.tgz" - }, - "regex-cache": { - "version": "0.4.3", - "from": "regex-cache@>=0.4.2 <0.5.0", - "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.3.tgz" - }, - "regexpu-core": { - "version": "1.0.0", - "from": "regexpu-core@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-1.0.0.tgz" - }, - "regjsgen": { - "version": "0.2.0", - "from": "regjsgen@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz" - }, - "regjsparser": { - "version": "0.1.5", - "from": "regjsparser@>=0.1.4 <0.2.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz" - }, - "repeat-element": { - "version": "1.1.2", - "from": "repeat-element@>=1.1.2 <2.0.0", - "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz" - }, - "repeat-string": { - "version": "1.5.4", - "from": "repeat-string@>=1.5.0 <2.0.0", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.5.4.tgz" - }, - "repeating": { - "version": "1.1.3", - "from": "repeating@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/repeating/-/repeating-1.1.3.tgz" - }, - "replace-ext": { - "version": "0.0.1", - "from": "replace-ext@0.0.1", - "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-0.0.1.tgz" - }, - "require-from-string": { - "version": "1.2.0", - "from": "require-from-string@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-1.2.0.tgz" - }, - "require-nocache": { - "version": "1.0.0", - "from": "require-nocache@latest", - "resolved": "https://registry.npmjs.org/require-nocache/-/require-nocache-1.0.0.tgz" - }, - "require-uncached": { - "version": "1.0.3", - "from": "require-uncached@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz" - }, - "reselect": { - "version": "2.5.4", - "from": "reselect@2.5.4", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-2.5.4.tgz" - }, - "resize-observer-polyfill": { - "version": "1.3.1", - "from": "resize-observer-polyfill@1.3.1", - "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.3.1.tgz" - }, - "resolve": { - "version": "1.1.7", - "from": "resolve@>=1.1.5 <2.0.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz" - }, - "resolve-from": { - "version": "1.0.1", - "from": "resolve-from@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz" - }, - "restore-cursor": { - "version": "1.0.1", - "from": "restore-cursor@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz" - }, - "right-align": { - "version": "0.1.3", - "from": "right-align@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz" - }, - "rimraf": { - "version": "2.5.2", - "from": "rimraf@>=2.2.8 <3.0.0", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.2.tgz", - "dependencies": { - "glob": { - "version": "7.0.3", - "from": "glob@>=7.0.0 <8.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.0.3.tgz" - } - } - }, - "ripemd160": { - "version": "0.2.0", - "from": "ripemd160@0.2.0", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-0.2.0.tgz" - }, - "rocambole": { - "version": "0.7.0", - "from": "rocambole@>=0.7.0 <2.0.0", - "resolved": "https://registry.npmjs.org/rocambole/-/rocambole-0.7.0.tgz" - }, - "rocambole-indent": { - "version": "2.0.4", - "from": "rocambole-indent@>=2.0.4 <3.0.0", - "resolved": "https://registry.npmjs.org/rocambole-indent/-/rocambole-indent-2.0.4.tgz", - "dependencies": { - "mout": { - "version": "0.11.1", - "from": "mout@>=0.11.0 <0.12.0", - "resolved": "https://registry.npmjs.org/mout/-/mout-0.11.1.tgz" - } - } - }, - "rocambole-linebreak": { - "version": "1.0.1", - "from": "rocambole-linebreak@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/rocambole-linebreak/-/rocambole-linebreak-1.0.1.tgz" - }, - "rocambole-node": { - "version": "1.0.0", - "from": "rocambole-node@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/rocambole-node/-/rocambole-node-1.0.0.tgz" - }, - "rocambole-token": { - "version": "1.2.1", - "from": "rocambole-token@>=1.1.2 <2.0.0", - "resolved": "https://registry.npmjs.org/rocambole-token/-/rocambole-token-1.2.1.tgz" - }, - "rocambole-whitespace": { - "version": "1.0.0", - "from": "rocambole-whitespace@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/rocambole-whitespace/-/rocambole-whitespace-1.0.0.tgz" - }, - "run-async": { - "version": "0.1.0", - "from": "run-async@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz" - }, - "run-sequence": { - "version": "1.2.0", - "from": "run-sequence@1.2.0", - "resolved": "https://registry.npmjs.org/run-sequence/-/run-sequence-1.2.0.tgz" - }, - "rx-lite": { - "version": "3.1.2", - "from": "rx-lite@>=3.1.2 <4.0.0", - "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz" - }, - "sax": { - "version": "1.2.1", - "from": "sax@>=1.2.1 <1.3.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz" - }, - "section-iterator": { - "version": "2.0.0", - "from": "section-iterator@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/section-iterator/-/section-iterator-2.0.0.tgz" - }, - "semver": { - "version": "4.3.6", - "from": "semver@>=4.3.1 <5.0.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.6.tgz" - }, - "sequencify": { - "version": "0.0.7", - "from": "sequencify@>=0.0.7 <0.1.0", - "resolved": "https://registry.npmjs.org/sequencify/-/sequencify-0.0.7.tgz" - }, - "serializerr": { - "version": "1.0.2", - "from": "serializerr@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/serializerr/-/serializerr-1.0.2.tgz" - }, - "setimmediate": { - "version": "1.0.5", - "from": "setimmediate@>=1.0.5 <2.0.0", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz" - }, - "sha.js": { - "version": "2.2.6", - "from": "sha.js@2.2.6", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.2.6.tgz" - }, - "shallow-equal": { - "version": "1.0.0", - "from": "shallow-equal@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.0.0.tgz" - }, - "shallowequal": { - "version": "0.2.2", - "from": "shallowequal@>=0.2.2 <0.3.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-0.2.2.tgz" - }, - "shebang-regex": { - "version": "1.0.0", - "from": "shebang-regex@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz" - }, - "shelljs": { - "version": "0.6.1", - "from": "shelljs@>=0.6.0 <0.7.0", - "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.6.1.tgz" - }, - "sigmund": { - "version": "1.0.1", - "from": "sigmund@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz" - }, - "signal-exit": { - "version": "2.1.2", - "from": "signal-exit@>=2.1.2 <3.0.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-2.1.2.tgz" - }, - "slash": { - "version": "1.0.0", - "from": "slash@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz" - }, - "slice-ansi": { - "version": "0.0.4", - "from": "slice-ansi@0.0.4", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz" - }, - "sort-keys": { - "version": "1.1.2", - "from": "sort-keys@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz" - }, - "source-list-map": { - "version": "0.1.6", - "from": "source-list-map@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-0.1.6.tgz" - }, - "source-map": { - "version": "0.5.6", - "from": "source-map@>=0.5.6 <0.6.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz" - }, - "source-map-support": { - "version": "0.2.10", - "from": "source-map-support@>=0.2.10 <0.3.0", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.2.10.tgz", - "dependencies": { - "source-map": { - "version": "0.1.32", - "from": "source-map@0.1.32", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.32.tgz" - } - } - }, - "sparkles": { - "version": "1.0.0", - "from": "sparkles@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-1.0.0.tgz" - }, - "spawn-sync": { - "version": "1.0.15", - "from": "spawn-sync@>=1.0.5 <2.0.0", - "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz" - }, - "spdx-correct": { - "version": "1.0.2", - "from": "spdx-correct@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-1.0.2.tgz" - }, - "spdx-exceptions": { - "version": "1.0.4", - "from": "spdx-exceptions@>=1.0.4 <2.0.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-1.0.4.tgz" - }, - "spdx-expression-parse": { - "version": "1.0.2", - "from": "spdx-expression-parse@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-1.0.2.tgz" - }, - "spdx-license-ids": { - "version": "1.2.1", - "from": "spdx-license-ids@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-1.2.1.tgz" - }, - "specificity": { - "version": "0.2.1", - "from": "specificity@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/specificity/-/specificity-0.2.1.tgz" - }, - "split": { - "version": "0.3.3", - "from": "split@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz" - }, - "split2": { - "version": "0.2.1", - "from": "split2@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-0.2.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - } - } - }, - "sprintf-js": { - "version": "1.0.3", - "from": "sprintf-js@>=1.0.2 <1.1.0", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" - }, - "statuses": { - "version": "1.3.0", - "from": "statuses@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.0.tgz" - }, - "stdin": { - "version": "0.0.1", - "from": "stdin@*", - "resolved": "https://registry.npmjs.org/stdin/-/stdin-0.0.1.tgz" - }, - "stream-browserify": { - "version": "1.0.0", - "from": "stream-browserify@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-1.0.0.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.1.14", - "from": "readable-stream@>=1.0.27-1 <2.0.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz" - } - } - }, - "stream-combiner": { - "version": "0.0.4", - "from": "stream-combiner@>=0.0.4 <0.1.0", - "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz" - }, - "stream-consume": { - "version": "0.1.0", - "from": "stream-consume@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/stream-consume/-/stream-consume-0.1.0.tgz" - }, - "streamqueue": { - "version": "1.1.1", - "from": "streamqueue@1.1.1", - "resolved": "https://registry.npmjs.org/streamqueue/-/streamqueue-1.1.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33 <1.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - } - } - }, - "strict-uri-encode": { - "version": "1.1.0", - "from": "strict-uri-encode@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz" - }, - "string_decoder": { - "version": "0.10.31", - "from": "string_decoder@>=0.10.0 <0.11.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" - }, - "string-width": { - "version": "1.0.1", - "from": "string-width@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.1.tgz" - }, - "strip-ansi": { - "version": "3.0.1", - "from": "strip-ansi@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz" - }, - "strip-bom": { - "version": "2.0.0", - "from": "strip-bom@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz" - }, - "strip-bom-stream": { - "version": "1.0.0", - "from": "strip-bom-stream@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-stream/-/strip-bom-stream-1.0.0.tgz" - }, - "strip-indent": { - "version": "1.0.1", - "from": "strip-indent@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz" - }, - "strip-json-comments": { - "version": "0.1.3", - "from": "strip-json-comments@>=0.1.1 <0.2.0", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-0.1.3.tgz" - }, - "style-loader": { - "version": "0.13.1", - "from": "style-loader@0.13.1", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-0.13.1.tgz" - }, - "style-search": { - "version": "0.1.0", - "from": "style-search@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz" - }, - "stylehacks": { - "version": "2.3.1", - "from": "stylehacks@>=2.3.0 <3.0.0", - "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-2.3.1.tgz" - }, - "stylelint": { - "version": "7.3.1", - "from": "stylelint@7.3.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-7.3.1.tgz", - "dependencies": { - "get-stdin": { - "version": "5.0.1", - "from": "get-stdin@>=5.0.0 <6.0.0", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-5.0.1.tgz" - }, - "glob": { - "version": "7.1.0", - "from": "glob@>=7.0.3 <8.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.0.tgz" - }, - "globby": { - "version": "6.0.0", - "from": "globby@>=6.0.0 <7.0.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-6.0.0.tgz" - }, - "ignore": { - "version": "3.1.5", - "from": "ignore@>=3.1.3 <4.0.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.1.5.tgz" - }, - "minimatch": { - "version": "3.0.3", - "from": "minimatch@^3.0.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz" - }, - "postcss-selector-parser": { - "version": "2.2.1", - "from": "postcss-selector-parser@>=2.1.1 <3.0.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-2.2.1.tgz" - }, - "resolve-from": { - "version": "2.0.0", - "from": "resolve-from@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz" - } - } - }, - "sugarss": { - "version": "0.1.6", - "from": "sugarss@>=0.1.2 <0.2.0", - "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-0.1.6.tgz", - "dependencies": { - "postcss": { - "version": "5.2.0", - "from": "postcss@^5.2.0", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.0.tgz" - } - } - }, - "supports-color": { - "version": "3.1.2", - "from": "supports-color@>=3.1.2 <4.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.1.2.tgz" - }, - "svg-tags": { - "version": "1.0.0", - "from": "svg-tags@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz" - }, - "svgo": { - "version": "0.6.6", - "from": "svgo@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-0.6.6.tgz" - }, - "symbol-observable": { - "version": "1.0.4", - "from": "symbol-observable@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.4.tgz" - }, - "sync-exec": { - "version": "0.5.0", - "from": "sync-exec@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/sync-exec/-/sync-exec-0.5.0.tgz" - }, - "synesthesia": { - "version": "1.0.1", - "from": "synesthesia@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/synesthesia/-/synesthesia-1.0.1.tgz" - }, - "table": { - "version": "3.7.8", - "from": "table@>=3.7.8 <4.0.0", - "resolved": "https://registry.npmjs.org/table/-/table-3.7.8.tgz" - }, - "tapable": { - "version": "0.1.10", - "from": "tapable@>=0.1.8 <0.2.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.1.10.tgz" - }, - "tar": { - "version": "2.2.1", - "from": "tar@>=2.1.1 <3.0.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz" - }, - "tar.gz": { - "version": "1.0.3", - "from": "tar.gz@1.0.3", - "resolved": "https://registry.npmjs.org/tar.gz/-/tar.gz-1.0.3.tgz", - "dependencies": { - "bluebird": { - "version": "2.10.2", - "from": "bluebird@>=2.9.34 <3.0.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.10.2.tgz" - }, - "mout": { - "version": "0.11.1", - "from": "mout@>=0.11.0 <0.12.0", - "resolved": "https://registry.npmjs.org/mout/-/mout-0.11.1.tgz" - } - } - }, - "tether": { - "version": "1.4.0", - "from": "tether@>=1.3.7 <2.0.0", - "resolved": "https://registry.npmjs.org/tether/-/tether-1.4.0.tgz" - }, - "text-table": { - "version": "0.2.0", - "from": "text-table@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz" - }, - "through": { - "version": "2.3.8", - "from": "through@>=2.3.6 <3.0.0", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz" - }, - "through2": { - "version": "2.0.1", - "from": "through2@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.1.tgz" - }, - "tildify": { - "version": "1.2.0", - "from": "tildify@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/tildify/-/tildify-1.2.0.tgz" - }, - "time-stamp": { - "version": "1.0.1", - "from": "time-stamp@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.0.1.tgz" - }, - "timers-browserify": { - "version": "1.4.2", - "from": "timers-browserify@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-1.4.2.tgz" - }, - "to-fast-properties": { - "version": "1.0.2", - "from": "to-fast-properties@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.2.tgz" - }, - "trim-newlines": { - "version": "1.0.0", - "from": "trim-newlines@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz" - }, - "tryit": { - "version": "1.0.2", - "from": "tryit@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/tryit/-/tryit-1.0.2.tgz" - }, - "tty-browserify": { - "version": "0.0.0", - "from": "tty-browserify@0.0.0", - "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz" - }, - "tv4": { - "version": "1.2.7", - "from": "tv4@>=1.2.7 <2.0.0", - "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.2.7.tgz" - }, - "type-check": { - "version": "0.3.2", - "from": "type-check@>=0.3.2 <0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz" - }, - "type-detect": { - "version": "1.0.0", - "from": "type-detect@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz" - }, - "type-is": { - "version": "1.6.13", - "from": "type-is@>=1.6.10 <1.7.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.13.tgz" - }, - "typedarray": { - "version": "0.0.6", - "from": "typedarray@>=0.0.6 <0.0.7", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz" - }, - "ua-parser-js": { - "version": "0.7.12", - "from": "ua-parser-js@>=0.7.9 <0.8.0", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.12.tgz" - }, - "uglify-js": { - "version": "2.3.6", - "from": "uglify-js@>=2.3.0 <2.4.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.3.6.tgz", - "dependencies": { - "optimist": { - "version": "0.3.7", - "from": "optimist@>=0.3.5 <0.4.0", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz" - }, - "source-map": { - "version": "0.1.43", - "from": "source-map@>=0.1.7 <0.2.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz" - }, - "wordwrap": { - "version": "0.0.3", - "from": "wordwrap@>=0.0.2 <0.1.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz" - } - } - }, - "uglify-to-browserify": { - "version": "1.0.2", - "from": "uglify-to-browserify@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz" - }, - "uniq": { - "version": "1.0.1", - "from": "uniq@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz" - }, - "uniqid": { - "version": "1.0.0", - "from": "uniqid@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/uniqid/-/uniqid-1.0.0.tgz" - }, - "uniqs": { - "version": "2.0.0", - "from": "uniqs@>=2.0.0 <3.0.0", - "resolved": "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz" - }, - "unique-stream": { - "version": "1.0.0", - "from": "unique-stream@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-1.0.0.tgz" - }, - "unpipe": { - "version": "1.0.0", - "from": "unpipe@1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" - }, - "url": { - "version": "0.10.3", - "from": "url@>=0.10.1 <0.11.0", - "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "dependencies": { - "punycode": { - "version": "1.3.2", - "from": "punycode@1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz" - } - } - }, - "url-loader": { - "version": "0.5.7", - "from": "url-loader@0.5.7", - "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-0.5.7.tgz", - "dependencies": { - "mime": { - "version": "1.2.11", - "from": "mime@>=1.2.0 <1.3.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.2.11.tgz" - } - } - }, - "user-home": { - "version": "1.1.1", - "from": "user-home@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz" - }, - "util": { - "version": "0.10.3", - "from": "util@>=0.10.3 <0.11.0", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz" - }, - "util-deprecate": { - "version": "1.0.2", - "from": "util-deprecate@>=1.0.1 <1.1.0", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" - }, - "v8flags": { - "version": "2.0.11", - "from": "v8flags@>=2.0.2 <3.0.0", - "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-2.0.11.tgz" - }, - "validate-npm-package-license": { - "version": "3.0.1", - "from": "validate-npm-package-license@>=3.0.1 <4.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz" - }, - "vinyl": { - "version": "0.5.3", - "from": "vinyl@>=0.5.0 <0.6.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-0.5.3.tgz" - }, - "vinyl-bufferstream": { - "version": "1.0.1", - "from": "vinyl-bufferstream@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/vinyl-bufferstream/-/vinyl-bufferstream-1.0.1.tgz" - }, - "vinyl-file": { - "version": "1.3.0", - "from": "vinyl-file@>=1.2.1 <2.0.0", - "resolved": "https://registry.npmjs.org/vinyl-file/-/vinyl-file-1.3.0.tgz", - "dependencies": { - "vinyl": { - "version": "1.1.1", - "from": "vinyl@>=1.1.0 <2.0.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-1.1.1.tgz" - } - } - }, - "vinyl-fs": { - "version": "0.3.14", - "from": "vinyl-fs@>=0.3.0 <0.4.0", - "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-0.3.14.tgz", - "dependencies": { - "clone": { - "version": "0.2.0", - "from": "clone@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/clone/-/clone-0.2.0.tgz" - }, - "graceful-fs": { - "version": "3.0.8", - "from": "graceful-fs@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-3.0.8.tgz" - }, - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.33-1 <1.1.0-0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "strip-bom": { - "version": "1.0.0", - "from": "strip-bom@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-1.0.0.tgz" - }, - "through2": { - "version": "0.6.5", - "from": "through2@>=0.6.1 <0.7.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz" - }, - "vinyl": { - "version": "0.4.6", - "from": "vinyl@>=0.4.0 <0.5.0", - "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-0.4.6.tgz" - } - } - }, - "vinyl-map": { - "version": "1.0.1", - "from": "vinyl-map@>=1.0.1 <2.0.0", - "resolved": "https://registry.npmjs.org/vinyl-map/-/vinyl-map-1.0.1.tgz", - "dependencies": { - "isarray": { - "version": "0.0.1", - "from": "isarray@0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" - }, - "readable-stream": { - "version": "1.0.34", - "from": "readable-stream@>=1.0.17 <1.1.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz" - }, - "through2": { - "version": "0.4.2", - "from": "through2@>=0.4.1 <0.5.0", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.4.2.tgz" - }, - "xtend": { - "version": "2.1.2", - "from": "xtend@>=2.1.1 <2.2.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz" - } - } - }, - "vinyl-sourcemaps-apply": { - "version": "0.2.1", - "from": "vinyl-sourcemaps-apply@>=0.2.0 <0.3.0", - "resolved": "https://registry.npmjs.org/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz" - }, - "vm-browserify": { - "version": "0.0.4", - "from": "vm-browserify@0.0.4", - "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz" - }, - "warning": { - "version": "3.0.0", - "from": "warning@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz" - }, - "watchpack": { - "version": "0.2.9", - "from": "watchpack@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-0.2.9.tgz", - "dependencies": { - "async": { - "version": "0.9.2", - "from": "async@>=0.9.0 <0.10.0", - "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz" - } - } - }, - "webpack": { - "version": "1.13.1", - "from": "webpack@1.13.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-1.13.1.tgz", - "dependencies": { - "async": { - "version": "1.5.2", - "from": "async@>=1.3.0 <2.0.0", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz" - }, - "interpret": { - "version": "0.6.6", - "from": "interpret@>=0.6.4 <0.7.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-0.6.6.tgz" - }, - "uglify-js": { - "version": "2.6.2", - "from": "uglify-js@>=2.6.0 <2.7.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.6.2.tgz", - "dependencies": { - "async": { - "version": "0.2.10", - "from": "async@>=0.2.6 <0.3.0", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz" - } - } - } - } - }, - "webpack-core": { - "version": "0.6.8", - "from": "webpack-core@>=0.6.0 <0.7.0", - "resolved": "https://registry.npmjs.org/webpack-core/-/webpack-core-0.6.8.tgz", - "dependencies": { - "source-map": { - "version": "0.4.4", - "from": "source-map@>=0.4.1 <0.5.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz" - } - } - }, - "webpack-sources": { - "version": "0.1.3", - "from": "webpack-sources@>=0.1.0 <0.2.0", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-0.1.3.tgz" - }, - "webpack-stream": { - "version": "2.1.1", - "from": "webpack-stream@2.1.1", - "resolved": "https://registry.npmjs.org/webpack-stream/-/webpack-stream-2.1.1.tgz", - "dependencies": { - "memory-fs": { - "version": "0.2.0", - "from": "memory-fs@>=0.2.0 <0.3.0-0", - "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz" - } - } - }, - "websocket-driver": { - "version": "0.6.5", - "from": "websocket-driver@>=0.3.6", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.6.5.tgz" - }, - "websocket-extensions": { - "version": "0.1.1", - "from": "websocket-extensions@>=0.1.1", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.1.tgz" - }, - "whatwg-fetch": { - "version": "2.0.2", - "from": "whatwg-fetch@>=0.10.0", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.2.tgz" - }, - "when": { - "version": "3.7.7", - "from": "when@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/when/-/when-3.7.7.tgz" - }, - "whet.extend": { - "version": "0.9.9", - "from": "whet.extend@>=0.9.9 <0.10.0", - "resolved": "https://registry.npmjs.org/whet.extend/-/whet.extend-0.9.9.tgz" - }, - "which": { - "version": "1.2.9", - "from": "which@>=1.2.4 <2.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-1.2.9.tgz" - }, - "window-size": { - "version": "0.1.0", - "from": "window-size@0.1.0", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz" - }, - "wordwrap": { - "version": "1.0.0", - "from": "wordwrap@>=1.0.0 <1.1.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" - }, - "wrappy": { - "version": "1.0.2", - "from": "wrappy@>=1.0.0 <2.0.0", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" - }, - "write": { - "version": "0.2.1", - "from": "write@>=0.2.1 <0.3.0", - "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz" - }, - "write-file-stdout": { - "version": "0.0.2", - "from": "write-file-stdout@0.0.2", - "resolved": "https://registry.npmjs.org/write-file-stdout/-/write-file-stdout-0.0.2.tgz" - }, - "xregexp": { - "version": "3.1.1", - "from": "xregexp@>=3.0.0 <4.0.0", - "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-3.1.1.tgz" - }, - "xtend": { - "version": "4.0.1", - "from": "xtend@>=4.0.0 <5.0.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz" - }, - "yargs": { - "version": "3.10.0", - "from": "yargs@>=3.10.0 <3.11.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", - "dependencies": { - "camelcase": { - "version": "1.2.1", - "from": "camelcase@>=1.0.2 <2.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz" - } - } - } - } -} diff --git a/package.json b/package.json index 0e2bbdd55..85dd8eb59 100644 --- a/package.json +++ b/package.json @@ -1,96 +1,114 @@ { - "name": "Sonarr", - "version": "2.0.0", - "description": "Sonarr", + "name": "sonarr", + "version": "3.0.0", + "description": "Sonarr is a PVR for Usenet and BitTorrent users", "scripts": { "build": "gulp build", - "start": "gulp watch" - }, - "repository": { - "type": "git", - "url": "git://github.com/Sonarr/Sonarr.git" + "start": "gulp watch", + "eslint": "esprint check", + "eslint-fix": "eslint start --fix", + "stylelint": "stylelint frontend/**/*.css --config frontend/.stylelintrc" }, + "repository": "https://github.com/Sonarr/Sonarr", "author": "Team Sonarr", "license": "GPL-3.0", "readmeFilename": "readme.md", "dependencies": { - "autoprefixer": "6.3.6", - "babel-core": "6.9.0", - "babel-eslint": "7.1.0", - "babel-loader": "6.2.4", - "babel-plugin-transform-class-properties": "6.16.0", + "@fortawesome/fontawesome-free": "5.3.1", + "@fortawesome/fontawesome-svg-core": "1.2.4", + "@fortawesome/free-regular-svg-icons": "5.3.1", + "@fortawesome/free-solid-svg-icons": "5.3.1", + "@fortawesome/react-fontawesome": "0.1.3", + "@sentry/browser": "4.0.3", + "autoprefixer": "9.1.5", + "babel-core": "6.26.3", + "babel-eslint": "9.0.0", + "babel-loader": "7.1.2", + "babel-plugin-transform-class-properties": "6.24.1", "babel-preset-decorators-legacy": "1.0.0", - "babel-preset-es2015": "6.9.0", - "babel-preset-react": "6.22.0", - "babel-preset-stage-2": "6.5.0", - "classnames": "2.2.5", - "css-loader": "0.23.1", - "del": "2.2.0", + "babel-preset-es2015": "6.24.1", + "babel-preset-react": "6.24.1", + "babel-preset-stage-2": "6.24.1", + "classnames": "2.2.6", + "clipboard": "2.0.1", + "create-react-class": "15.6.3", + "css-loader": "0.28.9", + "del": "3.0.0", "element-class": "0.2.2", - "esformatter": "0.9.3", - "eslint": "2.10.2", - "eslint-loader": "1.3.0", - "eslint-plugin-filenames": "1.0.0", - "eslint-plugin-react": "5.2.2", - "extract-text-webpack-plugin": "1.0.1", - "file-loader": "0.9.0", - "filesize": "3.5.4", + "esformatter": "0.10.0", + "eslint": "5.6.0", + "eslint-plugin-filenames": "1.3.2", + "eslint-plugin-react": "7.11.1", + "esprint": "0.4.0", + "extract-text-webpack-plugin": "3.0.2", + "file-loader": "1.1.6", + "filesize": "3.6.1", "gulp": "3.9.1", - "gulp-cached": "1.1.0", - "gulp-clean-css": "^3.0.4", - "gulp-concat": "2.6.0", + "gulp-cached": "1.1.1", + "gulp-clean-css": "3.10.0", + "gulp-concat": "2.6.1", "gulp-declare": "0.3.0", - "gulp-handlebars": "3.0.1", - "gulp-less": "3.0.3", - "gulp-livereload": "3.8.1", - "gulp-postcss": "6.1.1", - "gulp-print": "2.0.1", - "gulp-sourcemaps": "1.6.0", + "gulp-livereload": "4.0.0", + "gulp-postcss": "8.0.0", + "gulp-print": "5.0.0", + "gulp-sourcemaps": "2.6.4", "gulp-stripbom": "1.0.4", - "gulp-util": "3.0.7", - "gulp-watch": "4.3.5", - "gulp-wrap": "0.13.0", - "handlebars": "3.0.3", - "lodash": "4.17.4", - "moment": "2.17.1", - "normalize.css": "5.0.0", - "postcss-loader": "0.9.1", - "postcss-nested": "1.0.0", - "postcss-simple-vars": "3.0.0", - "react": "15.4.2", - "react-addons-shallow-compare": "15.4.2", - "react-autosuggest": "8.0.0", - "react-dnd": "2.1.4", - "react-dnd-html5-backend": "2.1.2", - "react-document-title": "2.0.2", - "react-dom": "15.4.2", - "react-google-recaptcha": "0.5.4", - "react-lazyload": "2.2.0", - "react-measure": "1.4.5", - "react-portal": "3.0.0", - "react-redux": "5.0.2", - "react-router": "3.0.2", - "react-router-redux": "4.0.7", - "react-slider": "0.7.0", - "react-tabs": "0.8.2", - "react-tag-autocomplete": "5.1.0", - "react-tether": "0.5.5", - "react-virtualized": "8.11.3", - "redux": "3.6.0", - "redux-actions": "1.2.0", - "redux-batched-actions": "0.1.5", + "gulp-util": "3.0.8", + "gulp-watch": "5.0.1", + "gulp-wrap": "0.14.0", + "history": "4.7.2", + "jdu": "1.0.0", + "jquery": "3.3.1", + "loader-utils": "^1.1.0", + "lodash": "4.17.11", + "mobile-detect": "1.4.3", + "moment": "2.22.2", + "mousetrap": "1.6.2", + "normalize.css": "8.0.0", + "postcss-loader": "3.0.0", + "postcss-mixins": "6.2.0", + "postcss-nested": "4.1.0", + "postcss-simple-vars": "5.0.1", + "prop-types": "15.6.2", + "qs": "6.5.2", + "react": "16.5.2", + "react-addons-shallow-compare": "15.6.2", + "react-async-script": "1.0.0", + "react-autosuggest": "9.4.1", + "react-custom-scrollbars": "4.2.1", + "react-dnd": "5.0.0", + "react-dnd-html5-backend": "5.0.1", + "react-document-title": "2.0.3", + "react-dom": "16.5.2", + "react-google-recaptcha": "1.0.2", + "react-lazyload": "2.3.0", + "react-measure": "1.4.7", + "react-redux": "5.0.7", + "react-router-dom": "4.3.1", + "react-router-redux": "5.0.0-alpha.6", + "react-slider": "0.11.2", + "react-tabs": "2.3.0", + "react-tether": "1.0.1", + "react-text-truncate": "0.13.1", + "react-virtualized": "9.20.1", + "redux": "4.0.0", + "redux-actions": "2.6.1", + "redux-batched-actions": "0.4.0", "redux-localstorage": "0.4.1", - "redux-raven-middleware": "1.2.0", - "redux-thunk": "2.2.0", + "redux-thunk": "2.3.0", "require-nocache": "1.0.0", - "reselect": "2.5.4", - "run-sequence": "1.2.0", - "streamqueue": "1.1.1", - "style-loader": "0.13.1", - "stylelint": "7.3.1", - "tar.gz": "1.0.3", - "url-loader": "0.5.7", - "webpack": "1.13.1", - "webpack-stream": "2.1.1" - } + "reselect": "3.0.1", + "run-sequence": "2.2.1", + "signalr": "2.4.0", + "streamqueue": "1.1.2", + "style-loader": "0.19.1", + "stylelint": "9.5.0", + "stylelint-order": "1.0.0", + "tar.gz": "1.0.7", + "uglifyjs-webpack-plugin": "1.2.5", + "url-loader": "0.6.2", + "webpack": "3.10.0", + "webpack-stream": "^4.0.0" + }, + "main": "index.js" } diff --git a/src/NzbDrone.Common/Serializer/Json.cs b/src/NzbDrone.Common/Serializer/Json.cs index 4a0565a4d..90023e8f3 100644 --- a/src/NzbDrone.Common/Serializer/Json.cs +++ b/src/NzbDrone.Common/Serializer/Json.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Reflection; using Newtonsoft.Json; @@ -40,7 +40,7 @@ namespace NzbDrone.Common.Serializer { try { - return JsonConvert.DeserializeObject(json, SerializerSetting); + return JsonConvert.DeserializeObject(json, SerializerSettings); } catch (JsonReaderException ex) { @@ -52,7 +52,7 @@ namespace NzbDrone.Common.Serializer { try { - return JsonConvert.DeserializeObject(json, type, SerializerSetting); + return JsonConvert.DeserializeObject(json, type, SerializerSettings); } catch (JsonReaderException ex) { diff --git a/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs b/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs index c64adfe80..eed192a6a 100644 --- a/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs +++ b/src/NzbDrone.Core/Messaging/Commands/CommandQueueManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; diff --git a/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs b/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs index 1dbc4dfa4..c32fcc796 100644 --- a/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs +++ b/src/NzbDrone.Host/Owin/MiddleWare/SignalRMiddleWare.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.AspNet.SignalR; using NzbDrone.Common.Composition; using NzbDrone.SignalR; @@ -22,7 +22,7 @@ namespace NzbDrone.Host.Owin.MiddleWare public void Attach(IAppBuilder appBuilder) { - appBuilder.MapConnection("/signalr", typeof(NzbDronePersistentConnection), new ConnectionConfiguration()); + appBuilder.MapSignalR("/signalr", typeof(NzbDronePersistentConnection), new ConnectionConfiguration()); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Host/Owin/OwinServiceProvider.cs b/src/NzbDrone.Host/Owin/OwinServiceProvider.cs index c0676cd24..792c4d12c 100644 --- a/src/NzbDrone.Host/Owin/OwinServiceProvider.cs +++ b/src/NzbDrone.Host/Owin/OwinServiceProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -70,7 +70,7 @@ namespace NzbDrone.Host.Owin private void BuildApp(IAppBuilder appBuilder) { - appBuilder.Properties["host.AppName"] = "NzbDrone"; + appBuilder.Properties["host.AppName"] = "Sonarr"; foreach (var middleWare in _owinMiddleWares.OrderBy(c => c.Order)) { @@ -88,4 +88,4 @@ namespace NzbDrone.Host.Owin return provider; } } -} \ No newline at end of file +} diff --git a/src/Sonarr.sln b/src/Sonarr.sln index fc53dfb4d..0d25c827a 100644 --- a/src/Sonarr.sln +++ b/src/Sonarr.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26730.10 +VisualStudioVersion = 15.0.27130.2010 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NzbDrone.Console", "NzbDrone.Console\NzbDrone.Console.csproj", "{3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}" ProjectSection(ProjectDependencies) = postProject @@ -106,6 +106,12 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.ActiveCfg = Debug|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.Build.0 = Debug|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.ActiveCfg = Debug|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.Build.0 = Debug|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.ActiveCfg = Release|x86 + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.Build.0 = Release|x86 {FAFB5948-A222-4CF6-AD14-026BE7564802}.Debug|x86.ActiveCfg = Debug|x86 {FAFB5948-A222-4CF6-AD14-026BE7564802}.Debug|x86.Build.0 = Debug|x86 {FAFB5948-A222-4CF6-AD14-026BE7564802}.Mono|x86.ActiveCfg = Release|x86 @@ -200,12 +206,6 @@ Global {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Mono|x86.Build.0 = Release|x86 {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Release|x86.ActiveCfg = Release|x86 {FD286DF8-2D3A-4394-8AD5-443FADE55FB2}.Release|x86.Build.0 = Release|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.ActiveCfg = Debug|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Debug|x86.Build.0 = Debug|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.ActiveCfg = Debug|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Mono|x86.Build.0 = Debug|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.ActiveCfg = Release|x86 - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976}.Release|x86.Build.0 = Release|x86 {95C11A9E-56ED-456A-8447-2C89C1139266}.Debug|x86.ActiveCfg = Debug|x86 {95C11A9E-56ED-456A-8447-2C89C1139266}.Debug|x86.Build.0 = Debug|x86 {95C11A9E-56ED-456A-8447-2C89C1139266}.Mono|x86.ActiveCfg = Debug|x86 @@ -290,6 +290,7 @@ Global HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution + {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976} = {486ADF86-DD89-4E19-B805-9D94F19800D9} {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97} = {57A04B72-8088-4F75-A582-1158CF8291F7} {FAFB5948-A222-4CF6-AD14-026BE7564802} = {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97} {CADDFCE0-7509-4430-8364-2074E1EEFCA2} = {47697CDB-27B6-4B05-B4F8-0CBE6F6EDF97} @@ -303,7 +304,6 @@ Global {CC26800D-F67E-464B-88DE-8EB1A0C227A3} = {57A04B72-8088-4F75-A582-1158CF8291F7} {6BCE712F-846D-4846-9D1B-A66B858DA755} = {F9E67978-5CD6-4A5F-827B-4249711C0B02} {700D0B95-95CD-43F3-B6C9-FAA0FC1358D4} = {F9E67978-5CD6-4A5F-827B-4249711C0B02} - {3DCA7B58-B8B3-49AC-9D9E-56F4A0460976} = {486ADF86-DD89-4E19-B805-9D94F19800D9} {95C11A9E-56ED-456A-8447-2C89C1139266} = {486ADF86-DD89-4E19-B805-9D94F19800D9} {D12F7F2F-8A3C-415F-88FA-6DD061A84869} = {486ADF86-DD89-4E19-B805-9D94F19800D9} {F6FC6BE7-0847-4817-A1ED-223DC647C3D7} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} @@ -318,8 +318,8 @@ Global {74420A79-CC16-442C-8B1E-7C1B913844F0} = {F6E3A728-AE77-4D02-BAC8-82FBC1402DDA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {2955716E-0882-41EC-935D-C95694C5C30F} EnterpriseLibraryConfigurationToolBinariesPath = packages\Unity.2.1.505.0\lib\NET35;packages\Unity.2.1.505.2\lib\NET35 + SolutionGuid = {2955716E-0882-41EC-935D-C95694C5C30F} EndGlobalSection GlobalSection(MonoDevelopProperties) = preSolution StartupItem = NzbDrone.Console\NzbDrone.Console.csproj diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 000000000..22d1eeec9 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8528 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" + dependencies: + "@babel/highlight" "^7.0.0" + +"@babel/core@^7.0.0-rc.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.0.1.tgz#406658caed0e9686fa4feb5c2f3cefb6161c0f41" + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/generator" "^7.0.0" + "@babel/helpers" "^7.0.0" + "@babel/parser" "^7.0.0" + "@babel/template" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@babel/types" "^7.0.0" + convert-source-map "^1.1.0" + debug "^3.1.0" + json5 "^0.5.0" + lodash "^4.17.10" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + +"@babel/generator@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.0.0.tgz#1efd58bffa951dc846449e58ce3a1d7f02d393aa" + dependencies: + "@babel/types" "^7.0.0" + jsesc "^2.5.1" + lodash "^4.17.10" + source-map "^0.5.0" + trim-right "^1.0.1" + +"@babel/helper-function-name@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.0.0.tgz#a68cc8d04420ccc663dd258f9cc41b8261efa2d4" + dependencies: + "@babel/helper-get-function-arity" "^7.0.0" + "@babel/template" "^7.0.0" + "@babel/types" "^7.0.0" + +"@babel/helper-get-function-arity@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz#83572d4320e2a4657263734113c42868b64e49c3" + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helper-split-export-declaration@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.0.0.tgz#3aae285c0311c2ab095d997b8c9a94cad547d813" + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helpers@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.0.0.tgz#7213388341eeb07417f44710fd7e1d00acfa6ac0" + dependencies: + "@babel/template" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@babel/types" "^7.0.0" + +"@babel/highlight@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0.tgz#f710c38c8d458e6dd9a201afb637fcb781ce99e4" + dependencies: + chalk "^2.0.0" + esutils "^2.0.2" + js-tokens "^4.0.0" + +"@babel/parser@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.0.0.tgz#697655183394facffb063437ddf52c0277698775" + +"@babel/template@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.0.0.tgz#c2bc9870405959c89a9c814376a2ecb247838c80" + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.0.0" + "@babel/types" "^7.0.0" + +"@babel/traverse@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.0.0.tgz#b1fe9b6567fdf3ab542cfad6f3b31f854d799a61" + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/generator" "^7.0.0" + "@babel/helper-function-name" "^7.0.0" + "@babel/helper-split-export-declaration" "^7.0.0" + "@babel/parser" "^7.0.0" + "@babel/types" "^7.0.0" + debug "^3.1.0" + globals "^11.1.0" + lodash "^4.17.10" + +"@babel/types@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.0.0.tgz#6e191793d3c854d19c6749989e3bc55f0e962118" + dependencies: + esutils "^2.0.2" + lodash "^4.17.10" + to-fast-properties "^2.0.0" + +"@fortawesome/fontawesome-common-types@^0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.4.tgz#7c560ff732c6c7c7c179ae25227ce5449e6f6d65" + +"@fortawesome/fontawesome-free@5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.3.1.tgz#5466b8f31c1f493a96754c1426c25796d0633dd9" + +"@fortawesome/fontawesome-svg-core@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.4.tgz#2e40c65e66c7ad5aabc179a2d7c5827b1599905c" + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.4" + +"@fortawesome/free-regular-svg-icons@5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.3.1.tgz#edd019dc61a991c7e3cb9d862a6efd971675e3ba" + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.4" + +"@fortawesome/free-solid-svg-icons@5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.3.1.tgz#9660bece3c4850d58f1653e26c1693487ff74b08" + dependencies: + "@fortawesome/fontawesome-common-types" "^0.2.4" + +"@fortawesome/react-fontawesome@0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.3.tgz#266b4047892c3d10498af1075d89252f74015b11" + dependencies: + humps "^2.0.1" + prop-types "^15.5.10" + +"@gulp-sourcemaps/identity-map@1.X": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/identity-map/-/identity-map-1.0.1.tgz#cfa23bc5840f9104ce32a65e74db7e7a974bbee1" + dependencies: + acorn "^5.0.3" + css "^2.2.1" + normalize-path "^2.1.1" + source-map "^0.5.6" + through2 "^2.0.3" + +"@gulp-sourcemaps/map-sources@1.X": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@gulp-sourcemaps/map-sources/-/map-sources-1.0.0.tgz#890ae7c5d8c877f6d384860215ace9d7ec945bda" + dependencies: + normalize-path "^2.0.1" + through2 "^2.0.3" + +"@mrmlnc/readdir-enhanced@^2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" + dependencies: + call-me-maybe "^1.0.1" + glob-to-regexp "^0.3.0" + +"@nodelib/fs.stat@^1.0.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.2.tgz#54c5a964462be3d4d78af631363c18d6fa91ac26" + +"@sentry/browser@4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-4.0.3.tgz#a748ce8b7695246d8d36f6c1fe209d259e18f1c2" + dependencies: + "@sentry/core" "4.0.3" + "@sentry/types" "4.0.1" + "@sentry/utils" "4.0.1" + md5 "2.2.1" + +"@sentry/core@4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-4.0.3.tgz#4f8fd67888f1cf0f1a984c5fa362122b60e8bd08" + dependencies: + "@sentry/hub" "4.0.1" + "@sentry/minimal" "4.0.1" + "@sentry/types" "4.0.1" + "@sentry/utils" "4.0.1" + +"@sentry/hub@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-4.0.1.tgz#01870cede195029ae32d763199ff6c3e4edf99d1" + dependencies: + "@sentry/types" "4.0.0" + "@sentry/utils" "4.0.1" + +"@sentry/minimal@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-4.0.1.tgz#c51a2af81eba48977fb54ab187e0c0eb0ad12c15" + dependencies: + "@sentry/hub" "4.0.1" + "@sentry/types" "4.0.0" + +"@sentry/types@4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.0.0.tgz#9dd46a7b05004871fe0cea0b0423098d9d91a173" + +"@sentry/types@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.0.1.tgz#f9342e905ce2aee71975574589d915b6fb691fb0" + +"@sentry/utils@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-4.0.1.tgz#5690058fb030c23d46ea056aa3e8ebebb8105d45" + dependencies: + "@sentry/types" "4.0.0" + +abbrev@1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.0.tgz#d0554c2256636e2f56e7c2e5ad183f859428d81f" + +acorn-dynamic-import@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-2.0.2.tgz#c752bd210bef679501b6c6cb7fc84f8f47158cc4" + dependencies: + acorn "^4.0.3" + +acorn-jsx@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-4.1.1.tgz#e8e41e48ea2fe0c896740610ab6a4ffd8add225e" + dependencies: + acorn "^5.0.3" + +acorn-to-esprima@^2.0.6, acorn-to-esprima@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/acorn-to-esprima/-/acorn-to-esprima-2.0.8.tgz#003f0c642eb92132f417d3708f14ada82adf2eb1" + +acorn@5.X: + version "5.3.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.3.0.tgz#7446d39459c54fb49a80e6ee6478149b940ec822" + +acorn@^4.0.3: + version "4.0.13" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787" + +acorn@^5.0.0, acorn@^5.0.3: + version "5.1.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.1.2.tgz#911cb53e036807cf0fa778dc5d370fbd864246d7" + +acorn@^5.6.0: + version "5.7.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279" + +add-px-to-style@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/add-px-to-style/-/add-px-to-style-1.0.0.tgz#d0c135441fa8014a8137904531096f67f28f263a" + +ajv-errors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.0.tgz#ecf021fa108fd17dfb5e6b383f2dd233e31ffc59" + +ajv-keywords@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.0.tgz#a296e17f7bfae7c1ce4f7e0de53d29cb32162df0" + +ajv-keywords@^3.0.0, ajv-keywords@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.2.0.tgz#e86b819c602cf8821ad637413698f1dec021847a" + +ajv@^5.0.0, ajv@^5.1.5: + version "5.2.3" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.3.tgz#c06f598778c44c6b161abafe3466b81ad1814ed2" + dependencies: + co "^4.6.0" + fast-deep-equal "^1.0.0" + json-schema-traverse "^0.3.0" + json-stable-stringify "^1.0.1" + +ajv@^5.1.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.2.tgz#47c68d69e86f5d953103b0074a9430dc63da5e39" + dependencies: + co "^4.6.0" + fast-deep-equal "^1.0.0" + json-schema-traverse "^0.3.0" + json-stable-stringify "^1.0.1" + +ajv@^6.0.1, ajv@^6.5.3: + version "6.5.3" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.5.3.tgz#71a569d189ecf4f4f321224fecb166f071dd90f9" + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^6.1.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.5.0.tgz#4c8affdf80887d8f132c9c52ab8a2dc4d0b7b24c" + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.3.0" + uri-js "^4.2.1" + +align-text@^0.1.1, align-text@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" + dependencies: + kind-of "^3.0.2" + longest "^1.0.1" + repeat-string "^1.5.2" + +alphanum-sort@^1.0.1, alphanum-sort@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" + +amdefine@>=0.0.4: + version "1.0.1" + resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" + +ansi-colors@1.1.0, ansi-colors@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-1.1.0.tgz#6374b4dd5d4718ff3ce27a671a3b1cad077132a9" + dependencies: + ansi-wrap "^0.1.0" + +ansi-cyan@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ansi-cyan/-/ansi-cyan-0.1.1.tgz#538ae528af8982f28ae30d86f2f17456d2609873" + dependencies: + ansi-wrap "0.1.0" + +ansi-escapes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.0.0.tgz#ec3e8b4e9f8064fc02c3ac9b65f1c275bda8ef92" + +ansi-gray@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ansi-gray/-/ansi-gray-0.1.1.tgz#2962cf54ec9792c48510a3deb524436861ef7251" + dependencies: + ansi-wrap "0.1.0" + +ansi-red@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ansi-red/-/ansi-red-0.1.1.tgz#8c638f9d1080800a353c9c28c8a81ca4705d946c" + dependencies: + ansi-wrap "0.1.0" + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + +ansi-styles@^2.0.1, ansi-styles@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + dependencies: + color-convert "^1.9.0" + +ansi-wrap@0.1.0, ansi-wrap@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf" + +anymatch@^1.3.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a" + dependencies: + micromatch "^2.1.5" + normalize-path "^2.0.0" + +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + +aproba@^1.0.3, aproba@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + +archy@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" + +are-we-there-yet@~1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.4.tgz#bb5dca382bb94f05e15194373d16fd3ba1ca110d" + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.6" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + dependencies: + sprintf-js "~1.0.2" + +arr-diff@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-1.1.0.tgz#687c32758163588fef7de7b36fabe495eb1a399a" + dependencies: + arr-flatten "^1.0.1" + array-slice "^0.2.3" + +arr-diff@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf" + dependencies: + arr-flatten "^1.0.1" + +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + +arr-flatten@^1.0.1, arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + +arr-union@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-2.1.0.tgz#20f9eab5ec70f5c7d215b1077b1c39161d292c7d" + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + +array-differ@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-1.0.0.tgz#eff52e3758249d33be402b8bb8e564bb2b5d4031" + +array-each@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/array-each/-/array-each-1.0.1.tgz#a794af0c05ab1752846ee753a1f211a05ba0c44f" + +array-find-index@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1" + +array-includes@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.0.3.tgz#184b48f62d92d7452bb31b323165c7f8bd02266d" + dependencies: + define-properties "^1.1.2" + es-abstract "^1.7.0" + +array-slice@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-0.2.3.tgz#dd3cfb80ed7973a75117cdac69b0b99ec86186f5" + +array-slice@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-slice/-/array-slice-1.0.0.tgz#e73034f00dcc1f40876008fd20feae77bd4b7c2f" + +array-union@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39" + dependencies: + array-uniq "^1.0.1" + +array-uniq@^1.0.1, array-uniq@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/array-uniq/-/array-uniq-1.0.3.tgz#af6ac877a25cc7f74e058894753858dfdb24fdb6" + +array-unique@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53" + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + +arrify@^1.0.0, arrify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" + +asap@^2.0.6, asap@~2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + +asn1.js@^4.0.0: + version "4.9.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.9.1.tgz#48ba240b45a9280e94748990ba597d216617fd40" + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + +asn1@~0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + +assert@^1.1.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/assert/-/assert-1.4.1.tgz#99912d591836b5a6f5b345c0f07eefc08fc65d91" + dependencies: + util "0.10.3" + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + +async-each@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" + +async@^2.1.2, async@^2.4.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/async/-/async-2.5.0.tgz#843190fd6b7357a0b9e1c956edddd5ec8462b54d" + dependencies: + lodash "^4.14.0" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + +atob@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + +atob@~1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/atob/-/atob-1.1.3.tgz#95f13629b12c3a51a5d215abdce2aa9f32f80773" + +autobind-decorator@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/autobind-decorator/-/autobind-decorator-2.1.0.tgz#4451240dbfeff46361c506575a63ed40f0e5bc68" + +autoprefixer@9.1.5, autoprefixer@^9.0.0: + version "9.1.5" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.1.5.tgz#8675fd8d1c0d43069f3b19a2c316f3524e4f6671" + dependencies: + browserslist "^4.1.0" + caniuse-lite "^1.0.30000884" + normalize-range "^0.1.2" + num2fraction "^1.2.2" + postcss "^7.0.2" + postcss-value-parser "^3.2.3" + +autoprefixer@^6.3.1: + version "6.3.6" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.3.6.tgz#de772e1fcda08dce0e992cecf79252d5f008e367" + dependencies: + browserslist "~1.3.1" + caniuse-db "^1.0.30000444" + normalize-range "^0.1.2" + num2fraction "^1.2.2" + postcss "^5.0.19" + postcss-value-parser "^3.2.3" + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + +aws4@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e" + +babel-code-frame@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" + dependencies: + chalk "^1.1.3" + esutils "^2.0.2" + js-tokens "^3.0.2" + +babel-core@6.26.3: + version "6.26.3" + resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.3.tgz#b2e2f09e342d0f0c88e2f02e067794125e75c207" + dependencies: + babel-code-frame "^6.26.0" + babel-generator "^6.26.0" + babel-helpers "^6.24.1" + babel-messages "^6.23.0" + babel-register "^6.26.0" + babel-runtime "^6.26.0" + babel-template "^6.26.0" + babel-traverse "^6.26.0" + babel-types "^6.26.0" + babylon "^6.18.0" + convert-source-map "^1.5.1" + debug "^2.6.9" + json5 "^0.5.1" + lodash "^4.17.4" + minimatch "^3.0.4" + path-is-absolute "^1.0.1" + private "^0.1.8" + slash "^1.0.0" + source-map "^0.5.7" + +babel-core@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.0.tgz#af32f78b31a6fcef119c87b0fd8d9753f03a0bb8" + dependencies: + babel-code-frame "^6.26.0" + babel-generator "^6.26.0" + babel-helpers "^6.24.1" + babel-messages "^6.23.0" + babel-register "^6.26.0" + babel-runtime "^6.26.0" + babel-template "^6.26.0" + babel-traverse "^6.26.0" + babel-types "^6.26.0" + babylon "^6.18.0" + convert-source-map "^1.5.0" + debug "^2.6.8" + json5 "^0.5.1" + lodash "^4.17.4" + minimatch "^3.0.4" + path-is-absolute "^1.0.1" + private "^0.1.7" + slash "^1.0.0" + source-map "^0.5.6" + +babel-eslint@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-9.0.0.tgz#7d9445f81ed9f60aff38115f838970df9f2b6220" + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@babel/types" "^7.0.0" + eslint-scope "3.7.1" + eslint-visitor-keys "^1.0.0" + +babel-generator@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.0.tgz#ac1ae20070b79f6e3ca1d3269613053774f20dc5" + dependencies: + babel-messages "^6.23.0" + babel-runtime "^6.26.0" + babel-types "^6.26.0" + detect-indent "^4.0.0" + jsesc "^1.3.0" + lodash "^4.17.4" + source-map "^0.5.6" + trim-right "^1.0.1" + +babel-helper-bindify-decorators@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-bindify-decorators/-/babel-helper-bindify-decorators-6.24.1.tgz#14c19e5f142d7b47f19a52431e52b1ccbc40a330" + dependencies: + babel-runtime "^6.22.0" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helper-builder-binary-assignment-operator-visitor@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz#cce4517ada356f4220bcae8a02c2b346f9a56664" + dependencies: + babel-helper-explode-assignable-expression "^6.24.1" + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-helper-builder-react-jsx@^6.24.1: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-helper-builder-react-jsx/-/babel-helper-builder-react-jsx-6.26.0.tgz#39ff8313b75c8b65dceff1f31d383e0ff2a408a0" + dependencies: + babel-runtime "^6.26.0" + babel-types "^6.26.0" + esutils "^2.0.2" + +babel-helper-call-delegate@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz#ece6aacddc76e41c3461f88bfc575bd0daa2df8d" + dependencies: + babel-helper-hoist-variables "^6.24.1" + babel-runtime "^6.22.0" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helper-define-map@^6.24.1: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz#a5f56dab41a25f97ecb498c7ebaca9819f95be5f" + dependencies: + babel-helper-function-name "^6.24.1" + babel-runtime "^6.26.0" + babel-types "^6.26.0" + lodash "^4.17.4" + +babel-helper-explode-assignable-expression@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz#f25b82cf7dc10433c55f70592d5746400ac22caa" + dependencies: + babel-runtime "^6.22.0" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helper-explode-class@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-explode-class/-/babel-helper-explode-class-6.24.1.tgz#7dc2a3910dee007056e1e31d640ced3d54eaa9eb" + dependencies: + babel-helper-bindify-decorators "^6.24.1" + babel-runtime "^6.22.0" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helper-function-name@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz#d3475b8c03ed98242a25b48351ab18399d3580a9" + dependencies: + babel-helper-get-function-arity "^6.24.1" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helper-get-function-arity@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz#8f7782aa93407c41d3aa50908f89b031b1b6853d" + dependencies: + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-helper-hoist-variables@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz#1ecb27689c9d25513eadbc9914a73f5408be7a76" + dependencies: + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-helper-optimise-call-expression@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz#f7a13427ba9f73f8f4fa993c54a97882d1244257" + dependencies: + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-helper-regex@^6.24.1: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz#325c59f902f82f24b74faceed0363954f6495e72" + dependencies: + babel-runtime "^6.26.0" + babel-types "^6.26.0" + lodash "^4.17.4" + +babel-helper-remap-async-to-generator@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz#5ec581827ad723fecdd381f1c928390676e4551b" + dependencies: + babel-helper-function-name "^6.24.1" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helper-replace-supers@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz#bf6dbfe43938d17369a213ca8a8bf74b6a90ab1a" + dependencies: + babel-helper-optimise-call-expression "^6.24.1" + babel-messages "^6.23.0" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-helpers@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-helpers/-/babel-helpers-6.24.1.tgz#3471de9caec388e5c850e597e58a26ddf37602b2" + dependencies: + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-loader@7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-7.1.2.tgz#f6cbe122710f1aa2af4d881c6d5b54358ca24126" + dependencies: + find-cache-dir "^1.0.0" + loader-utils "^1.0.2" + mkdirp "^0.5.1" + +babel-messages@^6.23.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-check-es2015-constants@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz#35157b101426fd2ffd3da3f75c7d1e91835bbf8a" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-syntax-async-functions@^6.8.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95" + +babel-plugin-syntax-async-generators@^6.5.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-generators/-/babel-plugin-syntax-async-generators-6.13.0.tgz#6bc963ebb16eccbae6b92b596eb7f35c342a8b9a" + +babel-plugin-syntax-class-properties@^6.8.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz#d7eb23b79a317f8543962c505b827c7d6cac27de" + +babel-plugin-syntax-decorators@^6.1.18, babel-plugin-syntax-decorators@^6.13.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-decorators/-/babel-plugin-syntax-decorators-6.13.0.tgz#312563b4dbde3cc806cee3e416cceeaddd11ac0b" + +babel-plugin-syntax-dynamic-import@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz#8d6a26229c83745a9982a441051572caa179b1da" + +babel-plugin-syntax-exponentiation-operator@^6.8.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz#9ee7e8337290da95288201a6a57f4170317830de" + +babel-plugin-syntax-flow@^6.18.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz#4c3ab20a2af26aa20cd25995c398c4eb70310c8d" + +babel-plugin-syntax-jsx@^6.3.13, babel-plugin-syntax-jsx@^6.8.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz#0af32a9a6e13ca7a3fd5069e62d7b0f58d0d8946" + +babel-plugin-syntax-object-rest-spread@^6.8.0: + version "6.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz#fd6536f2bce13836ffa3a5458c4903a597bb3bf5" + +babel-plugin-syntax-trailing-function-commas@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz#ba0360937f8d06e40180a43fe0d5616fff532cf3" + +babel-plugin-transform-async-generator-functions@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-generator-functions/-/babel-plugin-transform-async-generator-functions-6.24.1.tgz#f058900145fd3e9907a6ddf28da59f215258a5db" + dependencies: + babel-helper-remap-async-to-generator "^6.24.1" + babel-plugin-syntax-async-generators "^6.5.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-async-to-generator@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz#6536e378aff6cb1d5517ac0e40eb3e9fc8d08761" + dependencies: + babel-helper-remap-async-to-generator "^6.24.1" + babel-plugin-syntax-async-functions "^6.8.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-class-properties@6.24.1, babel-plugin-transform-class-properties@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-class-properties/-/babel-plugin-transform-class-properties-6.24.1.tgz#6a79763ea61d33d36f37b611aa9def81a81b46ac" + dependencies: + babel-helper-function-name "^6.24.1" + babel-plugin-syntax-class-properties "^6.8.0" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-plugin-transform-decorators-legacy@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-decorators-legacy/-/babel-plugin-transform-decorators-legacy-1.3.4.tgz#741b58f6c5bce9e6027e0882d9c994f04f366925" + dependencies: + babel-plugin-syntax-decorators "^6.1.18" + babel-runtime "^6.2.0" + babel-template "^6.3.0" + +babel-plugin-transform-decorators@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-decorators/-/babel-plugin-transform-decorators-6.24.1.tgz#788013d8f8c6b5222bdf7b344390dfd77569e24d" + dependencies: + babel-helper-explode-class "^6.24.1" + babel-plugin-syntax-decorators "^6.13.0" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-arrow-functions@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz#452692cb711d5f79dc7f85e440ce41b9f244d221" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-block-scoped-functions@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz#bbc51b49f964d70cb8d8e0b94e820246ce3a6141" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-block-scoping@^6.24.1: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz#d70f5299c1308d05c12f463813b0a09e73b1895f" + dependencies: + babel-runtime "^6.26.0" + babel-template "^6.26.0" + babel-traverse "^6.26.0" + babel-types "^6.26.0" + lodash "^4.17.4" + +babel-plugin-transform-es2015-classes@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz#5a4c58a50c9c9461e564b4b2a3bfabc97a2584db" + dependencies: + babel-helper-define-map "^6.24.1" + babel-helper-function-name "^6.24.1" + babel-helper-optimise-call-expression "^6.24.1" + babel-helper-replace-supers "^6.24.1" + babel-messages "^6.23.0" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-computed-properties@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz#6fe2a8d16895d5634f4cd999b6d3480a308159b3" + dependencies: + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-plugin-transform-es2015-destructuring@^6.22.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz#997bb1f1ab967f682d2b0876fe358d60e765c56d" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-duplicate-keys@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz#73eb3d310ca969e3ef9ec91c53741a6f1576423e" + dependencies: + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-for-of@^6.22.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz#f47c95b2b613df1d3ecc2fdb7573623c75248691" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-function-name@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz#834c89853bc36b1af0f3a4c5dbaa94fd8eacaa8b" + dependencies: + babel-helper-function-name "^6.24.1" + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-literals@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz#4f54a02d6cd66cf915280019a31d31925377ca2e" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-modules-amd@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz#3b3e54017239842d6d19c3011c4bd2f00a00d154" + dependencies: + babel-plugin-transform-es2015-modules-commonjs "^6.24.1" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-plugin-transform-es2015-modules-commonjs@^6.24.1: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.0.tgz#0d8394029b7dc6abe1a97ef181e00758dd2e5d8a" + dependencies: + babel-plugin-transform-strict-mode "^6.24.1" + babel-runtime "^6.26.0" + babel-template "^6.26.0" + babel-types "^6.26.0" + +babel-plugin-transform-es2015-modules-systemjs@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz#ff89a142b9119a906195f5f106ecf305d9407d23" + dependencies: + babel-helper-hoist-variables "^6.24.1" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-plugin-transform-es2015-modules-umd@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz#ac997e6285cd18ed6176adb607d602344ad38468" + dependencies: + babel-plugin-transform-es2015-modules-amd "^6.24.1" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + +babel-plugin-transform-es2015-object-super@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz#24cef69ae21cb83a7f8603dad021f572eb278f8d" + dependencies: + babel-helper-replace-supers "^6.24.1" + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-parameters@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz#57ac351ab49caf14a97cd13b09f66fdf0a625f2b" + dependencies: + babel-helper-call-delegate "^6.24.1" + babel-helper-get-function-arity "^6.24.1" + babel-runtime "^6.22.0" + babel-template "^6.24.1" + babel-traverse "^6.24.1" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-shorthand-properties@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz#24f875d6721c87661bbd99a4622e51f14de38aa0" + dependencies: + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-spread@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz#d6d68a99f89aedc4536c81a542e8dd9f1746f8d1" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-sticky-regex@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz#00c1cdb1aca71112cdf0cf6126c2ed6b457ccdbc" + dependencies: + babel-helper-regex "^6.24.1" + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-plugin-transform-es2015-template-literals@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz#a84b3450f7e9f8f1f6839d6d687da84bb1236d8d" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-typeof-symbol@^6.22.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz#dec09f1cddff94b52ac73d505c84df59dcceb372" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-es2015-unicode-regex@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz#d38b12f42ea7323f729387f18a7c5ae1faeb35e9" + dependencies: + babel-helper-regex "^6.24.1" + babel-runtime "^6.22.0" + regexpu-core "^2.0.0" + +babel-plugin-transform-exponentiation-operator@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz#2ab0c9c7f3098fa48907772bb813fe41e8de3a0e" + dependencies: + babel-helper-builder-binary-assignment-operator-visitor "^6.24.1" + babel-plugin-syntax-exponentiation-operator "^6.8.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-flow-strip-types@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz#84cb672935d43714fdc32bce84568d87441cf7cf" + dependencies: + babel-plugin-syntax-flow "^6.18.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-object-rest-spread@^6.22.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz#0f36692d50fef6b7e2d4b3ac1478137a963b7b06" + dependencies: + babel-plugin-syntax-object-rest-spread "^6.8.0" + babel-runtime "^6.26.0" + +babel-plugin-transform-react-display-name@^6.23.0: + version "6.25.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.25.0.tgz#67e2bf1f1e9c93ab08db96792e05392bf2cc28d1" + dependencies: + babel-runtime "^6.22.0" + +babel-plugin-transform-react-jsx-self@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-jsx-self/-/babel-plugin-transform-react-jsx-self-6.22.0.tgz#df6d80a9da2612a121e6ddd7558bcbecf06e636e" + dependencies: + babel-plugin-syntax-jsx "^6.8.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-react-jsx-source@^6.22.0: + version "6.22.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-jsx-source/-/babel-plugin-transform-react-jsx-source-6.22.0.tgz#66ac12153f5cd2d17b3c19268f4bf0197f44ecd6" + dependencies: + babel-plugin-syntax-jsx "^6.8.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-react-jsx@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-react-jsx/-/babel-plugin-transform-react-jsx-6.24.1.tgz#840a028e7df460dfc3a2d29f0c0d91f6376e66a3" + dependencies: + babel-helper-builder-react-jsx "^6.24.1" + babel-plugin-syntax-jsx "^6.8.0" + babel-runtime "^6.22.0" + +babel-plugin-transform-regenerator@^6.24.1: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz#e0703696fbde27f0a3efcacf8b4dca2f7b3a8f2f" + dependencies: + regenerator-transform "^0.10.0" + +babel-plugin-transform-strict-mode@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz#d5faf7aa578a65bbe591cf5edae04a0c67020758" + dependencies: + babel-runtime "^6.22.0" + babel-types "^6.24.1" + +babel-preset-decorators-legacy@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/babel-preset-decorators-legacy/-/babel-preset-decorators-legacy-1.0.0.tgz#87772ec5303c5a3b748ce450c8400975662d1731" + dependencies: + babel-plugin-transform-decorators-legacy "^1.3.4" + +babel-preset-es2015@6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-preset-es2015/-/babel-preset-es2015-6.24.1.tgz#d44050d6bc2c9feea702aaf38d727a0210538939" + dependencies: + babel-plugin-check-es2015-constants "^6.22.0" + babel-plugin-transform-es2015-arrow-functions "^6.22.0" + babel-plugin-transform-es2015-block-scoped-functions "^6.22.0" + babel-plugin-transform-es2015-block-scoping "^6.24.1" + babel-plugin-transform-es2015-classes "^6.24.1" + babel-plugin-transform-es2015-computed-properties "^6.24.1" + babel-plugin-transform-es2015-destructuring "^6.22.0" + babel-plugin-transform-es2015-duplicate-keys "^6.24.1" + babel-plugin-transform-es2015-for-of "^6.22.0" + babel-plugin-transform-es2015-function-name "^6.24.1" + babel-plugin-transform-es2015-literals "^6.22.0" + babel-plugin-transform-es2015-modules-amd "^6.24.1" + babel-plugin-transform-es2015-modules-commonjs "^6.24.1" + babel-plugin-transform-es2015-modules-systemjs "^6.24.1" + babel-plugin-transform-es2015-modules-umd "^6.24.1" + babel-plugin-transform-es2015-object-super "^6.24.1" + babel-plugin-transform-es2015-parameters "^6.24.1" + babel-plugin-transform-es2015-shorthand-properties "^6.24.1" + babel-plugin-transform-es2015-spread "^6.22.0" + babel-plugin-transform-es2015-sticky-regex "^6.24.1" + babel-plugin-transform-es2015-template-literals "^6.22.0" + babel-plugin-transform-es2015-typeof-symbol "^6.22.0" + babel-plugin-transform-es2015-unicode-regex "^6.24.1" + babel-plugin-transform-regenerator "^6.24.1" + +babel-preset-flow@^6.23.0: + version "6.23.0" + resolved "https://registry.yarnpkg.com/babel-preset-flow/-/babel-preset-flow-6.23.0.tgz#e71218887085ae9a24b5be4169affb599816c49d" + dependencies: + babel-plugin-transform-flow-strip-types "^6.22.0" + +babel-preset-react@6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-preset-react/-/babel-preset-react-6.24.1.tgz#ba69dfaea45fc3ec639b6a4ecea6e17702c91380" + dependencies: + babel-plugin-syntax-jsx "^6.3.13" + babel-plugin-transform-react-display-name "^6.23.0" + babel-plugin-transform-react-jsx "^6.24.1" + babel-plugin-transform-react-jsx-self "^6.22.0" + babel-plugin-transform-react-jsx-source "^6.22.0" + babel-preset-flow "^6.23.0" + +babel-preset-stage-2@6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-preset-stage-2/-/babel-preset-stage-2-6.24.1.tgz#d9e2960fb3d71187f0e64eec62bc07767219bdc1" + dependencies: + babel-plugin-syntax-dynamic-import "^6.18.0" + babel-plugin-transform-class-properties "^6.24.1" + babel-plugin-transform-decorators "^6.24.1" + babel-preset-stage-3 "^6.24.1" + +babel-preset-stage-3@^6.24.1: + version "6.24.1" + resolved "https://registry.yarnpkg.com/babel-preset-stage-3/-/babel-preset-stage-3-6.24.1.tgz#836ada0a9e7a7fa37cb138fb9326f87934a48395" + dependencies: + babel-plugin-syntax-trailing-function-commas "^6.22.0" + babel-plugin-transform-async-generator-functions "^6.24.1" + babel-plugin-transform-async-to-generator "^6.24.1" + babel-plugin-transform-exponentiation-operator "^6.24.1" + babel-plugin-transform-object-rest-spread "^6.22.0" + +babel-register@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.26.0.tgz#6ed021173e2fcb486d7acb45c6009a856f647071" + dependencies: + babel-core "^6.26.0" + babel-runtime "^6.26.0" + core-js "^2.5.0" + home-or-tmp "^2.0.0" + lodash "^4.17.4" + mkdirp "^0.5.1" + source-map-support "^0.4.15" + +babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe" + dependencies: + core-js "^2.4.0" + regenerator-runtime "^0.11.0" + +babel-template@^6.24.1, babel-template@^6.26.0, babel-template@^6.3.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.26.0.tgz#de03e2d16396b069f46dd9fff8521fb1a0e35e02" + dependencies: + babel-runtime "^6.26.0" + babel-traverse "^6.26.0" + babel-types "^6.26.0" + babylon "^6.18.0" + lodash "^4.17.4" + +babel-traverse@^6.24.1, babel-traverse@^6.26.0, babel-traverse@^6.4.5, babel-traverse@^6.9.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee" + dependencies: + babel-code-frame "^6.26.0" + babel-messages "^6.23.0" + babel-runtime "^6.26.0" + babel-types "^6.26.0" + babylon "^6.18.0" + debug "^2.6.8" + globals "^9.18.0" + invariant "^2.2.2" + lodash "^4.17.4" + +babel-types@^6.19.0, babel-types@^6.24.1, babel-types@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497" + dependencies: + babel-runtime "^6.26.0" + esutils "^2.0.2" + lodash "^4.17.4" + to-fast-properties "^1.0.3" + +babylon@^6.18.0, babylon@^6.8.0: + version "6.18.0" + resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3" + +bail@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.3.tgz#63cfb9ddbac829b02a3128cd53224be78e6c21a3" + +balanced-match@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838" + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + +base64-js@^1.0.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.1.tgz#a91947da1f4a516ea38e5b4ec0ec3773675e0886" + +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + +bcrypt-pbkdf@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz#63bc5dcb61331b92bc05fd528953c33462a06f8d" + dependencies: + tweetnacl "^0.14.3" + +beeper@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/beeper/-/beeper-1.1.1.tgz#e6d5ea8c5dad001304a70b22638447f69cb2f809" + +big.js@^3.1.3: + version "3.2.0" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" + +binary-extensions@^1.0.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.10.0.tgz#9aeb9a6c5e88638aad171e167f5900abe24835d0" + +bindings@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.3.0.tgz#b346f6ecf6a95f5a815c5839fc7cdb22502f1ed7" + +bl@^1.1.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.1.tgz#cac328f7bee45730d404b692203fcb590e172d5e" + dependencies: + readable-stream "^2.0.5" + +block-stream@*: + version "0.0.9" + resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" + dependencies: + inherits "~2.0.0" + +bluebird@^2.9.34: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1" + +bluebird@^3.1.1: + version "3.5.0" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c" + +bluebird@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" + +bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: + version "4.11.8" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" + +body@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/body/-/body-5.1.0.tgz#e4ba0ce410a46936323367609ecb4e6553125069" + dependencies: + continuable-cache "^0.3.1" + error "^7.0.0" + raw-body "~1.1.0" + safe-json-parse "~1.0.1" + +boom@4.x.x: + version "4.3.1" + resolved "https://registry.yarnpkg.com/boom/-/boom-4.3.1.tgz#4f8a3005cb4a7e3889f749030fd25b96e01d2e31" + dependencies: + hoek "4.x.x" + +boom@5.x.x: + version "5.2.0" + resolved "https://registry.yarnpkg.com/boom/-/boom-5.2.0.tgz#5dd9da6ee3a5f302077436290cb717d3f4a54e02" + dependencies: + hoek "4.x.x" + +brace-expansion@^1.0.0, brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^1.8.2: + version "1.8.5" + resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" + dependencies: + expand-range "^1.8.1" + preserve "^0.2.0" + repeat-element "^1.1.2" + +braces@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.0.tgz#a46941cb5fb492156b3d6a656e06c35364e3e66e" + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + define-property "^1.0.0" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +braces@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +brorand@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" + +browserify-aes@^1.0.0, browserify-aes@^1.0.4: + version "1.0.8" + resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.0.8.tgz#c8fa3b1b7585bb7ba77c5560b60996ddec6d5309" + dependencies: + buffer-xor "^1.0.3" + cipher-base "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.3" + inherits "^2.0.1" + safe-buffer "^5.0.1" + +browserify-cipher@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.0.tgz#9988244874bf5ed4e28da95666dcd66ac8fc363a" + dependencies: + browserify-aes "^1.0.4" + browserify-des "^1.0.0" + evp_bytestokey "^1.0.0" + +browserify-des@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.0.tgz#daa277717470922ed2fe18594118a175439721dd" + dependencies: + cipher-base "^1.0.1" + des.js "^1.0.0" + inherits "^2.0.1" + +browserify-rsa@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" + dependencies: + bn.js "^4.1.0" + randombytes "^2.0.1" + +browserify-sign@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.4.tgz#aa4eb68e5d7b658baa6bf6a57e630cbd7a93d298" + dependencies: + bn.js "^4.1.1" + browserify-rsa "^4.0.0" + create-hash "^1.1.0" + create-hmac "^1.1.2" + elliptic "^6.0.0" + inherits "^2.0.1" + parse-asn1 "^5.0.0" + +browserify-zlib@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d" + dependencies: + pako "~0.2.0" + +browserslist@^1.3.6, browserslist@^1.5.2: + version "1.7.7" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-1.7.7.tgz#0bd76704258be829b2398bb50e4b62d1a166b0b9" + dependencies: + caniuse-db "^1.0.30000639" + electron-to-chromium "^1.2.7" + +browserslist@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.1.1.tgz#328eb4ff1215b12df6589e9ab82f8adaa4fc8cd6" + dependencies: + caniuse-lite "^1.0.30000884" + electron-to-chromium "^1.3.62" + node-releases "^1.0.0-alpha.11" + +browserslist@~1.3.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-1.3.6.tgz#952ff48d56463d3b538f85ef2f8eaddfd284b133" + dependencies: + caniuse-db "^1.0.30000525" + +bser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719" + dependencies: + node-int64 "^0.4.0" + +buffer-from@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.0.tgz#87fcaa3a298358e0ade6e442cfce840740d1ad04" + +buffer-xor@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" + +buffer@^4.3.0: + version "4.9.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298" + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + isarray "^1.0.0" + +bufferstreams@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/bufferstreams/-/bufferstreams-1.0.1.tgz#cfb1ad9568d3ba3cfe935ba9abdd952de88aab2a" + dependencies: + readable-stream "^1.0.33" + +builtin-modules@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" + +builtin-status-codes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" + +bytes@1: + version "1.0.0" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-1.0.0.tgz#3569ede8ba34315fab99c3e92cb04c7220de1fa8" + +cacache@^10.0.4: + version "10.0.4" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-10.0.4.tgz#6452367999eff9d4188aefd9a14e9d7c6a263460" + dependencies: + bluebird "^3.5.1" + chownr "^1.0.1" + glob "^7.1.2" + graceful-fs "^4.1.11" + lru-cache "^4.1.1" + mississippi "^2.0.0" + mkdirp "^0.5.1" + move-concurrently "^1.0.1" + promise-inflight "^1.0.1" + rimraf "^2.6.2" + ssri "^5.2.4" + unique-filename "^1.1.0" + y18n "^4.0.0" + +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + +call-me-maybe@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" + +caller-path@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f" + dependencies: + callsites "^0.2.0" + +callsites@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca" + +camelcase-css@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-1.0.1.tgz#157c4238265f5cf94a1dffde86446552cbf3f705" + +camelcase-keys@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-4.2.0.tgz#a2aa5fb1af688758259c32c141426d78923b9b77" + dependencies: + camelcase "^4.1.0" + map-obj "^2.0.0" + quick-lru "^1.0.0" + +camelcase@^1.0.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39" + +camelcase@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" + +caniuse-api@^1.5.2: + version "1.6.1" + resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-1.6.1.tgz#b534e7c734c4f81ec5fbe8aca2ad24354b962c6c" + dependencies: + browserslist "^1.3.6" + caniuse-db "^1.0.30000529" + lodash.memoize "^4.1.2" + lodash.uniq "^4.5.0" + +caniuse-db@^1.0.30000444, caniuse-db@^1.0.30000525, caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000639: + version "1.0.30000733" + resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000733.tgz#3a625bc41c7a9f99d59d64552857dd1af0edd9d4" + +caniuse-lite@^1.0.30000884: + version "1.0.30000885" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000885.tgz#e889e9f8e7e50e769f2a49634c932b8aee622984" + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + +ccount@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.0.3.tgz#f1cec43f332e2ea5a569fd46f9f5bde4e6102aff" + +center-align@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad" + dependencies: + align-text "^0.1.3" + lazy-cache "^1.0.3" + +chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: + version "1.1.3" + resolved "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" + dependencies: + ansi-styles "^2.2.1" + escape-string-regexp "^1.0.2" + has-ansi "^2.0.0" + strip-ansi "^3.0.0" + supports-color "^2.0.0" + +chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e" + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +change-emitter@^0.1.2: + version "0.1.6" + resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" + +character-entities-html4@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-1.1.2.tgz#c44fdde3ce66b52e8d321d6c1bf46101f0150610" + +character-entities-legacy@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.2.tgz#7c6defb81648498222c9855309953d05f4d63a9c" + +character-entities@^1.0.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.2.tgz#58c8f371c0774ef0ba9b2aca5f00d8f100e6e363" + +character-reference-invalid@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.2.tgz#21e421ad3d84055952dab4a43a04e73cd425d3ed" + +chardet@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" + +charenc@~0.0.1: + version "0.0.2" + resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" + +chokidar@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468" + dependencies: + anymatch "^1.3.0" + async-each "^1.0.0" + glob-parent "^2.0.0" + inherits "^2.0.1" + is-binary-path "^1.0.0" + is-glob "^2.0.0" + path-is-absolute "^1.0.0" + readdirp "^2.0.0" + optionalDependencies: + fsevents "^1.0.0" + +chokidar@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.0.tgz#6686313c541d3274b2a5c01233342037948c911b" + dependencies: + anymatch "^2.0.0" + async-each "^1.0.0" + braces "^2.3.0" + glob-parent "^3.1.0" + inherits "^2.0.1" + is-binary-path "^1.0.0" + is-glob "^4.0.0" + normalize-path "^2.1.1" + path-is-absolute "^1.0.0" + readdirp "^2.0.0" + optionalDependencies: + fsevents "^1.0.0" + +chownr@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181" + +cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +circular-json@^0.3.1: + version "0.3.3" + resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66" + +clap@^1.0.9: + version "1.2.2" + resolved "https://registry.yarnpkg.com/clap/-/clap-1.2.2.tgz#683f6f93a320794d129386d74b2a1d2d66fede7e" + dependencies: + chalk "^1.1.3" + +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +classnames@2.2.6: + version "2.2.6" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" + +classnames@^2.2.0, classnames@^2.2.3: + version "2.2.5" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d" + +clean-css@4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.1.tgz#2d411ef76b8569b6d0c84068dabe85b0aa5e5c17" + dependencies: + source-map "~0.6.0" + +cli-cursor@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" + dependencies: + restore-cursor "^2.0.0" + +cli-width@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" + +clipboard@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.1.tgz#a12481e1c13d8a50f5f036b0560fe5d16d74e46a" + dependencies: + good-listener "^1.2.2" + select "^1.1.2" + tiny-emitter "^2.0.0" + +cliui@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" + dependencies: + center-align "^0.1.1" + right-align "^0.1.1" + wordwrap "0.0.2" + +cliui@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + wrap-ansi "^2.0.0" + +clone-buffer@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" + +clone-regexp@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/clone-regexp/-/clone-regexp-1.0.1.tgz#051805cd33173375d82118fc0918606da39fd60f" + dependencies: + is-regexp "^1.0.0" + is-supported-regexp-flag "^1.0.0" + +clone-stats@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1" + +clone-stats@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680" + +clone@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/clone/-/clone-0.2.0.tgz#c6126a90ad4f72dbf5acdb243cc37724fe93fc1f" + +clone@^1.0.0, clone@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.2.tgz#260b7a99ebb1edfe247538175f783243cb19d149" + +clone@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.1.tgz#d217d1e961118e3ac9a4b8bba3285553bf647cdb" + +cloneable-readable@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.0.0.tgz#a6290d413f217a61232f95e458ff38418cfb0117" + dependencies: + inherits "^2.0.1" + process-nextick-args "^1.0.6" + through2 "^2.0.1" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + +coa@~1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/coa/-/coa-1.0.4.tgz#a9ef153660d6a86a8bdec0289a5c684d217432fd" + dependencies: + q "^1.1.2" + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + +collapse-white-space@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.4.tgz#ce05cf49e54c3277ae573036a26851ba430a0091" + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + +color-convert@^1.3.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a" + dependencies: + color-name "^1.1.1" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + dependencies: + color-name "1.1.3" + +color-name@1.1.3, color-name@^1.0.0, color-name@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + +color-string@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-0.3.0.tgz#27d46fb67025c5c2fa25993bfbf579e47841b991" + dependencies: + color-name "^1.0.0" + +color-support@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + +color@^0.11.0: + version "0.11.4" + resolved "https://registry.yarnpkg.com/color/-/color-0.11.4.tgz#6d7b5c74fb65e841cd48792ad1ed5e07b904d764" + dependencies: + clone "^1.0.2" + color-convert "^1.3.0" + color-string "^0.3.0" + +colormin@^1.0.5: + version "1.1.2" + resolved "https://registry.yarnpkg.com/colormin/-/colormin-1.1.2.tgz#ea2f7420a72b96881a38aae59ec124a6f7298133" + dependencies: + color "^0.11.0" + css-color-names "0.0.4" + has "^1.0.1" + +colors@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" + +combined-stream@^1.0.5, combined-stream@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009" + dependencies: + delayed-stream "~1.0.0" + +commander@^2.2.0, commander@^2.8.1: + version "2.11.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.11.0.tgz#157152fd1e7a6c8d98a5b715cf376df928004563" + +commander@~2.13.0: + version "2.13.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c" + +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + +component-emitter@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + +concat-stream@^1.5.0: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +concat-with-sourcemaps@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/concat-with-sourcemaps/-/concat-with-sourcemaps-1.0.4.tgz#f55b3be2aeb47601b10a2d5259ccfb70fd2f1dd6" + dependencies: + source-map "^0.5.1" + +console-browserify@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10" + dependencies: + date-now "^0.1.4" + +console-control-strings@^1.0.0, console-control-strings@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + +consolidate@^0.14.1: + version "0.14.5" + resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.14.5.tgz#5a25047bc76f73072667c8cb52c989888f494c63" + dependencies: + bluebird "^3.1.1" + +constants-browserify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" + +continuable-cache@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/continuable-cache/-/continuable-cache-0.3.1.tgz#bd727a7faed77e71ff3985ac93351a912733ad0f" + +convert-source-map@1.X, convert-source-map@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.0.tgz#9acd70851c6d5dfdd93d9282e5edf94a03ff46b5" + +convert-source-map@^1.1.0, convert-source-map@^1.5.1: + version "1.6.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" + dependencies: + safe-buffer "~5.1.1" + +copy-concurrently@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0" + dependencies: + aproba "^1.1.1" + fs-write-stream-atomic "^1.0.8" + iferr "^0.1.5" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.0" + +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + +core-js@^1.0.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" + +core-js@^2.4.0, core-js@^2.5.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.1.tgz#ae6874dc66937789b80754ff5428df66819ca50b" + +core-util-is@1.0.2, core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + +cosmiconfig@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-4.0.0.tgz#760391549580bbd2df1e562bc177b13c290972dc" + dependencies: + is-directory "^0.3.1" + js-yaml "^3.9.0" + parse-json "^4.0.0" + require-from-string "^2.0.1" + +cosmiconfig@^5.0.0: + version "5.0.6" + resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.0.6.tgz#dca6cf680a0bd03589aff684700858c81abeeb39" + dependencies: + is-directory "^0.3.1" + js-yaml "^3.9.0" + parse-json "^4.0.0" + +create-ecdh@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.0.tgz#888c723596cdf7612f6498233eebd7a35301737d" + dependencies: + bn.js "^4.1.0" + elliptic "^6.0.0" + +create-hash@^1.1.0, create-hash@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.1.3.tgz#606042ac8b9262750f483caddab0f5819172d8fd" + dependencies: + cipher-base "^1.0.1" + inherits "^2.0.1" + ripemd160 "^2.0.0" + sha.js "^2.4.0" + +create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: + version "1.1.6" + resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.6.tgz#acb9e221a4e17bdb076e90657c42b93e3726cf06" + dependencies: + cipher-base "^1.0.3" + create-hash "^1.1.0" + inherits "^2.0.1" + ripemd160 "^2.0.0" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +create-react-class@15.6.3: + version "15.6.3" + resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.6.3.tgz#2d73237fb3f970ae6ebe011a9e66f46dbca80036" + dependencies: + fbjs "^0.8.9" + loose-envify "^1.3.1" + object-assign "^4.1.1" + +cross-spawn@^5.0.1: + version "5.1.0" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" + dependencies: + lru-cache "^4.0.1" + shebang-command "^1.2.0" + which "^1.2.9" + +cross-spawn@^6.0.5: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +crypt@~0.0.1: + version "0.0.2" + resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" + +cryptiles@3.x.x: + version "3.1.2" + resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-3.1.2.tgz#a89fbb220f5ce25ec56e8c4aa8a4fd7b5b0d29fe" + dependencies: + boom "5.x.x" + +crypto-browserify@^3.11.0: + version "3.11.1" + resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.11.1.tgz#948945efc6757a400d6e5e5af47194d10064279f" + dependencies: + browserify-cipher "^1.0.0" + browserify-sign "^4.0.0" + create-ecdh "^4.0.0" + create-hash "^1.1.0" + create-hmac "^1.1.0" + diffie-hellman "^5.0.0" + inherits "^2.0.1" + pbkdf2 "^3.0.3" + public-encrypt "^4.0.0" + randombytes "^2.0.0" + +css-color-names@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" + +css-loader@0.28.9: + version "0.28.9" + resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-0.28.9.tgz#68064b85f4e271d7ce4c48a58300928e535d1c95" + dependencies: + babel-code-frame "^6.26.0" + css-selector-tokenizer "^0.7.0" + cssnano "^3.10.0" + icss-utils "^2.1.0" + loader-utils "^1.0.2" + lodash.camelcase "^4.3.0" + object-assign "^4.1.1" + postcss "^5.0.6" + postcss-modules-extract-imports "^1.2.0" + postcss-modules-local-by-default "^1.2.0" + postcss-modules-scope "^1.1.0" + postcss-modules-values "^1.3.0" + postcss-value-parser "^3.3.0" + source-list-map "^2.0.0" + +css-selector-tokenizer@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.7.0.tgz#e6988474ae8c953477bf5e7efecfceccd9cf4c86" + dependencies: + cssesc "^0.1.0" + fastparse "^1.1.1" + regexpu-core "^1.0.0" + +css@2.X, css@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/css/-/css-2.2.1.tgz#73a4c81de85db664d4ee674f7d47085e3b2d55dc" + dependencies: + inherits "^2.0.1" + source-map "^0.1.38" + source-map-resolve "^0.3.0" + urix "^0.1.0" + +cssesc@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4" + +cssnano@^3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-3.10.0.tgz#4f38f6cea2b9b17fa01490f23f1dc68ea65c1c38" + dependencies: + autoprefixer "^6.3.1" + decamelize "^1.1.2" + defined "^1.0.0" + has "^1.0.1" + object-assign "^4.0.1" + postcss "^5.0.14" + postcss-calc "^5.2.0" + postcss-colormin "^2.1.8" + postcss-convert-values "^2.3.4" + postcss-discard-comments "^2.0.4" + postcss-discard-duplicates "^2.0.1" + postcss-discard-empty "^2.0.1" + postcss-discard-overridden "^0.1.1" + postcss-discard-unused "^2.2.1" + postcss-filter-plugins "^2.0.0" + postcss-merge-idents "^2.1.5" + postcss-merge-longhand "^2.0.1" + postcss-merge-rules "^2.0.3" + postcss-minify-font-values "^1.0.2" + postcss-minify-gradients "^1.0.1" + postcss-minify-params "^1.0.4" + postcss-minify-selectors "^2.0.4" + postcss-normalize-charset "^1.1.0" + postcss-normalize-url "^3.0.7" + postcss-ordered-values "^2.1.0" + postcss-reduce-idents "^2.2.2" + postcss-reduce-initial "^1.0.0" + postcss-reduce-transforms "^1.0.3" + postcss-svgo "^2.1.1" + postcss-unique-selectors "^2.0.2" + postcss-value-parser "^3.2.3" + postcss-zindex "^2.0.1" + +csso@~2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/csso/-/csso-2.3.2.tgz#ddd52c587033f49e94b71fc55569f252e8ff5f85" + dependencies: + clap "^1.0.9" + source-map "^0.5.3" + +currently-unhandled@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" + dependencies: + array-find-index "^1.0.1" + +cyclist@~0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" + +d@1: + version "1.0.0" + resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f" + dependencies: + es5-ext "^0.10.9" + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + dependencies: + assert-plus "^1.0.0" + +date-now@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b" + +dateformat@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-2.0.0.tgz#2743e3abb5c3fc2462e527dca445e04e9f4dee17" + +debug-fabulous@1.X: + version "1.0.0" + resolved "https://registry.yarnpkg.com/debug-fabulous/-/debug-fabulous-1.0.0.tgz#57f6648646097b1b0849dcda0017362c1ec00f8b" + dependencies: + debug "3.X" + memoizee "0.4.X" + object-assign "4.X" + +debug@3.X: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + dependencies: + ms "2.0.0" + +debug@^0.7.4: + version "0.7.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-0.7.4.tgz#06e1ea8082c2cb14e39806e22e2f6f757f92af39" + +debug@^2.1.3, debug@^2.6.8: + version "2.6.8" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.8.tgz#e731531ca2ede27d188222427da17821d68ff4fc" + dependencies: + ms "2.0.0" + +debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + dependencies: + ms "2.0.0" + +debug@^3.0.0, debug@^3.1.0: + version "3.2.5" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.5.tgz#c2418fbfd7a29f4d4f70ff4cea604d4b64c46407" + dependencies: + ms "^2.1.1" + +decamelize-keys@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9" + dependencies: + decamelize "^1.1.0" + map-obj "^1.0.0" + +decamelize@^1.0.0, decamelize@^1.1.0, decamelize@^1.1.1, decamelize@^1.1.2: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + +deep-equal@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" + +deep-extend@~0.4.0: + version "0.4.2" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f" + +deep-is@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" + +defaults@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d" + dependencies: + clone "^1.0.2" + +define-properties@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.2.tgz#83a73f2fea569898fb737193c8f873caf6d45c94" + dependencies: + foreach "^2.0.5" + object-keys "^1.0.8" + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + +defined@^1.0.0, defined@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" + +del@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5" + dependencies: + globby "^6.1.0" + is-path-cwd "^1.0.0" + is-path-in-cwd "^1.0.0" + p-map "^1.1.1" + pify "^3.0.0" + rimraf "^2.2.8" + +del@^2.0.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/del/-/del-2.2.2.tgz#c12c981d067846c84bcaf862cff930d907ffd1a8" + dependencies: + globby "^5.0.0" + is-path-cwd "^1.0.0" + is-path-in-cwd "^1.0.0" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + rimraf "^2.2.8" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + +delegate@^3.1.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + +deprecated@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/deprecated/-/deprecated-0.0.1.tgz#f9c9af5464afa1e7a971458a8bdef2aa94d5bb19" + +des.js@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc" + dependencies: + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + +detect-file@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-0.1.0.tgz#4935dedfd9488648e006b0129566e9386711ea63" + dependencies: + fs-exists-sync "^0.1.0" + +detect-indent@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208" + dependencies: + repeating "^2.0.0" + +detect-newline@2.X: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" + +diff@^1.3.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-1.4.0.tgz#7f28d2eb9ee7b15a97efd89ce63dcfdaa3ccbabf" + +diffie-hellman@^5.0.0: + version "5.0.2" + resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e" + dependencies: + bn.js "^4.1.0" + miller-rabin "^4.0.0" + randombytes "^2.0.0" + +dir-glob@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034" + dependencies: + arrify "^1.0.1" + path-type "^3.0.0" + +disparity@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/disparity/-/disparity-2.0.0.tgz#57ddacb47324ae5f58d2cc0da886db4ce9eeb718" + dependencies: + ansi-styles "^2.0.1" + diff "^1.3.2" + +dnd-core@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-4.0.5.tgz#3b83d138d0d5e265c73ec978dec5e1ed441dc665" + dependencies: + asap "^2.0.6" + invariant "^2.2.4" + lodash "^4.17.10" + redux "^4.0.0" + +dnode-protocol@~0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/dnode-protocol/-/dnode-protocol-0.2.2.tgz#51151d16fc3b5f84815ee0b9497a1061d0d1949d" + dependencies: + jsonify "~0.0.0" + traverse "~0.6.3" + +dnode@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/dnode/-/dnode-1.2.2.tgz#4ac3cfe26e292b3b39b8258ae7d94edc58132efa" + dependencies: + dnode-protocol "~0.2.2" + jsonify "~0.0.0" + optionalDependencies: + weak "^1.0.0" + +doctrine@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" + dependencies: + esutils "^2.0.2" + +dom-css@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/dom-css/-/dom-css-2.1.0.tgz#fdbc2d5a015d0a3e1872e11472bbd0e7b9e6a202" + dependencies: + add-px-to-style "1.0.0" + prefix-style "2.0.1" + to-camel-case "1.0.0" + +"dom-helpers@^2.4.0 || ^3.0.0": + version "3.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.2.1.tgz#3203e07fed217bd1f424b019735582fc37b2825a" + +dom-serializer@0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82" + dependencies: + domelementtype "~1.1.1" + entities "~1.1.1" + +domain-browser@^1.1.1: + version "1.1.7" + resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.1.7.tgz#867aa4b093faa05f1de08c06f4d7b21fdf8698bc" + +domelementtype@1, domelementtype@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.0.tgz#b17aed82e8ab59e52dd9c19b1756e0fc187204c2" + +domelementtype@~1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.1.3.tgz#bd28773e2642881aec51544924299c5cd822185b" + +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + dependencies: + domelementtype "1" + +domutils@^1.5.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + dependencies: + dom-serializer "0" + domelementtype "1" + +dot-prop@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57" + dependencies: + is-obj "^1.0.0" + +duplexer2@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.0.2.tgz#c614dcf67e2fb14995a91711e5a617e8a60a31db" + dependencies: + readable-stream "~1.1.9" + +duplexer@^0.1.1, duplexer@~0.1.1: + version "0.1.1" + resolved "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" + +duplexify@^3.4.2, duplexify@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.6.0.tgz#592903f5d80b38d037220541264d69a198fb3410" + dependencies: + end-of-stream "^1.0.0" + inherits "^2.0.1" + readable-stream "^2.0.0" + stream-shift "^1.0.0" + +ecc-jsbn@~0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz#0fc73a9ed5f0d53c38193398523ef7e543777505" + dependencies: + jsbn "~0.1.0" + +electron-to-chromium@^1.2.7: + version "1.3.21" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.21.tgz#a967ebdcfe8ed0083fc244d1894022a8e8113ea2" + +electron-to-chromium@^1.3.62: + version "1.3.67" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.67.tgz#5e8f3ffac89b4b0402c7e1a565be06f3a109abbc" + +element-class@0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/element-class/-/element-class-0.2.2.tgz#9d3bbd0767f9013ef8e1c8ebe722c1402a60050e" + +elliptic@^6.0.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.0.tgz#cac9af8762c85836187003c8dfe193e5e2eae5df" + dependencies: + bn.js "^4.4.0" + brorand "^1.0.1" + hash.js "^1.0.0" + hmac-drbg "^1.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.0" + +emojis-list@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" + +encoding@^0.1.11: + version "0.1.12" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" + dependencies: + iconv-lite "~0.4.13" + +end-of-stream@^1.0.0, end-of-stream@^1.1.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" + dependencies: + once "^1.4.0" + +end-of-stream@~0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-0.1.5.tgz#8e177206c3c80837d85632e8b9359dfe8b2f6eaf" + dependencies: + once "~1.3.0" + +enhanced-resolve@^3.4.0: + version "3.4.1" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e" + dependencies: + graceful-fs "^4.1.2" + memory-fs "^0.4.0" + object-assign "^4.0.1" + tapable "^0.2.7" + +entities@^1.1.1, entities@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0" + +errno@^0.1.3, errno@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.4.tgz#b896e23a9e5e8ba33871fc996abd3635fc9a1c7d" + dependencies: + prr "~0.0.0" + +errno@~0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" + dependencies: + prr "~1.0.1" + +error-ex@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.1.tgz#f855a86ce61adc4e8621c3cda21e7a7612c3a8dc" + dependencies: + is-arrayish "^0.2.1" + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + dependencies: + is-arrayish "^0.2.1" + +error@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/error/-/error-7.0.2.tgz#a5f75fff4d9926126ddac0ea5dc38e689153cb02" + dependencies: + string-template "~0.2.1" + xtend "~4.0.0" + +es-abstract@^1.5.0, es-abstract@^1.7.0: + version "1.8.2" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.8.2.tgz#25103263dc4decbda60e0c737ca32313518027ee" + dependencies: + es-to-primitive "^1.1.1" + function-bind "^1.1.1" + has "^1.0.1" + is-callable "^1.1.3" + is-regex "^1.0.4" + +es-to-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.1.1.tgz#45355248a88979034b6792e19bb81f2b7975dd0d" + dependencies: + is-callable "^1.1.1" + is-date-object "^1.0.1" + is-symbol "^1.0.1" + +es5-ext@^0.10.14, es5-ext@^0.10.30, es5-ext@^0.10.9, es5-ext@~0.10.14, es5-ext@~0.10.2: + version "0.10.30" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.30.tgz#7141a16836697dbabfaaaeee41495ce29f52c939" + dependencies: + es6-iterator "2" + es6-symbol "~3.1" + +es6-iterator@2, es6-iterator@^2.0.1, es6-iterator@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.1.tgz#8e319c9f0453bf575d374940a655920e59ca5512" + dependencies: + d "1" + es5-ext "^0.10.14" + es6-symbol "^3.1" + +es6-map@^0.1.3: + version "0.1.5" + resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.5.tgz#9136e0503dcc06a301690f0bb14ff4e364e949f0" + dependencies: + d "1" + es5-ext "~0.10.14" + es6-iterator "~2.0.1" + es6-set "~0.1.5" + es6-symbol "~3.1.1" + event-emitter "~0.3.5" + +es6-promise@^3.1.2: + version "3.3.1" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" + +es6-set@~0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1" + dependencies: + d "1" + es5-ext "~0.10.14" + es6-iterator "~2.0.1" + es6-symbol "3.1.1" + event-emitter "~0.3.5" + +es6-symbol@3.1.1, es6-symbol@^3.1, es6-symbol@^3.1.1, es6-symbol@~3.1, es6-symbol@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77" + dependencies: + d "1" + es5-ext "~0.10.14" + +es6-weak-map@^2.0.1, es6-weak-map@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.2.tgz#5e3ab32251ffd1538a1f8e5ffa1357772f92d96f" + dependencies: + d "1" + es5-ext "^0.10.14" + es6-iterator "^2.0.1" + es6-symbol "^3.1.1" + +escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + +escope@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/escope/-/escope-3.6.0.tgz#e01975e812781a163a6dadfdd80398dc64c889c3" + dependencies: + es6-map "^0.1.3" + es6-weak-map "^2.0.1" + esrecurse "^4.1.0" + estraverse "^4.1.1" + +esformatter-parser@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/esformatter-parser/-/esformatter-parser-1.0.0.tgz#0854072d0487539ed39cae38d8a5432c17ec11d3" + dependencies: + acorn-to-esprima "^2.0.8" + babel-traverse "^6.9.0" + babylon "^6.8.0" + rocambole "^0.7.0" + +esformatter@0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/esformatter/-/esformatter-0.10.0.tgz#e321ecc3d94083372cdfcf5c6f942cef6fec59d3" + dependencies: + acorn-to-esprima "^2.0.6" + babel-traverse "^6.4.5" + debug "^0.7.4" + disparity "^2.0.0" + esformatter-parser "^1.0.0" + glob "^7.0.5" + minimatch "^3.0.2" + minimist "^1.1.1" + mout ">=0.9 <2.0" + npm-run "^3.0.0" + resolve "^1.1.5" + rocambole ">=0.7 <2.0" + rocambole-indent "^2.0.4" + rocambole-linebreak "^1.0.2" + rocambole-node "~1.0" + rocambole-token "^1.1.2" + rocambole-whitespace "^1.0.0" + stdin "*" + strip-json-comments "~0.1.1" + supports-color "^1.3.1" + user-home "^2.0.0" + +eslint-plugin-filenames@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-filenames/-/eslint-plugin-filenames-1.3.2.tgz#7094f00d7aefdd6999e3ac19f72cea058e590cf7" + dependencies: + lodash.camelcase "4.3.0" + lodash.kebabcase "4.1.1" + lodash.snakecase "4.1.1" + lodash.upperfirst "4.3.1" + +eslint-plugin-react@7.11.1: + version "7.11.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.11.1.tgz#c01a7af6f17519457d6116aa94fc6d2ccad5443c" + dependencies: + array-includes "^3.0.3" + doctrine "^2.1.0" + has "^1.0.3" + jsx-ast-utils "^2.0.1" + prop-types "^15.6.2" + +eslint-scope@3.7.1: + version "3.7.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.1.tgz#3d63c3edfda02e06e01a452ad88caacc7cdcb6e8" + dependencies: + esrecurse "^4.1.0" + estraverse "^4.1.1" + +eslint-scope@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.0.tgz#50bf3071e9338bcdc43331794a0cb533f0136172" + dependencies: + esrecurse "^4.1.0" + estraverse "^4.1.1" + +eslint-utils@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.3.1.tgz#9a851ba89ee7c460346f97cf8939c7298827e512" + +eslint-visitor-keys@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d" + +eslint@5.6.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-5.6.0.tgz#b6f7806041af01f71b3f1895cbb20971ea4b6223" + dependencies: + "@babel/code-frame" "^7.0.0" + ajv "^6.5.3" + chalk "^2.1.0" + cross-spawn "^6.0.5" + debug "^3.1.0" + doctrine "^2.1.0" + eslint-scope "^4.0.0" + eslint-utils "^1.3.1" + eslint-visitor-keys "^1.0.0" + espree "^4.0.0" + esquery "^1.0.1" + esutils "^2.0.2" + file-entry-cache "^2.0.0" + functional-red-black-tree "^1.0.1" + glob "^7.1.2" + globals "^11.7.0" + ignore "^4.0.6" + imurmurhash "^0.1.4" + inquirer "^6.1.0" + is-resolvable "^1.1.0" + js-yaml "^3.12.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.3.0" + lodash "^4.17.5" + minimatch "^3.0.4" + mkdirp "^0.5.1" + natural-compare "^1.4.0" + optionator "^0.8.2" + path-is-inside "^1.0.2" + pluralize "^7.0.0" + progress "^2.0.0" + regexpp "^2.0.0" + require-uncached "^1.0.3" + semver "^5.5.1" + strip-ansi "^4.0.0" + strip-json-comments "^2.0.1" + table "^4.0.3" + text-table "^0.2.0" + +espree@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-4.0.0.tgz#253998f20a0f82db5d866385799d912a83a36634" + dependencies: + acorn "^5.6.0" + acorn-jsx "^4.1.1" + +esprima@^2.1, esprima@^2.6.0: + version "2.7.3" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + +esprint@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/esprint/-/esprint-0.4.0.tgz#f89c9bace36d90407968a8f9ceb0800ff786aab0" + dependencies: + dnode "^1.2.2" + fb-watchman "^2.0.0" + glob "^7.1.1" + sane "^1.6.0" + worker-farm "^1.3.1" + yargs "^8.0.1" + +esquery@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708" + dependencies: + estraverse "^4.0.0" + +esrecurse@^4.1.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.0.tgz#fa9568d98d3823f9a41d91e902dcab9ea6e5b163" + dependencies: + estraverse "^4.1.0" + object-assign "^4.0.1" + +estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" + +esutils@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" + +event-emitter@^0.3.5, event-emitter@~0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39" + dependencies: + d "1" + es5-ext "~0.10.14" + +event-stream@^3.3.4: + version "3.3.6" + resolved "https://registry.yarnpkg.com/event-stream/-/event-stream-3.3.6.tgz#cac1230890e07e73ec9cacd038f60a5b66173eef" + dependencies: + duplexer "^0.1.1" + flatmap-stream "^0.1.0" + from "^0.1.7" + map-stream "0.0.7" + pause-stream "^0.0.11" + split "^1.0.1" + stream-combiner "^0.2.2" + through "^2.3.8" + +events@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" + +evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" + dependencies: + md5.js "^1.3.4" + safe-buffer "^5.1.1" + +exec-sh@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.1.tgz#163b98a6e89e6b65b47c2a28d215bc1f63989c38" + dependencies: + merge "^1.1.3" + +execa@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" + dependencies: + cross-spawn "^5.0.1" + get-stream "^3.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +execall@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execall/-/execall-1.0.0.tgz#73d0904e395b3cab0658b08d09ec25307f29bb73" + dependencies: + clone-regexp "^1.0.0" + +exenv@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d" + +expand-brackets@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b" + dependencies: + is-posix-bracket "^0.1.0" + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +expand-range@^1.8.1: + version "1.8.2" + resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" + dependencies: + fill-range "^2.1.0" + +expand-tilde@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-1.2.2.tgz#0b81eba897e5a3d31d1c3d102f8f01441e559449" + dependencies: + os-homedir "^1.0.1" + +expand-tilde@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" + dependencies: + homedir-polyfill "^1.0.1" + +extend-shallow@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-1.1.4.tgz#19d6bf94dfc09d76ba711f39b872d21ff4dd9071" + dependencies: + kind-of "^1.1.0" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + +extend@~3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" + +external-editor@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.0.3.tgz#5866db29a97826dbe4bf3afd24070ead9ea43a27" + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + +extglob@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" + dependencies: + is-extglob "^1.0.0" + +extglob@^2.0.2, extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +extract-text-webpack-plugin@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extract-text-webpack-plugin/-/extract-text-webpack-plugin-3.0.2.tgz#5f043eaa02f9750a9258b78c0a6e0dc1408fb2f7" + dependencies: + async "^2.4.1" + loader-utils "^1.1.0" + schema-utils "^0.3.0" + webpack-sources "^1.0.1" + +extsprintf@1.3.0, extsprintf@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + +fancy-log@1.3.2, fancy-log@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.2.tgz#f41125e3d84f2e7d89a43d06d958c8f78be16be1" + dependencies: + ansi-gray "^0.1.1" + color-support "^1.1.3" + time-stamp "^1.0.0" + +fancy-log@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.0.tgz#45be17d02bb9917d60ccffd4995c999e6c8c9948" + dependencies: + chalk "^1.1.1" + time-stamp "^1.0.0" + +fast-deep-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff" + +fast-deep-equal@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" + +fast-glob@^2.0.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.2.tgz#71723338ac9b4e0e2fff1d6748a2a13d5ed352bf" + dependencies: + "@mrmlnc/readdir-enhanced" "^2.2.1" + "@nodelib/fs.stat" "^1.0.1" + glob-parent "^3.1.0" + is-glob "^4.0.0" + merge2 "^1.2.1" + micromatch "^3.1.10" + +fast-json-stable-stringify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" + +fast-levenshtein@~2.0.4: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + +fastparse@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.1.tgz#d1e2643b38a94d7583b479060e6c4affc94071f8" + +faye-websocket@~0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.10.0.tgz#4e492f8d04dfb6f89003507f6edbf2d501e7c6f4" + dependencies: + websocket-driver ">=0.5.1" + +fb-watchman@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58" + dependencies: + bser "^2.0.0" + +fbjs@^0.8.1, fbjs@^0.8.16: + version "0.8.17" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.18" + +fbjs@^0.8.4, fbjs@^0.8.9: + version "0.8.15" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.15.tgz#4f0695fdfcc16c37c0b07facec8cb4c4091685b9" + dependencies: + core-js "^1.0.0" + isomorphic-fetch "^2.1.1" + loose-envify "^1.0.0" + object-assign "^4.1.0" + promise "^7.1.1" + setimmediate "^1.0.5" + ua-parser-js "^0.7.9" + +figures@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" + dependencies: + escape-string-regexp "^1.0.5" + +file-entry-cache@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-2.0.0.tgz#c392990c3e684783d838b8c84a45d8a048458361" + dependencies: + flat-cache "^1.2.1" + object-assign "^4.0.1" + +file-loader@1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-1.1.6.tgz#7b9a8f2c58f00a77fddf49e940f7ac978a3ea0e8" + dependencies: + loader-utils "^1.0.2" + schema-utils "^0.3.0" + +filename-regex@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" + +filesize@3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" + +fill-range@^2.1.0: + version "2.2.4" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565" + dependencies: + is-number "^2.1.0" + isobject "^2.0.0" + randomatic "^3.0.0" + repeat-element "^1.1.2" + repeat-string "^1.5.2" + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +find-cache-dir@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-1.0.0.tgz#9288e3e9e3cc3748717d39eade17cf71fc30ee6f" + dependencies: + commondir "^1.0.1" + make-dir "^1.0.0" + pkg-dir "^2.0.0" + +find-index@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/find-index/-/find-index-0.1.1.tgz#675d358b2ca3892d795a1ab47232f8b6e2e0dde4" + +find-up@^2.0.0, find-up@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" + dependencies: + locate-path "^2.0.0" + +findup-sync@^0.4.2: + version "0.4.3" + resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.4.3.tgz#40043929e7bc60adf0b7f4827c4c6e75a0deca12" + dependencies: + detect-file "^0.1.0" + is-glob "^2.0.1" + micromatch "^2.3.7" + resolve-dir "^0.1.0" + +fined@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fined/-/fined-1.1.0.tgz#b37dc844b76a2f5e7081e884f7c0ae344f153476" + dependencies: + expand-tilde "^2.0.2" + is-plain-object "^2.0.3" + object.defaults "^1.1.0" + object.pick "^1.2.0" + parse-filepath "^1.0.1" + +first-chunk-stream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz#59bfb50cd905f60d7c394cd3d9acaab4e6ad934e" + +first-chunk-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-2.0.0.tgz#1bdecdb8e083c0664b91945581577a43a9f31d70" + dependencies: + readable-stream "^2.0.2" + +flagged-respawn@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/flagged-respawn/-/flagged-respawn-0.3.2.tgz#ff191eddcd7088a675b2610fffc976be9b8074b5" + +flat-cache@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.3.0.tgz#d3030b32b38154f4e3b7e9c709f490f7ef97c481" + dependencies: + circular-json "^0.3.1" + del "^2.0.2" + graceful-fs "^4.1.2" + write "^0.2.1" + +flatmap-stream@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/flatmap-stream/-/flatmap-stream-0.1.0.tgz#ed54e01422cd29281800914fcb968d58b685d5f1" + +flatten@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" + +flush-write-stream@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.0.3.tgz#c5d586ef38af6097650b49bc41b55fabb19f35bd" + dependencies: + inherits "^2.0.1" + readable-stream "^2.0.4" + +for-each@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.2.tgz#2c40450b9348e97f281322593ba96704b9abd4d4" + dependencies: + is-function "~1.0.0" + +for-in@^1.0.1, for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + +for-own@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce" + dependencies: + for-in "^1.0.1" + +for-own@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b" + dependencies: + for-in "^1.0.1" + +foreach@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + +form-data@~2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.1.tgz#6fb94fbd71885306d73d15cc497fe4cc4ecd44bf" + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.5" + mime-types "^2.1.12" + +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + dependencies: + map-cache "^0.2.2" + +from2@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" + dependencies: + inherits "^2.0.1" + readable-stream "^2.0.0" + +from@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" + +fs-exists-sync@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add" + +fs-readfile-promise@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fs-readfile-promise/-/fs-readfile-promise-2.0.1.tgz#80023823981f9ffffe01609e8be668f69ae49e70" + dependencies: + graceful-fs "^4.1.2" + +fs-write-stream-atomic@^1.0.8: + version "1.0.10" + resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9" + dependencies: + graceful-fs "^4.1.2" + iferr "^0.1.5" + imurmurhash "^0.1.4" + readable-stream "1 || 2" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + +fsevents@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.1.2.tgz#3282b713fb3ad80ede0e9fcf4611b5aa6fc033f4" + dependencies: + nan "^2.3.0" + node-pre-gyp "^0.6.36" + +fstream-ignore@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/fstream-ignore/-/fstream-ignore-1.0.5.tgz#9c31dae34767018fe1d249b24dada67d092da105" + dependencies: + fstream "^1.0.0" + inherits "2" + minimatch "^3.0.0" + +fstream@^1.0.0, fstream@^1.0.10, fstream@^1.0.2, fstream@^1.0.8: + version "1.0.11" + resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.11.tgz#5c1fb1f117477114f0632a0eb4b71b3cb0fd3171" + dependencies: + graceful-fs "^4.1.2" + inherits "~2.0.0" + mkdirp ">=0.5 0" + rimraf "2" + +function-bind@^1.0.2, function-bind@^1.1.1, function-bind@~1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + +functional-red-black-tree@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" + +gauge@~2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" + dependencies: + aproba "^1.0.3" + console-control-strings "^1.0.0" + has-unicode "^2.0.0" + object-assign "^4.1.0" + signal-exit "^3.0.0" + string-width "^1.0.1" + strip-ansi "^3.0.1" + wide-align "^1.1.0" + +gaze@^0.5.1: + version "0.5.2" + resolved "https://registry.yarnpkg.com/gaze/-/gaze-0.5.2.tgz#40b709537d24d1d45767db5a908689dfe69ac44f" + dependencies: + globule "~0.1.0" + +get-caller-file@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5" + +get-node-dimensions@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/get-node-dimensions/-/get-node-dimensions-1.2.2.tgz#7a71e8624cf9e1ab74599bb05b7e5116e995e45b" + +get-stdin@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b" + +get-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + dependencies: + assert-plus "^1.0.0" + +glob-base@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" + dependencies: + glob-parent "^2.0.0" + is-glob "^2.0.0" + +glob-parent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28" + dependencies: + is-glob "^2.0.0" + +glob-parent@^3.0.1, glob-parent@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" + dependencies: + is-glob "^3.1.0" + path-dirname "^1.0.0" + +glob-stream@^3.1.5: + version "3.1.18" + resolved "https://registry.yarnpkg.com/glob-stream/-/glob-stream-3.1.18.tgz#9170a5f12b790306fdfe598f313f8f7954fd143b" + dependencies: + glob "^4.3.1" + glob2base "^0.0.12" + minimatch "^2.0.1" + ordered-read-streams "^0.1.0" + through2 "^0.6.1" + unique-stream "^1.0.0" + +glob-to-regexp@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" + +glob-watcher@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/glob-watcher/-/glob-watcher-0.0.6.tgz#b95b4a8df74b39c83298b0c05c978b4d9a3b710b" + dependencies: + gaze "^0.5.1" + +glob2base@^0.0.12: + version "0.0.12" + resolved "https://registry.yarnpkg.com/glob2base/-/glob2base-0.0.12.tgz#9d419b3e28f12e83a362164a277055922c9c0d56" + dependencies: + find-index "^0.1.1" + +glob@^4.3.1: + version "4.5.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-4.5.3.tgz#c6cb73d3226c1efef04de3c56d012f03377ee15f" + dependencies: + inflight "^1.0.4" + inherits "2" + minimatch "^2.0.1" + once "^1.3.0" + +glob@^7.0.3, glob@^7.1.1, glob@~7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^7.0.5, glob@^7.1.2: + version "7.1.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@~3.1.21: + version "3.1.21" + resolved "https://registry.yarnpkg.com/glob/-/glob-3.1.21.tgz#d29e0a055dea5138f4d07ed40e8982e83c2066cd" + dependencies: + graceful-fs "~1.2.0" + inherits "1" + minimatch "~0.2.11" + +global-modules@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d" + dependencies: + global-prefix "^0.1.4" + is-windows "^0.2.0" + +global-prefix@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-0.1.5.tgz#8d3bc6b8da3ca8112a160d8d496ff0462bfef78f" + dependencies: + homedir-polyfill "^1.0.0" + ini "^1.3.4" + is-windows "^0.2.0" + which "^1.2.12" + +globals@^11.1.0, globals@^11.7.0: + version "11.7.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.7.0.tgz#a583faa43055b1aca771914bf68258e2fc125673" + +globals@^9.18.0: + version "9.18.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a" + +globby@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-5.0.0.tgz#ebd84667ca0dbb330b99bcfc68eac2bc54370e0d" + dependencies: + array-union "^1.0.1" + arrify "^1.0.0" + glob "^7.0.3" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +globby@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c" + dependencies: + array-union "^1.0.1" + glob "^7.0.3" + object-assign "^4.0.1" + pify "^2.0.0" + pinkie-promise "^2.0.0" + +globby@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/globby/-/globby-8.0.1.tgz#b5ad48b8aa80b35b814fc1281ecc851f1d2b5b50" + dependencies: + array-union "^1.0.1" + dir-glob "^2.0.0" + fast-glob "^2.0.2" + glob "^7.1.2" + ignore "^3.3.5" + pify "^3.0.0" + slash "^1.0.0" + +globjoin@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/globjoin/-/globjoin-0.1.4.tgz#2f4494ac8919e3767c5cbb691e9f463324285d43" + +globule@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/globule/-/globule-0.1.0.tgz#d9c8edde1da79d125a151b79533b978676346ae5" + dependencies: + glob "~3.1.21" + lodash "~1.0.1" + minimatch "~0.2.11" + +glogg@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/glogg/-/glogg-1.0.0.tgz#7fe0f199f57ac906cf512feead8f90ee4a284fc5" + dependencies: + sparkles "^1.0.0" + +gonzales-pe@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/gonzales-pe/-/gonzales-pe-4.2.3.tgz#41091703625433285e0aee3aa47829fc1fbeb6f2" + dependencies: + minimist "1.1.x" + +good-listener@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" + dependencies: + delegate "^3.1.2" + +graceful-fs@4.X, graceful-fs@^4.1.11, graceful-fs@^4.1.2: + version "4.1.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658" + +graceful-fs@^3.0.0: + version "3.0.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-3.0.11.tgz#7613c778a1afea62f25c630a086d7f3acbbdd818" + dependencies: + natives "^1.1.0" + +graceful-fs@~1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-1.2.3.tgz#15a4806a57547cb2d2dbf27f42e89a8c3451b364" + +gulp-cached@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/gulp-cached/-/gulp-cached-1.1.1.tgz#fe7cd4f87f37601e6073cfedee5c2bdaf8b6acce" + dependencies: + lodash.defaults "^4.2.0" + through2 "^2.0.1" + +gulp-clean-css@3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/gulp-clean-css/-/gulp-clean-css-3.10.0.tgz#bccd4605eff104bfa4980014cc4b3c24c571736d" + dependencies: + clean-css "4.2.1" + plugin-error "1.0.1" + through2 "2.0.3" + vinyl-sourcemaps-apply "0.2.1" + +gulp-concat@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/gulp-concat/-/gulp-concat-2.6.1.tgz#633d16c95d88504628ad02665663cee5a4793353" + dependencies: + concat-with-sourcemaps "^1.0.0" + through2 "^2.0.0" + vinyl "^2.0.0" + +gulp-declare@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/gulp-declare/-/gulp-declare-0.3.0.tgz#86830fc6faa88e06382162c8664b8e94957afcd9" + dependencies: + nsdeclare "^0.1.0" + vinyl-map "^1.0.1" + xtend "^4.0.0" + +gulp-livereload@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/gulp-livereload/-/gulp-livereload-4.0.0.tgz#be4a6b01731a93f76f4fb29c9e62e323affe7d03" + dependencies: + chalk "^2.4.1" + debug "^3.1.0" + event-stream "^3.3.4" + fancy-log "^1.3.2" + lodash.assign "^4.2.0" + tiny-lr "^1.1.1" + vinyl "^2.2.0" + +gulp-postcss@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/gulp-postcss/-/gulp-postcss-8.0.0.tgz#8d3772cd4d27bca55ec8cb4c8e576e3bde4dc550" + dependencies: + fancy-log "^1.3.2" + plugin-error "^1.0.1" + postcss "^7.0.2" + postcss-load-config "^2.0.0" + vinyl-sourcemaps-apply "^0.2.1" + +gulp-print@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/gulp-print/-/gulp-print-5.0.0.tgz#0fa2791dc4589633f4015f054e4f39a8d285120b" + dependencies: + ansi-colors "^1.0.1" + fancy-log "^1.3.2" + map-stream "0.0.7" + vinyl "^2.1.0" + +gulp-sourcemaps@2.6.4: + version "2.6.4" + resolved "https://registry.yarnpkg.com/gulp-sourcemaps/-/gulp-sourcemaps-2.6.4.tgz#cbb2008450b1bcce6cd23bf98337be751bf6e30a" + dependencies: + "@gulp-sourcemaps/identity-map" "1.X" + "@gulp-sourcemaps/map-sources" "1.X" + acorn "5.X" + convert-source-map "1.X" + css "2.X" + debug-fabulous "1.X" + detect-newline "2.X" + graceful-fs "4.X" + source-map "~0.6.0" + strip-bom-string "1.X" + through2 "2.X" + +gulp-stripbom@1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/gulp-stripbom/-/gulp-stripbom-1.0.4.tgz#58c1d03e85e008a7aab47d81b1297c8c1bc828eb" + dependencies: + gulp-util "^3.0.0" + log-symbols "^1.0.0" + strip-bom "^1.0.0" + through2 "^0.5.1" + +gulp-util@3.0.8, gulp-util@^3.0.0, gulp-util@^3.0.7: + version "3.0.8" + resolved "https://registry.yarnpkg.com/gulp-util/-/gulp-util-3.0.8.tgz#0054e1e744502e27c04c187c3ecc505dd54bbb4f" + dependencies: + array-differ "^1.0.0" + array-uniq "^1.0.2" + beeper "^1.0.0" + chalk "^1.0.0" + dateformat "^2.0.0" + fancy-log "^1.1.0" + gulplog "^1.0.0" + has-gulplog "^0.1.0" + lodash._reescape "^3.0.0" + lodash._reevaluate "^3.0.0" + lodash._reinterpolate "^3.0.0" + lodash.template "^3.0.0" + minimist "^1.1.0" + multipipe "^0.1.2" + object-assign "^3.0.0" + replace-ext "0.0.1" + through2 "^2.0.0" + vinyl "^0.5.0" + +gulp-watch@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/gulp-watch/-/gulp-watch-5.0.1.tgz#83d378752f5bfb46da023e73c17ed1da7066215d" + dependencies: + ansi-colors "1.1.0" + anymatch "^1.3.0" + chokidar "^2.0.0" + fancy-log "1.3.2" + glob-parent "^3.0.1" + object-assign "^4.1.0" + path-is-absolute "^1.0.1" + plugin-error "1.0.1" + readable-stream "^2.2.2" + slash "^1.0.0" + vinyl "^2.1.0" + vinyl-file "^2.0.0" + +gulp-wrap@0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/gulp-wrap/-/gulp-wrap-0.14.0.tgz#15a5c2048e2721e70539a61baf1c34a0bc5f2729" + dependencies: + consolidate "^0.14.1" + es6-promise "^3.1.2" + fs-readfile-promise "^2.0.1" + js-yaml "^3.2.6" + lodash "^4.11.1" + node.extend "^1.1.2" + plugin-error "^0.1.2" + through2 "^2.0.1" + tryit "^1.0.1" + vinyl-bufferstream "^1.0.1" + +gulp@3.9.1: + version "3.9.1" + resolved "https://registry.yarnpkg.com/gulp/-/gulp-3.9.1.tgz#571ce45928dd40af6514fc4011866016c13845b4" + dependencies: + archy "^1.0.0" + chalk "^1.0.0" + deprecated "^0.0.1" + gulp-util "^3.0.0" + interpret "^1.0.0" + liftoff "^2.1.0" + minimist "^1.1.0" + orchestrator "^0.3.0" + pretty-hrtime "^1.0.0" + semver "^4.1.0" + tildify "^1.0.0" + v8flags "^2.0.2" + vinyl-fs "^0.3.0" + +gulplog@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/gulplog/-/gulplog-1.0.0.tgz#e28c4d45d05ecbbed818363ce8f9c5926229ffe5" + dependencies: + glogg "^1.0.0" + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + +har-validator@~5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.0.3.tgz#ba402c266194f15956ef15e0fcf242993f6a7dfd" + dependencies: + ajv "^5.1.0" + har-schema "^2.0.0" + +has-ansi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91" + dependencies: + ansi-regex "^2.0.0" + +has-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" + +has-flag@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + +has-gulplog@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/has-gulplog/-/has-gulplog-0.1.0.tgz#6414c82913697da51590397dafb12f22967811ce" + dependencies: + sparkles "^1.0.0" + +has-unicode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +has@^1.0.1, has@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.1.tgz#8461733f538b0837c9361e39a9ab9e9704dc2f28" + dependencies: + function-bind "^1.0.2" + +has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + dependencies: + function-bind "^1.1.1" + +hash-base@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-2.0.2.tgz#66ea1d856db4e8a5470cadf6fce23ae5244ef2e1" + dependencies: + inherits "^2.0.1" + +hash-base@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918" + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +hash.js@^1.0.0, hash.js@^1.0.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.3.tgz#340dedbe6290187151c1ea1d777a3448935df846" + dependencies: + inherits "^2.0.3" + minimalistic-assert "^1.0.0" + +hawk@~6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/hawk/-/hawk-6.0.2.tgz#af4d914eb065f9b5ce4d9d11c1cb2126eecc3038" + dependencies: + boom "4.x.x" + cryptiles "3.x.x" + hoek "4.x.x" + sntp "2.x.x" + +history@4.7.2, history@^4.7.2: + version "4.7.2" + resolved "https://registry.yarnpkg.com/history/-/history-4.7.2.tgz#22b5c7f31633c5b8021c7f4a8a954ac139ee8d5b" + dependencies: + invariant "^2.2.1" + loose-envify "^1.2.0" + resolve-pathname "^2.2.0" + value-equal "^0.4.0" + warning "^3.0.0" + +history@^4.5.1: + version "4.6.3" + resolved "https://registry.yarnpkg.com/history/-/history-4.6.3.tgz#6d723a8712c581d6bef37e8c26f4aedc6eb86967" + dependencies: + invariant "^2.2.1" + loose-envify "^1.2.0" + resolve-pathname "^2.0.0" + value-equal "^0.2.0" + warning "^3.0.0" + +hmac-drbg@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" + dependencies: + hash.js "^1.0.3" + minimalistic-assert "^1.0.0" + minimalistic-crypto-utils "^1.0.1" + +hoek@4.x.x: + version "4.2.0" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d" + +hoist-non-react-statics@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.3.1.tgz#343db84c6018c650778898240135a1420ee22ce0" + +hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0: + version "2.5.5" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" + +hoist-non-react-statics@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.0.1.tgz#fba3e7df0210eb9447757ca1a7cb607162f0a364" + dependencies: + react-is "^16.3.2" + +home-or-tmp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.1" + +homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.1.tgz#4c2bbc8a758998feebf5ed68580f76d46768b4bc" + dependencies: + parse-passwd "^1.0.0" + +hosted-git-info@^2.1.4: + version "2.7.1" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047" + +html-comment-regex@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e" + +html-tags@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-2.0.0.tgz#10b30a386085f43cede353cc8fa7cb0deeea668b" + +htmlparser2@^3.9.2: + version "3.9.2" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.9.2.tgz#1bdf87acca0f3f9e53fa4fcceb0f4b4cbb00b338" + dependencies: + domelementtype "^1.3.0" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^2.0.2" + +http-parser-js@>=0.4.0: + version "0.4.6" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.4.6.tgz#195273f58704c452d671076be201329dd341dc55" + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +https-browserify@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-0.0.1.tgz#3f91365cabe60b77ed0ebba24b454e3e09d95a82" + +humps@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa" + +iconv-lite@^0.4.24, iconv-lite@~0.4.13: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + dependencies: + safer-buffer ">= 2.1.2 < 3" + +icss-replace-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" + +icss-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-2.1.0.tgz#83f0a0ec378bf3246178b6c2ad9136f135b1c962" + dependencies: + postcss "^6.0.1" + +ieee754@^1.1.4: + version "1.1.8" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" + +iferr@^0.1.5: + version "0.1.5" + resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" + +ignore@^3.3.5: + version "3.3.10" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" + +ignore@^4.0.0, ignore@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" + +import-cwd@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" + dependencies: + import-from "^2.1.0" + +import-from@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/import-from/-/import-from-2.1.0.tgz#335db7f2a7affd53aaa471d4b8021dee36b7f3b1" + dependencies: + resolve-from "^3.0.0" + +import-lazy@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-3.1.0.tgz#891279202c8a2280fdbd6674dbd8da1a1dfc67cc" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + +indent-string@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" + +indexes-of@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607" + +indexof@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-1.0.2.tgz#ca4309dadee6b54cc0b8d247e8d7c7a0975bdc9b" + +inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + +inherits@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" + +ini@^1.3.4, ini@~1.3.0: + version "1.3.4" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.4.tgz#0537cb79daf59b59a1a517dff706c86ec039162e" + +inquirer@^6.1.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.0.tgz#51adcd776f661369dc1e894859c2560a224abdd8" + dependencies: + ansi-escapes "^3.0.0" + chalk "^2.0.0" + cli-cursor "^2.1.0" + cli-width "^2.0.0" + external-editor "^3.0.0" + figures "^2.0.0" + lodash "^4.17.10" + mute-stream "0.0.7" + run-async "^2.2.0" + rxjs "^6.1.0" + string-width "^2.1.0" + strip-ansi "^4.0.0" + through "^2.3.6" + +interpret@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.4.tgz#820cdd588b868ffb191a809506d6c9c8f212b1b0" + +invariant@^2.0.0, invariant@^2.1.0, invariant@^2.2.1, invariant@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360" + dependencies: + loose-envify "^1.0.0" + +invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" + dependencies: + loose-envify "^1.0.0" + +invert-kv@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" + +is-absolute-url@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" + +is-absolute@^0.2.3: + version "0.2.6" + resolved "https://registry.yarnpkg.com/is-absolute/-/is-absolute-0.2.6.tgz#20de69f3db942ef2d87b9c2da36f172235b1b5eb" + dependencies: + is-relative "^0.2.1" + is-windows "^0.2.0" + +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + dependencies: + kind-of "^6.0.0" + +is-alphabetical@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.2.tgz#1fa6e49213cb7885b75d15862fb3f3d96c884f41" + +is-alphanumeric@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-alphanumeric/-/is-alphanumeric-1.0.0.tgz#4a9cef71daf4c001c1d81d63d140cf53fd6889f4" + +is-alphanumerical@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.2.tgz#1138e9ae5040158dc6ff76b820acd6b7a181fd40" + dependencies: + is-alphabetical "^1.0.0" + is-decimal "^1.0.0" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + +is-binary-path@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" + dependencies: + binary-extensions "^1.0.0" + +is-buffer@^1.1.4, is-buffer@^1.1.5, is-buffer@~1.1.1: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + +is-builtin-module@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-1.0.0.tgz#540572d34f7ac3119f8f76c30cbc1b1e037affbe" + dependencies: + builtin-modules "^1.0.0" + +is-callable@^1.1.1, is-callable@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2" + +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + dependencies: + kind-of "^6.0.0" + +is-date-object@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" + +is-decimal@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.2.tgz#894662d6a8709d307f3a276ca4339c8fa5dff0ff" + +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + +is-directory@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/is-directory/-/is-directory-0.3.1.tgz#61339b6f2475fc772fd9c9d83f5c8575dc154ae1" + +is-dotfile@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1" + +is-equal-shallow@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534" + dependencies: + is-primitive "^2.0.0" + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + dependencies: + is-plain-object "^2.0.4" + +is-extglob@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0" + +is-extglob@^2.1.0, is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + +is-finite@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa" + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + +is-function@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.1.tgz#12cfb98b65b57dd3d193a3121f5f6e2f437602b5" + +is-glob@^2.0.0, is-glob@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" + dependencies: + is-extglob "^1.0.0" + +is-glob@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" + dependencies: + is-extglob "^2.1.0" + +is-glob@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.0.tgz#9521c76845cc2610a85203ddf080a958c2ffabc0" + dependencies: + is-extglob "^2.1.1" + +is-hexadecimal@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.2.tgz#b6e710d7d07bb66b98cb8cece5c9b4921deeb835" + +is-number@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" + dependencies: + kind-of "^3.0.2" + +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + dependencies: + kind-of "^3.0.2" + +is-number@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff" + +is-obj@^1.0.0: + version "1.0.1" + resolved "http://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" + +is-odd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-odd/-/is-odd-1.0.0.tgz#3b8a932eb028b3775c39bb09e91767accdb69088" + dependencies: + is-number "^3.0.0" + +is-path-cwd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d" + +is-path-in-cwd@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52" + dependencies: + is-path-inside "^1.0.0" + +is-path-inside@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" + dependencies: + path-is-inside "^1.0.1" + +is-plain-obj@^1.0.0, is-plain-obj@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + +is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + dependencies: + isobject "^3.0.1" + +is-posix-bracket@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4" + +is-primitive@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" + +is-promise@^2.1, is-promise@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" + +is-regex@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" + dependencies: + has "^1.0.1" + +is-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" + +is-relative@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-relative/-/is-relative-0.2.1.tgz#d27f4c7d516d175fb610db84bbeef23c3bc97aa5" + dependencies: + is-unc-path "^0.1.1" + +is-resolvable@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" + +is-stream@^1.0.1, is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + +is-supported-regexp-flag@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-supported-regexp-flag/-/is-supported-regexp-flag-1.0.1.tgz#21ee16518d2c1dd3edd3e9a0d57e50207ac364ca" + +is-svg@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-2.1.0.tgz#cf61090da0d9efbcab8722deba6f032208dbb0e9" + dependencies: + html-comment-regex "^1.1.0" + +is-symbol@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.1.tgz#3cc59f00025194b6ab2e38dbae6689256b660572" + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + +is-unc-path@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/is-unc-path/-/is-unc-path-0.1.2.tgz#6ab053a72573c10250ff416a3814c35178af39b9" + dependencies: + unc-path-regex "^0.1.0" + +is-utf8@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72" + +is-whitespace-character@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.2.tgz#ede53b4c6f6fb3874533751ec9280d01928d03ed" + +is-windows@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.2.0.tgz#de1aa6d63ea29dd248737b69f1ff8b8002d2108c" + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + +is-word-character@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-word-character/-/is-word-character-1.0.2.tgz#46a5dac3f2a1840898b91e576cd40d493f3ae553" + +is@^3.1.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/is/-/is-3.2.1.tgz#d0ac2ad55eb7b0bec926a5266f6c662aaa83dca5" + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + +isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + +isomorphic-fetch@^2.1.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" + dependencies: + node-fetch "^1.0.1" + whatwg-fetch ">=0.10.0" + +isstream@^0.1.2, isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + +jdu@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/jdu/-/jdu-1.0.0.tgz#28f1e388501785ae0a1d93e93ed0b14dd41e51ce" + +jquery@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca" + +jquery@>=1.6.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-2.2.4.tgz#2c89d6889b5eac522a7eea32c14521559c6cbf02" + +js-base64@^2.1.9: + version "2.4.9" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.4.9.tgz#748911fb04f48a60c4771b375cac45a80df11c03" + +js-tokens@^3.0.0, js-tokens@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + +js-yaml@^3.12.0, js-yaml@^3.9.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.0.tgz#eaed656ec8344f10f527c6bfa1b6e2244de167d1" + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^3.2.6: + version "3.10.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.10.0.tgz#2e78441646bd4682e963f22b6e92823c309c62dc" + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@~3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.7.0.tgz#5c967ddd837a9bfdca5f2de84253abe8a1c03b80" + dependencies: + argparse "^1.0.7" + esprima "^2.6.0" + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + +jsesc@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b" + +jsesc@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.1.tgz#e421a2a8e20d6b0819df28908f782526b96dd1fe" + +jsesc@~0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" + +json-loader@^0.5.4: + version "0.5.7" + resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.7.tgz#dca14a70235ff82f0ac9a3abeb60d337a365185d" + +json-parse-better-errors@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + +json-schema-traverse@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + +json-stable-stringify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af" + dependencies: + jsonify "~0.0.0" + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + +json5@^0.5.0, json5@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" + +jsonify@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" + +jsprim@^1.2.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + +jsx-ast-utils@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.0.1.tgz#e801b1b39985e20fffc87b40e3748080e2dcac7f" + dependencies: + array-includes "^3.0.3" + +kind-of@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-1.1.0.tgz#140a3d2d41a36d2efcfa9377b62c24f8495a5c44" + +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0, kind-of@^5.0.2: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" + +known-css-properties@^0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.6.1.tgz#31b5123ad03d8d1a3f36bd4155459c981173478b" + +lazy-cache@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e" + +lcid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" + dependencies: + invert-kv "^1.0.0" + +levn@^0.3.0, levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +liftoff@^2.1.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-2.3.0.tgz#a98f2ff67183d8ba7cfaca10548bd7ff0550b385" + dependencies: + extend "^3.0.0" + findup-sync "^0.4.2" + fined "^1.0.1" + flagged-respawn "^0.3.2" + lodash.isplainobject "^4.0.4" + lodash.isstring "^4.0.1" + lodash.mapvalues "^4.4.0" + rechoir "^0.6.2" + resolve "^1.1.7" + +livereload-js@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.3.0.tgz#c3ab22e8aaf5bf3505d80d098cbad67726548c9a" + +load-json-file@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" + dependencies: + graceful-fs "^4.1.2" + parse-json "^2.2.0" + pify "^2.0.0" + strip-bom "^3.0.0" + +load-json-file@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" + dependencies: + graceful-fs "^4.1.2" + parse-json "^4.0.0" + pify "^3.0.0" + strip-bom "^3.0.0" + +loader-runner@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2" + +loader-utils@^1.0.2, loader-utils@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" + dependencies: + big.js "^3.1.3" + emojis-list "^2.0.0" + json5 "^0.5.0" + +locate-path@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" + dependencies: + p-locate "^2.0.0" + path-exists "^3.0.0" + +lodash-es@^4.17.5: + version "4.17.11" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0" + +lodash._basecopy@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36" + +lodash._basetostring@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash._basetostring/-/lodash._basetostring-3.0.1.tgz#d1861d877f824a52f669832dcaf3ee15566a07d5" + +lodash._basevalues@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lodash._basevalues/-/lodash._basevalues-3.0.0.tgz#5b775762802bde3d3297503e26300820fdf661b7" + +lodash._getnative@^3.0.0: + version "3.9.1" + resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" + +lodash._isiterateecall@^3.0.0: + version "3.0.9" + resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c" + +lodash._reescape@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lodash._reescape/-/lodash._reescape-3.0.0.tgz#2b1d6f5dfe07c8a355753e5f27fac7f1cde1616a" + +lodash._reevaluate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lodash._reevaluate/-/lodash._reevaluate-3.0.0.tgz#58bc74c40664953ae0b124d806996daca431e2ed" + +lodash._reinterpolate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" + +lodash._root@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692" + +lodash.assign@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" + +lodash.camelcase@4.3.0, lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + +lodash.clone@^4.3.2: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clone/-/lodash.clone-4.5.0.tgz#195870450f5a13192478df4bc3d23d2dea1907b6" + +lodash.curry@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.curry/-/lodash.curry-4.1.1.tgz#248e36072ede906501d75966200a86dab8b23170" + +lodash.defaults@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + +lodash.escape@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-3.2.0.tgz#995ee0dc18c1b48cc92effae71a10aab5b487698" + dependencies: + lodash._root "^3.0.0" + +lodash.isarguments@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" + +lodash.isarray@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" + +lodash.isplainobject@^4.0.4: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + +lodash.kebabcase@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36" + +lodash.keys@^3.0.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a" + dependencies: + lodash._getnative "^3.0.0" + lodash.isarguments "^3.0.0" + lodash.isarray "^3.0.0" + +lodash.mapvalues@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c" + +lodash.memoize@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + +lodash.restparam@^3.0.0: + version "3.6.1" + resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" + +lodash.snakecase@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz#39d714a35357147837aefd64b5dcbb16becd8f8d" + +lodash.some@^4.2.2: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d" + +lodash.template@^3.0.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-3.6.2.tgz#f8cdecc6169a255be9098ae8b0c53d378931d14f" + dependencies: + lodash._basecopy "^3.0.0" + lodash._basetostring "^3.0.0" + lodash._basevalues "^3.0.0" + lodash._isiterateecall "^3.0.0" + lodash._reinterpolate "^3.0.0" + lodash.escape "^3.0.0" + lodash.keys "^3.0.0" + lodash.restparam "^3.0.0" + lodash.templatesettings "^3.0.0" + +lodash.templatesettings@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-3.1.1.tgz#fb307844753b66b9f1afa54e262c745307dba8e5" + dependencies: + lodash._reinterpolate "^3.0.0" + lodash.escape "^3.0.0" + +lodash.uniq@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" + +lodash.upperfirst@4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" + +lodash@4.17.11, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5: + version "4.17.11" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" + +lodash@^4.11.1, lodash@^4.14.0: + version "4.17.4" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" + +lodash@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-1.0.2.tgz#8f57560c83b59fc270bd3d561b690043430e2551" + +log-symbols@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18" + dependencies: + chalk "^1.0.0" + +log-symbols@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" + dependencies: + chalk "^2.0.1" + +longest-streak@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.2.tgz#2421b6ba939a443bb9ffebf596585a50b4c38e2e" + +longest@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" + +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +loose-envify@^1.2.0, loose-envify@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848" + dependencies: + js-tokens "^3.0.0" + +loud-rejection@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" + dependencies: + currently-unhandled "^0.4.1" + signal-exit "^3.0.0" + +lru-cache@2: + version "2.7.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952" + +lru-cache@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.1.tgz#622e32e82488b49279114a4f9ecf45e7cd6bba55" + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + +lru-cache@^4.1.1: + version "4.1.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.3.tgz#a1175cf3496dfc8436c156c334b4955992bce69c" + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + +lru-queue@0.1: + version "0.1.0" + resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" + dependencies: + es5-ext "~0.10.2" + +macaddress@^0.2.8: + version "0.2.8" + resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.8.tgz#5904dc537c39ec6dbefeae902327135fa8511f12" + +make-dir@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.0.0.tgz#97a011751e91dd87cfadef58832ebb04936de978" + dependencies: + pify "^2.3.0" + +makeerror@1.0.x: + version "1.0.11" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" + dependencies: + tmpl "1.0.x" + +map-cache@^0.2.0, map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + +map-obj@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d" + +map-obj@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-2.0.0.tgz#a65cd29087a92598b8791257a523e021222ac1f9" + +map-stream@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/map-stream/-/map-stream-0.0.7.tgz#8a1f07896d82b10926bd3744a2420009f88974a8" + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + dependencies: + object-visit "^1.0.0" + +markdown-escapes@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.2.tgz#e639cbde7b99c841c0bacc8a07982873b46d2122" + +markdown-table@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-1.1.2.tgz#c78db948fa879903a41bce522e3b96f801c63786" + +math-expression-evaluator@^1.2.14: + version "1.2.17" + resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac" + +math-random@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.1.tgz#8b3aac588b8a66e4975e3cdea67f7bb329601fac" + +mathml-tag-names@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.0.tgz#490b70e062ee24636536e3d9481e333733d00f2c" + +md5.js@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.4.tgz#e9bdbde94a20a5ac18b04340fc5764d5b09d901d" + dependencies: + hash-base "^3.0.0" + inherits "^2.0.1" + +md5@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9" + dependencies: + charenc "~0.0.1" + crypt "~0.0.1" + is-buffer "~1.1.1" + +mdast-util-compact@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.2.tgz#c12ebe16fffc84573d3e19767726de226e95f649" + dependencies: + unist-util-visit "^1.1.0" + +mem@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76" + dependencies: + mimic-fn "^1.0.0" + +memoizee@0.4.X: + version "0.4.11" + resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.11.tgz#bde9817663c9e40fdb2a4ea1c367296087ae8c8f" + dependencies: + d "1" + es5-ext "^0.10.30" + es6-weak-map "^2.0.2" + event-emitter "^0.3.5" + is-promise "^2.1" + lru-queue "0.1" + next-tick "1" + timers-ext "^0.1.2" + +memory-fs@^0.4.0, memory-fs@^0.4.1, memory-fs@~0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" + dependencies: + errno "^0.1.3" + readable-stream "^2.0.1" + +meow@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/meow/-/meow-5.0.0.tgz#dfc73d63a9afc714a5e371760eb5c88b91078aa4" + dependencies: + camelcase-keys "^4.0.0" + decamelize-keys "^1.0.0" + loud-rejection "^1.0.0" + minimist-options "^3.0.1" + normalize-package-data "^2.3.4" + read-pkg-up "^3.0.0" + redent "^2.0.0" + trim-newlines "^2.0.0" + yargs-parser "^10.0.0" + +merge2@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.2.2.tgz#03212e3da8d86c4d8523cebd6318193414f94e34" + +merge@^1.1.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.0.tgz#7531e39d4949c281a66b8c5a6e0265e8b05894da" + +micromatch@^2.1.5, micromatch@^2.3.11, micromatch@^2.3.7: + version "2.3.11" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" + dependencies: + arr-diff "^2.0.0" + array-unique "^0.2.1" + braces "^1.8.2" + expand-brackets "^0.1.4" + extglob "^0.3.1" + filename-regex "^2.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.1" + kind-of "^3.0.2" + normalize-path "^2.0.1" + object.omit "^2.0.0" + parse-glob "^3.0.4" + regex-cache "^0.4.2" + +micromatch@^3.1.10: + version "3.1.10" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.2" + +micromatch@^3.1.4: + version "3.1.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.5.tgz#d05e168c206472dfbca985bfef4f57797b4cd4ba" + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.0" + define-property "^1.0.0" + extend-shallow "^2.0.1" + extglob "^2.0.2" + fragment-cache "^0.2.1" + kind-of "^6.0.0" + nanomatch "^1.2.5" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +miller-rabin@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.0.tgz#4a62fb1d42933c05583982f4c716f6fb9e6c6d3d" + dependencies: + bn.js "^4.0.0" + brorand "^1.0.1" + +mime-db@~1.30.0: + version "1.30.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01" + +mime-types@^2.1.12, mime-types@~2.1.17: + version "2.1.17" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a" + dependencies: + mime-db "~1.30.0" + +mime@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" + +mimic-fn@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.1.0.tgz#e667783d92e89dbd342818b5230b9d62a672ad18" + +minimalistic-assert@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.0.tgz#702be2dda6b37f4836bcb3f5db56641b64a1d3d3" + +minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" + +minimatch@^2.0.1: + version "2.0.10" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-2.0.10.tgz#8d087c39c6b38c001b97fca7ce6d0e1e80afbac7" + dependencies: + brace-expansion "^1.0.0" + +minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + dependencies: + brace-expansion "^1.1.7" + +minimatch@~0.2.11: + version "0.2.14" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-0.2.14.tgz#c74e780574f63c6f9a090e90efbe6ef53a6a756a" + dependencies: + lru-cache "2" + sigmund "~1.0.0" + +minimist-options@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-3.0.2.tgz#fba4c8191339e13ecf4d61beb03f070103f3d954" + dependencies: + arrify "^1.0.1" + is-plain-obj "^1.1.0" + +minimist@0.0.8: + version "0.0.8" + resolved "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + +minimist@1.1.x: + version "1.1.3" + resolved "http://registry.npmjs.org/minimist/-/minimist-1.1.3.tgz#3bedfd91a92d39016fcfaa1c681e8faa1a1efda8" + +minimist@^1.1.0, minimist@^1.1.1, minimist@^1.2.0, minimist@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" + +mississippi@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f" + dependencies: + concat-stream "^1.5.0" + duplexify "^3.4.2" + end-of-stream "^1.1.0" + flush-write-stream "^1.0.0" + from2 "^2.1.0" + parallel-transform "^1.1.0" + pump "^2.0.1" + pumpify "^1.3.3" + stream-each "^1.1.0" + through2 "^2.0.0" + +mixin-deep@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: + version "0.5.1" + resolved "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + dependencies: + minimist "0.0.8" + +mobile-detect@1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/mobile-detect/-/mobile-detect-1.4.3.tgz#e436a3839f5807dd4d3cd4e081f7d3a51ffda2dd" + +moment@2.22.2: + version "2.22.2" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66" + +mousetrap@1.6.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.2.tgz#caadd9cf886db0986fb2fee59a82f6bd37527587" + +"mout@>=0.9 <2.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/mout/-/mout-1.0.0.tgz#9bdf1d4af57d66d47cb353a6335a3281098e1501" + +mout@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/mout/-/mout-0.11.1.tgz#ba3611df5f0e5b1ffbfd01166b8f02d1f5fa2b99" + +move-concurrently@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92" + dependencies: + aproba "^1.1.1" + copy-concurrently "^1.0.0" + fs-write-stream-atomic "^1.0.8" + mkdirp "^0.5.1" + rimraf "^2.5.4" + run-queue "^1.0.3" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + +ms@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + +multipipe@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/multipipe/-/multipipe-0.1.2.tgz#2a8f2ddf70eed564dff2d57f1e1a137d9f05078b" + dependencies: + duplexer2 "0.0.2" + +mute-stream@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" + +nan@^2.0.5, nan@^2.3.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.7.0.tgz#d95bf721ec877e08db276ed3fc6eb78f9083ad46" + +nanomatch@^1.2.5: + version "1.2.7" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.7.tgz#53cd4aa109ff68b7f869591fdc9d10daeeea3e79" + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^1.0.0" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + is-odd "^1.0.0" + kind-of "^5.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +nanomatch@^1.2.9: + version "1.2.13" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +natives@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/natives/-/natives-1.1.0.tgz#e9ff841418a6b2ec7a495e939984f78f163e6e31" + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + +new-from@0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/new-from/-/new-from-0.0.3.tgz#1c4ad13613de3e15d6321b70ed5c23937ea25e67" + dependencies: + readable-stream "~1.1.8" + +next-tick@1: + version "1.0.0" + resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" + +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + +node-libs-browser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.0.0.tgz#a3a59ec97024985b46e958379646f96c4b616646" + dependencies: + assert "^1.1.1" + browserify-zlib "^0.1.4" + buffer "^4.3.0" + console-browserify "^1.1.0" + constants-browserify "^1.0.0" + crypto-browserify "^3.11.0" + domain-browser "^1.1.1" + events "^1.0.0" + https-browserify "0.0.1" + os-browserify "^0.2.0" + path-browserify "0.0.0" + process "^0.11.0" + punycode "^1.2.4" + querystring-es3 "^0.2.0" + readable-stream "^2.0.5" + stream-browserify "^2.0.1" + stream-http "^2.3.1" + string_decoder "^0.10.25" + timers-browserify "^2.0.2" + tty-browserify "0.0.0" + url "^0.11.0" + util "^0.10.3" + vm-browserify "0.0.4" + +node-pre-gyp@^0.6.36: + version "0.6.37" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.37.tgz#3c872b236b2e266e4140578fe1ee88f693323a05" + dependencies: + mkdirp "^0.5.1" + nopt "^4.0.1" + npmlog "^4.0.2" + rc "^1.1.7" + request "^2.81.0" + rimraf "^2.6.1" + semver "^5.3.0" + tape "^4.6.3" + tar "^2.2.1" + tar-pack "^3.4.0" + +node-releases@^1.0.0-alpha.11: + version "1.0.0-alpha.11" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.0.0-alpha.11.tgz#73c810acc2e5b741a17ddfbb39dfca9ab9359d8a" + dependencies: + semver "^5.3.0" + +node.extend@^1.1.2: + version "1.1.6" + resolved "https://registry.yarnpkg.com/node.extend/-/node.extend-1.1.6.tgz#a7b882c82d6c93a4863a5504bd5de8ec86258b96" + dependencies: + is "^3.1.0" + +nopt@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" + dependencies: + abbrev "1" + osenv "^0.1.4" + +normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: + version "2.4.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" + dependencies: + hosted-git-info "^2.1.4" + is-builtin-module "^1.0.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + +normalize-path@^2.0.0, normalize-path@^2.0.1, normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + dependencies: + remove-trailing-separator "^1.0.1" + +normalize-range@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + +normalize-selector@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/normalize-selector/-/normalize-selector-0.2.0.tgz#d0b145eb691189c63a78d201dc4fdb1293ef0c03" + +normalize-url@^1.4.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" + dependencies: + object-assign "^4.0.1" + prepend-http "^1.0.0" + query-string "^4.1.0" + sort-keys "^1.0.0" + +normalize.css@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-8.0.0.tgz#14ac5e461612538a4ce9be90a7da23f86e718493" + +npm-path@^1.0.0, npm-path@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/npm-path/-/npm-path-1.1.0.tgz#0474ae00419c327d54701b7cf2cd05dc88be1140" + dependencies: + which "^1.2.4" + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + dependencies: + path-key "^2.0.0" + +npm-run@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/npm-run/-/npm-run-3.0.0.tgz#568920f840a98fd8e2299db66b2616e2476caf69" + dependencies: + minimist "^1.1.1" + npm-path "^1.0.1" + npm-which "^2.0.0" + serializerr "^1.0.1" + sync-exec "^0.6.2" + +npm-which@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/npm-which/-/npm-which-2.0.0.tgz#0c46982160b783093661d1d01bd4496d2feabbac" + dependencies: + commander "^2.2.0" + npm-path "^1.0.0" + which "^1.0.5" + +npmlog@^4.0.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.7.3" + set-blocking "~2.0.0" + +nsdeclare@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/nsdeclare/-/nsdeclare-0.1.0.tgz#10daa153642382d3cf2c01a916f4eb20a128b19f" + +num2fraction@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + +oauth-sign@~0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43" + +object-assign@4.X, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + +object-assign@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2" + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-inspect@~1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.3.0.tgz#5b1eb8e6742e2ee83342a637034d844928ba2f6d" + +object-keys@^1.0.8: + version "1.0.11" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d" + +object-keys@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-0.4.0.tgz#28a6aae7428dd2c3a92f3d95f21335dd204e0336" + +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + dependencies: + isobject "^3.0.0" + +object.defaults@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/object.defaults/-/object.defaults-1.1.0.tgz#3a7f868334b407dea06da16d88d5cd29e435fecf" + dependencies: + array-each "^1.0.1" + array-slice "^1.0.0" + for-own "^1.0.0" + isobject "^3.0.0" + +object.omit@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa" + dependencies: + for-own "^0.1.4" + is-extendable "^0.1.1" + +object.pick@^1.2.0, object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + dependencies: + isobject "^3.0.1" + +once@^1.3.0, once@^1.3.1, once@^1.3.3, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + dependencies: + wrappy "1" + +once@~1.3.0: + version "1.3.3" + resolved "https://registry.yarnpkg.com/once/-/once-1.3.3.tgz#b2e261557ce4c314ec8304f3fa82663e4297ca20" + dependencies: + wrappy "1" + +onetime@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" + dependencies: + mimic-fn "^1.0.0" + +optionator@^0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.4" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + wordwrap "~1.0.0" + +orchestrator@^0.3.0: + version "0.3.8" + resolved "https://registry.yarnpkg.com/orchestrator/-/orchestrator-0.3.8.tgz#14e7e9e2764f7315fbac184e506c7aa6df94ad7e" + dependencies: + end-of-stream "~0.1.5" + sequencify "~0.0.7" + stream-consume "~0.1.0" + +ordered-read-streams@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-0.1.0.tgz#fd565a9af8eb4473ba69b6ed8a34352cb552f126" + +os-browserify@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.2.1.tgz#63fc4ccee5d2d7763d26bbf8601078e6c2e0044f" + +os-homedir@^1.0.0, os-homedir@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" + +os-locale@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2" + dependencies: + execa "^0.7.0" + lcid "^1.0.0" + mem "^1.1.0" + +os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + +osenv@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644" + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.0" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + +p-limit@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" + dependencies: + p-try "^1.0.0" + +p-locate@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" + dependencies: + p-limit "^1.1.0" + +p-map@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" + +p-try@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" + +pako@~0.2.0: + version "0.2.9" + resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" + +parallel-transform@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.1.0.tgz#d410f065b05da23081fcd10f28854c29bda33b06" + dependencies: + cyclist "~0.2.2" + inherits "^2.0.3" + readable-stream "^2.1.5" + +parse-asn1@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.0.tgz#37c4f9b7ed3ab65c74817b5f2480937fbf97c712" + dependencies: + asn1.js "^4.0.0" + browserify-aes "^1.0.0" + create-hash "^1.1.0" + evp_bytestokey "^1.0.0" + pbkdf2 "^3.0.3" + +parse-entities@^1.0.2, parse-entities@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.1.2.tgz#9eaf719b29dc3bd62246b4332009072e01527777" + dependencies: + character-entities "^1.0.0" + character-entities-legacy "^1.0.0" + character-reference-invalid "^1.0.0" + is-alphanumerical "^1.0.0" + is-decimal "^1.0.0" + is-hexadecimal "^1.0.0" + +parse-filepath@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parse-filepath/-/parse-filepath-1.0.1.tgz#159d6155d43904d16c10ef698911da1e91969b73" + dependencies: + is-absolute "^0.2.3" + map-cache "^0.2.0" + path-root "^0.1.1" + +parse-glob@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" + dependencies: + glob-base "^0.3.0" + is-dotfile "^1.0.0" + is-extglob "^1.0.0" + is-glob "^2.0.0" + +parse-json@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" + dependencies: + error-ex "^1.2.0" + +parse-json@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + +parse-passwd@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" + +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + +path-browserify@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a" + +path-dirname@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + +path-is-absolute@^1.0.0, path-is-absolute@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + +path-is-inside@^1.0.1, path-is-inside@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" + +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + +path-parse@^1.0.5: + version "1.0.6" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + +path-root-regex@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/path-root-regex/-/path-root-regex-0.1.2.tgz#bfccdc8df5b12dc52c8b43ec38d18d72c04ba96d" + +path-root@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/path-root/-/path-root-0.1.1.tgz#9a4a6814cac1c0cd73360a95f32083c8ea4745b7" + dependencies: + path-root-regex "^0.1.0" + +path-to-regexp@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d" + dependencies: + isarray "0.0.1" + +path-type@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" + dependencies: + pify "^2.0.0" + +path-type@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" + dependencies: + pify "^3.0.0" + +pause-stream@^0.0.11: + version "0.0.11" + resolved "http://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445" + dependencies: + through "~2.3" + +pbkdf2@^3.0.3: + version "3.0.14" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.14.tgz#a35e13c64799b06ce15320f459c230e68e73bade" + dependencies: + create-hash "^1.1.2" + create-hmac "^1.1.4" + ripemd160 "^2.0.1" + safe-buffer "^5.0.1" + sha.js "^2.4.8" + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + +pify@^2.0.0, pify@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" + +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + +pify@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.0.tgz#db04c982b632fd0df9090d14aaf1c8413cadb695" + +pinkie-promise@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" + dependencies: + pinkie "^2.0.0" + +pinkie@^2.0.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870" + +pkg-dir@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" + dependencies: + find-up "^2.1.0" + +plugin-error@1.0.1, plugin-error@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/plugin-error/-/plugin-error-1.0.1.tgz#77016bd8919d0ac377fdcdd0322328953ca5781c" + dependencies: + ansi-colors "^1.0.1" + arr-diff "^4.0.0" + arr-union "^3.1.0" + extend-shallow "^3.0.2" + +plugin-error@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/plugin-error/-/plugin-error-0.1.2.tgz#3b9bb3335ccf00f425e07437e19276967da47ace" + dependencies: + ansi-cyan "^0.1.1" + ansi-red "^0.1.1" + arr-diff "^1.0.1" + arr-union "^2.0.1" + extend-shallow "^1.1.2" + +pluralize@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777" + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + +postcss-calc@^5.2.0: + version "5.3.1" + resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-5.3.1.tgz#77bae7ca928ad85716e2fda42f261bf7c1d65b5e" + dependencies: + postcss "^5.0.2" + postcss-message-helpers "^2.0.0" + reduce-css-calc "^1.2.6" + +postcss-colormin@^2.1.8: + version "2.2.2" + resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-2.2.2.tgz#6631417d5f0e909a3d7ec26b24c8a8d1e4f96e4b" + dependencies: + colormin "^1.0.5" + postcss "^5.0.13" + postcss-value-parser "^3.2.3" + +postcss-convert-values@^2.3.4: + version "2.6.1" + resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-2.6.1.tgz#bbd8593c5c1fd2e3d1c322bb925dcae8dae4d62d" + dependencies: + postcss "^5.0.11" + postcss-value-parser "^3.1.2" + +postcss-discard-comments@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-2.0.4.tgz#befe89fafd5b3dace5ccce51b76b81514be00e3d" + dependencies: + postcss "^5.0.14" + +postcss-discard-duplicates@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-2.1.0.tgz#b9abf27b88ac188158a5eb12abcae20263b91932" + dependencies: + postcss "^5.0.4" + +postcss-discard-empty@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-2.1.0.tgz#d2b4bd9d5ced5ebd8dcade7640c7d7cd7f4f92b5" + dependencies: + postcss "^5.0.14" + +postcss-discard-overridden@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-0.1.1.tgz#8b1eaf554f686fb288cd874c55667b0aa3668d58" + dependencies: + postcss "^5.0.16" + +postcss-discard-unused@^2.2.1: + version "2.2.3" + resolved "https://registry.yarnpkg.com/postcss-discard-unused/-/postcss-discard-unused-2.2.3.tgz#bce30b2cc591ffc634322b5fb3464b6d934f4433" + dependencies: + postcss "^5.0.14" + uniqs "^2.0.0" + +postcss-filter-plugins@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/postcss-filter-plugins/-/postcss-filter-plugins-2.0.2.tgz#6d85862534d735ac420e4a85806e1f5d4286d84c" + dependencies: + postcss "^5.0.4" + uniqid "^4.0.0" + +postcss-html@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/postcss-html/-/postcss-html-0.33.0.tgz#8ab6067d7a8a234e1937920b38760e3be1dca070" + dependencies: + htmlparser2 "^3.9.2" + +postcss-js@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-1.0.1.tgz#ffaf29226e399ea74b5dce02cab1729d7addbc7b" + dependencies: + camelcase-css "^1.0.1" + postcss "^6.0.11" + +postcss-jsx@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/postcss-jsx/-/postcss-jsx-0.33.0.tgz#433f8aadd6f3b0ee403a62b441bca8db9c87471c" + dependencies: + "@babel/core" "^7.0.0-rc.1" + optionalDependencies: + postcss-styled ">=0.33.0" + +postcss-less@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-less/-/postcss-less-2.0.0.tgz#5d190b8e057ca446d60fe2e2587ad791c9029fb8" + dependencies: + postcss "^5.2.16" + +postcss-load-config@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-2.0.0.tgz#f1312ddbf5912cd747177083c5ef7a19d62ee484" + dependencies: + cosmiconfig "^4.0.0" + import-cwd "^2.0.0" + +postcss-loader@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-3.0.0.tgz#6b97943e47c72d845fa9e03f273773d4e8dd6c2d" + dependencies: + loader-utils "^1.1.0" + postcss "^7.0.0" + postcss-load-config "^2.0.0" + schema-utils "^1.0.0" + +postcss-markdown@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/postcss-markdown/-/postcss-markdown-0.33.0.tgz#2d0462742ee108c9d6020780184b499630b8b33a" + dependencies: + remark "^9.0.0" + unist-util-find-all-after "^1.0.2" + +postcss-media-query-parser@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz#27b39c6f4d94f81b1a73b8f76351c609e5cef244" + +postcss-merge-idents@^2.1.5: + version "2.1.7" + resolved "https://registry.yarnpkg.com/postcss-merge-idents/-/postcss-merge-idents-2.1.7.tgz#4c5530313c08e1d5b3bbf3d2bbc747e278eea270" + dependencies: + has "^1.0.1" + postcss "^5.0.10" + postcss-value-parser "^3.1.1" + +postcss-merge-longhand@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-2.0.2.tgz#23d90cd127b0a77994915332739034a1a4f3d658" + dependencies: + postcss "^5.0.4" + +postcss-merge-rules@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-2.1.2.tgz#d1df5dfaa7b1acc3be553f0e9e10e87c61b5f721" + dependencies: + browserslist "^1.5.2" + caniuse-api "^1.5.2" + postcss "^5.0.4" + postcss-selector-parser "^2.2.2" + vendors "^1.0.0" + +postcss-message-helpers@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz#a4f2f4fab6e4fe002f0aed000478cdf52f9ba60e" + +postcss-minify-font-values@^1.0.2: + version "1.0.5" + resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-1.0.5.tgz#4b58edb56641eba7c8474ab3526cafd7bbdecb69" + dependencies: + object-assign "^4.0.1" + postcss "^5.0.4" + postcss-value-parser "^3.0.2" + +postcss-minify-gradients@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-1.0.5.tgz#5dbda11373703f83cfb4a3ea3881d8d75ff5e6e1" + dependencies: + postcss "^5.0.12" + postcss-value-parser "^3.3.0" + +postcss-minify-params@^1.0.4: + version "1.2.2" + resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-1.2.2.tgz#ad2ce071373b943b3d930a3fa59a358c28d6f1f3" + dependencies: + alphanum-sort "^1.0.1" + postcss "^5.0.2" + postcss-value-parser "^3.0.2" + uniqs "^2.0.0" + +postcss-minify-selectors@^2.0.4: + version "2.1.1" + resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-2.1.1.tgz#b2c6a98c0072cf91b932d1a496508114311735bf" + dependencies: + alphanum-sort "^1.0.2" + has "^1.0.1" + postcss "^5.0.14" + postcss-selector-parser "^2.0.0" + +postcss-mixins@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/postcss-mixins/-/postcss-mixins-6.2.0.tgz#fa9d2c2166b2ae7745956c727ab9dd2de4b96a40" + dependencies: + globby "^6.1.0" + postcss "^6.0.13" + postcss-js "^1.0.1" + postcss-simple-vars "^4.1.0" + sugarss "^1.0.0" + +postcss-modules-extract-imports@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.2.0.tgz#66140ecece38ef06bf0d3e355d69bf59d141ea85" + dependencies: + postcss "^6.0.1" + +postcss-modules-local-by-default@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz#f7d80c398c5a393fa7964466bd19500a7d61c069" + dependencies: + css-selector-tokenizer "^0.7.0" + postcss "^6.0.1" + +postcss-modules-scope@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz#d6ea64994c79f97b62a72b426fbe6056a194bb90" + dependencies: + css-selector-tokenizer "^0.7.0" + postcss "^6.0.1" + +postcss-modules-values@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz#ecffa9d7e192518389f42ad0e83f72aec456ea20" + dependencies: + icss-replace-symbols "^1.1.0" + postcss "^6.0.1" + +postcss-nested@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-4.1.0.tgz#271da8a047f2ee378139410ae2400b1c67d0bf30" + dependencies: + postcss "^7.0.2" + postcss-selector-parser "^3.1.1" + +postcss-normalize-charset@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-1.1.1.tgz#ef9ee71212d7fe759c78ed162f61ed62b5cb93f1" + dependencies: + postcss "^5.0.5" + +postcss-normalize-url@^3.0.7: + version "3.0.8" + resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-3.0.8.tgz#108f74b3f2fcdaf891a2ffa3ea4592279fc78222" + dependencies: + is-absolute-url "^2.0.0" + normalize-url "^1.4.0" + postcss "^5.0.14" + postcss-value-parser "^3.2.3" + +postcss-ordered-values@^2.1.0: + version "2.2.3" + resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-2.2.3.tgz#eec6c2a67b6c412a8db2042e77fe8da43f95c11d" + dependencies: + postcss "^5.0.4" + postcss-value-parser "^3.0.1" + +postcss-reduce-idents@^2.2.2: + version "2.4.0" + resolved "https://registry.yarnpkg.com/postcss-reduce-idents/-/postcss-reduce-idents-2.4.0.tgz#c2c6d20cc958284f6abfbe63f7609bf409059ad3" + dependencies: + postcss "^5.0.4" + postcss-value-parser "^3.0.2" + +postcss-reduce-initial@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-1.0.1.tgz#68f80695f045d08263a879ad240df8dd64f644ea" + dependencies: + postcss "^5.0.4" + +postcss-reduce-transforms@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-1.0.4.tgz#ff76f4d8212437b31c298a42d2e1444025771ae1" + dependencies: + has "^1.0.1" + postcss "^5.0.8" + postcss-value-parser "^3.0.1" + +postcss-reporter@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-reporter/-/postcss-reporter-5.0.0.tgz#a14177fd1342829d291653f2786efd67110332c3" + dependencies: + chalk "^2.0.1" + lodash "^4.17.4" + log-symbols "^2.0.0" + postcss "^6.0.8" + +postcss-resolve-nested-selector@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz#29ccbc7c37dedfac304e9fff0bf1596b3f6a0e4e" + +postcss-safe-parser@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/postcss-safe-parser/-/postcss-safe-parser-4.0.1.tgz#8756d9e4c36fdce2c72b091bbc8ca176ab1fcdea" + dependencies: + postcss "^7.0.0" + +postcss-sass@^0.3.0: + version "0.3.3" + resolved "https://registry.yarnpkg.com/postcss-sass/-/postcss-sass-0.3.3.tgz#bec188ac285d21ac8feba194c2f327fdda31e671" + dependencies: + gonzales-pe "^4.2.3" + postcss "^7.0.1" + +postcss-scss@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/postcss-scss/-/postcss-scss-2.0.0.tgz#248b0a28af77ea7b32b1011aba0f738bda27dea1" + dependencies: + postcss "^7.0.0" + +postcss-selector-parser@^2.0.0, postcss-selector-parser@^2.2.2: + version "2.2.3" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-2.2.3.tgz#f9437788606c3c9acee16ffe8d8b16297f27bb90" + dependencies: + flatten "^1.0.2" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-selector-parser@^3.1.0, postcss-selector-parser@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz#4f875f4afb0c96573d5cf4d74011aee250a7e865" + dependencies: + dot-prop "^4.1.1" + indexes-of "^1.0.1" + uniq "^1.0.1" + +postcss-simple-vars@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/postcss-simple-vars/-/postcss-simple-vars-5.0.1.tgz#850971fdfedf236ea1c815569ce261dab8623aa2" + dependencies: + postcss "^7.0.2" + +postcss-simple-vars@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/postcss-simple-vars/-/postcss-simple-vars-4.1.0.tgz#043248cfef8d3f51b3486a28c09f8375dbf1b2f9" + dependencies: + postcss "^6.0.9" + +postcss-sorting@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-sorting/-/postcss-sorting-4.0.0.tgz#abfdf41ff8f7710f66f5dc7e78a3a3cce3983c21" + dependencies: + lodash "^4.17.4" + postcss "^7.0.0" + +postcss-styled@>=0.33.0, postcss-styled@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/postcss-styled/-/postcss-styled-0.33.0.tgz#69be377584105a582fda7e4f76888e5b97eed737" + +postcss-svgo@^2.1.1: + version "2.1.6" + resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-2.1.6.tgz#b6df18aa613b666e133f08adb5219c2684ac108d" + dependencies: + is-svg "^2.0.0" + postcss "^5.0.14" + postcss-value-parser "^3.2.3" + svgo "^0.7.0" + +postcss-syntax@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/postcss-syntax/-/postcss-syntax-0.33.0.tgz#59c0c678d2f9ecefa84c6ce9ef46fc805c54ab3a" + +postcss-unique-selectors@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-2.0.2.tgz#981d57d29ddcb33e7b1dfe1fd43b8649f933ca1d" + dependencies: + alphanum-sort "^1.0.1" + postcss "^5.0.4" + uniqs "^2.0.0" + +postcss-value-parser@^3.0.1, postcss-value-parser@^3.0.2, postcss-value-parser@^3.1.1, postcss-value-parser@^3.1.2, postcss-value-parser@^3.2.3, postcss-value-parser@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz#87f38f9f18f774a4ab4c8a232f5c5ce8872a9d15" + +postcss-zindex@^2.0.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/postcss-zindex/-/postcss-zindex-2.2.0.tgz#d2109ddc055b91af67fc4cb3b025946639d2af22" + dependencies: + has "^1.0.1" + postcss "^5.0.4" + uniqs "^2.0.0" + +postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0.13, postcss@^5.0.14, postcss@^5.0.16, postcss@^5.0.19, postcss@^5.0.2, postcss@^5.0.4, postcss@^5.0.5, postcss@^5.0.6, postcss@^5.0.8: + version "5.2.17" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.17.tgz#cf4f597b864d65c8a492b2eabe9d706c879c388b" + dependencies: + chalk "^1.1.3" + js-base64 "^2.1.9" + source-map "^0.5.6" + supports-color "^3.2.3" + +postcss@^5.2.16: + version "5.2.18" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.18.tgz#badfa1497d46244f6390f58b319830d9107853c5" + dependencies: + chalk "^1.1.3" + js-base64 "^2.1.9" + source-map "^0.5.6" + supports-color "^3.2.3" + +postcss@^6.0.0, postcss@^6.0.1, postcss@^6.0.11: + version "6.0.11" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.11.tgz#f48db210b1d37a7f7ab6499b7a54982997ab6f72" + dependencies: + chalk "^2.1.0" + source-map "^0.5.7" + supports-color "^4.4.0" + +postcss@^6.0.13, postcss@^6.0.8: + version "6.0.23" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324" + dependencies: + chalk "^2.4.1" + source-map "^0.6.1" + supports-color "^5.4.0" + +postcss@^6.0.9: + version "6.0.12" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.12.tgz#6b0155089d2d212f7bd6a0cecd4c58c007403535" + dependencies: + chalk "^2.1.0" + source-map "^0.5.7" + supports-color "^4.4.0" + +postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.2.tgz#7b5a109de356804e27f95a960bef0e4d5bc9bb18" + dependencies: + chalk "^2.4.1" + source-map "^0.6.1" + supports-color "^5.4.0" + +prefix-style@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/prefix-style/-/prefix-style-2.0.1.tgz#66bba9a870cfda308a5dc20e85e9120932c95a06" + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + +prepend-http@^1.0.0: + version "1.0.4" + resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" + +preserve@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" + +pretty-hrtime@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" + +private@^0.1.6, private@^0.1.7: + version "0.1.7" + resolved "https://registry.yarnpkg.com/private/-/private-0.1.7.tgz#68ce5e8a1ef0a23bb570cc28537b5332aba63ef1" + +private@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" + +process-nextick-args@^1.0.6, process-nextick-args@~1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3" + +process-nextick-args@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa" + +process@^0.11.0: + version "0.11.10" + resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" + +progress@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f" + +promise-inflight@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" + +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + dependencies: + asap "~2.0.3" + +prop-types@15.6.2, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2: + version "15.6.2" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102" + dependencies: + loose-envify "^1.3.1" + object-assign "^4.1.1" + +prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.8: + version "15.5.10" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154" + dependencies: + fbjs "^0.8.9" + loose-envify "^1.3.1" + +prop-types@^15.5.7: + version "15.6.0" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856" + dependencies: + fbjs "^0.8.16" + loose-envify "^1.3.1" + object-assign "^4.1.1" + +protochain@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/protochain/-/protochain-1.0.5.tgz#991c407e99de264aadf8f81504b5e7faf7bfa260" + +prr@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a" + +prr@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" + +pseudomap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + +public-encrypt@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.0.tgz#39f699f3a46560dd5ebacbca693caf7c65c18cc6" + dependencies: + bn.js "^4.1.0" + browserify-rsa "^4.0.0" + create-hash "^1.1.0" + parse-asn1 "^5.0.0" + randombytes "^2.0.1" + +pump@^2.0.0, pump@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +pumpify@^1.3.3: + version "1.5.1" + resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce" + dependencies: + duplexify "^3.6.0" + inherits "^2.0.3" + pump "^2.0.0" + +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + +punycode@^1.2.4, punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + +punycode@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + +q@^1.1.2: + version "1.5.0" + resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1" + +qs@6.5.2, qs@^6.4.0: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + +qs@~6.5.1: + version "6.5.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" + +query-string@^4.1.0: + version "4.2.2" + resolved "https://registry.npmjs.org/query-string/-/query-string-4.2.2.tgz#888a6fcb6f76070ba39f2f3025c87099defa1645" + dependencies: + object-assign "^4.1.0" + strict-uri-encode "^1.0.0" + +querystring-es3@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" + +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + +quick-lru@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8" + +raf@^3.1.0: + version "3.3.2" + resolved "https://registry.yarnpkg.com/raf/-/raf-3.3.2.tgz#0c13be0b5b49b46f76d6669248d527cf2b02fe27" + dependencies: + performance-now "^2.1.0" + +randomatic@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.0.tgz#36f2ca708e9e567f5ed2ec01949026d50aa10116" + dependencies: + is-number "^4.0.0" + kind-of "^6.0.0" + math-random "^1.0.1" + +randombytes@^2.0.0, randombytes@^2.0.1: + version "2.0.5" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.0.5.tgz#dc009a246b8d09a177b4b7a0ae77bc570f4b1b79" + dependencies: + safe-buffer "^5.1.0" + +raw-body@~1.1.0: + version "1.1.7" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-1.1.7.tgz#1d027c2bfa116acc6623bca8f00016572a87d425" + dependencies: + bytes "1" + string_decoder "0.10" + +rc@^1.1.7: + version "1.2.1" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.1.tgz#2e03e8e42ee450b8cb3dce65be1bf8974e1dfd95" + dependencies: + deep-extend "~0.4.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +react-addons-shallow-compare@15.6.2: + version "15.6.2" + resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.2.tgz#198a00b91fc37623db64a28fd17b596ba362702f" + dependencies: + fbjs "^0.8.4" + object-assign "^4.1.0" + +react-async-script@1.0.0, react-async-script@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/react-async-script/-/react-async-script-1.0.0.tgz#3578153247bc3f9654a5878c4142539ffbf65c2d" + dependencies: + hoist-non-react-statics "^3.0.1" + prop-types "^15.5.0" + +react-autosuggest@9.4.1: + version "9.4.1" + resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-9.4.1.tgz#fe636b196eaffaf1d29283c2fc55f8a93cf3666d" + dependencies: + prop-types "^15.5.10" + react-autowhatever "^10.1.2" + shallow-equal "^1.0.0" + +react-autowhatever@^10.1.2: + version "10.1.2" + resolved "https://registry.yarnpkg.com/react-autowhatever/-/react-autowhatever-10.1.2.tgz#200ffc41373b2189e3f6140ac7bdb82363a79fd3" + dependencies: + prop-types "^15.5.8" + react-themeable "^1.1.0" + section-iterator "^2.0.0" + +react-custom-scrollbars@4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/react-custom-scrollbars/-/react-custom-scrollbars-4.2.1.tgz#830fd9502927e97e8a78c2086813899b2a8b66db" + dependencies: + dom-css "^2.0.0" + prop-types "^15.5.10" + raf "^3.1.0" + +react-dnd-html5-backend@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-5.0.1.tgz#0b578d79c5c01317c70414c8d717f632b919d4f1" + dependencies: + autobind-decorator "^2.1.0" + dnd-core "^4.0.5" + lodash "^4.17.10" + shallowequal "^1.0.2" + +react-dnd@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-5.0.0.tgz#c4a17c70109e456dad8906be838e6ee8f32b06b5" + dependencies: + dnd-core "^4.0.5" + hoist-non-react-statics "^2.5.0" + invariant "^2.1.0" + lodash "^4.17.10" + recompose "^0.27.1" + shallowequal "^1.0.2" + +react-document-title@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/react-document-title/-/react-document-title-2.0.3.tgz#bbf922a0d71412fc948245e4283b2412df70f2b9" + dependencies: + prop-types "^15.5.6" + react-side-effect "^1.0.2" + +react-dom@16.5.2: + version "16.5.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.5.2.tgz#b69ee47aa20bab5327b2b9d7c1fe2a30f2cfa9d7" + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + schedule "^0.5.0" + +react-google-recaptcha@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-1.0.2.tgz#d77d0c91558d07c2f9f22b8ec05c383cbdbcabac" + dependencies: + prop-types "^15.5.0" + react-async-script "^1.0.0" + +react-is@^16.3.2: + version "16.5.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.5.1.tgz#c6e8734fd548a22e1cef4fd0833afbeb433b85ee" + +react-lazyload@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/react-lazyload/-/react-lazyload-2.3.0.tgz#ccb134223012447074a96543954f44b055dc6185" + +react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + +react-measure@1.4.7: + version "1.4.7" + resolved "https://registry.yarnpkg.com/react-measure/-/react-measure-1.4.7.tgz#a1d2ca0dcfef04978b7ac263a765dcb6a0936fdb" + dependencies: + get-node-dimensions "^1.2.0" + prop-types "^15.5.4" + resize-observer-polyfill "^1.4.1" + +react-redux@5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.7.tgz#0dc1076d9afb4670f993ffaef44b8f8c1155a4c8" + dependencies: + hoist-non-react-statics "^2.5.0" + invariant "^2.0.0" + lodash "^4.17.5" + lodash-es "^4.17.5" + loose-envify "^1.1.0" + prop-types "^15.6.0" + +react-router-dom@4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.3.1.tgz#4c2619fc24c4fa87c9fd18f4fb4a43fe63fbd5c6" + dependencies: + history "^4.7.2" + invariant "^2.2.4" + loose-envify "^1.3.1" + prop-types "^15.6.1" + react-router "^4.3.1" + warning "^4.0.1" + +react-router-redux@5.0.0-alpha.6: + version "5.0.0-alpha.6" + resolved "https://registry.yarnpkg.com/react-router-redux/-/react-router-redux-5.0.0-alpha.6.tgz#7418663c2ecd3c51be856fcf28f3d1deecc1a576" + dependencies: + history "^4.5.1" + prop-types "^15.5.4" + react-router "^4.1.1" + +react-router@^4.1.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-4.2.0.tgz#61f7b3e3770daeb24062dae3eedef1b054155986" + dependencies: + history "^4.7.2" + hoist-non-react-statics "^2.3.0" + invariant "^2.2.2" + loose-envify "^1.3.1" + path-to-regexp "^1.7.0" + prop-types "^15.5.4" + warning "^3.0.0" + +react-router@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-4.3.1.tgz#aada4aef14c809cb2e686b05cee4742234506c4e" + dependencies: + history "^4.7.2" + hoist-non-react-statics "^2.5.0" + invariant "^2.2.4" + loose-envify "^1.3.1" + path-to-regexp "^1.7.0" + prop-types "^15.6.1" + warning "^4.0.1" + +react-side-effect@^1.0.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-1.1.3.tgz#512c25abe0dec172834c4001ec5c51e04d41bc5c" + dependencies: + exenv "^1.2.1" + shallowequal "^1.0.1" + +react-slider@0.11.2: + version "0.11.2" + resolved "https://registry.yarnpkg.com/react-slider/-/react-slider-0.11.2.tgz#ae014e1454c3cdd5f28b5c2495b2a08abd9971e6" + +react-tabs@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-2.3.0.tgz#0c37e786f288d369824acd06a96bd1818ab8b0dc" + dependencies: + classnames "^2.2.0" + prop-types "^15.5.0" + +react-tether@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/react-tether/-/react-tether-1.0.1.tgz#6e5173764d4f9b8bef6d1b20ff51972909674942" + dependencies: + prop-types "^15.5.8" + tether "^1.4.3" + +react-text-truncate@0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/react-text-truncate/-/react-text-truncate-0.13.1.tgz#0632cbf8bdd5f7826865c7cc55dc8a2c3a2c9147" + dependencies: + prop-types "^15.5.7" + +react-themeable@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/react-themeable/-/react-themeable-1.1.0.tgz#7d4466dd9b2b5fa75058727825e9f152ba379a0e" + dependencies: + object-assign "^3.0.0" + +react-virtualized@9.20.1: + version "9.20.1" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.20.1.tgz#02dc08fe9070386b8c48e2ac56bce7af0208d22d" + dependencies: + babel-runtime "^6.26.0" + classnames "^2.2.3" + dom-helpers "^2.4.0 || ^3.0.0" + loose-envify "^1.3.0" + prop-types "^15.6.0" + react-lifecycles-compat "^3.0.4" + +react@16.5.2: + version "16.5.2" + resolved "https://registry.yarnpkg.com/react/-/react-16.5.2.tgz#19f6b444ed139baa45609eee6dc3d318b3895d42" + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + schedule "^0.5.0" + +read-pkg-up@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" + dependencies: + find-up "^2.0.0" + read-pkg "^2.0.0" + +read-pkg-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-3.0.0.tgz#3ed496685dba0f8fe118d0691dc51f4a1ff96f07" + dependencies: + find-up "^2.0.0" + read-pkg "^3.0.0" + +read-pkg@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8" + dependencies: + load-json-file "^2.0.0" + normalize-package-data "^2.3.2" + path-type "^2.0.0" + +read-pkg@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" + dependencies: + load-json-file "^4.0.0" + normalize-package-data "^2.3.2" + path-type "^3.0.0" + +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.4: + version "2.3.6" + resolved "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +"readable-stream@>=1.0.33-1 <1.1.0-0", readable-stream@~1.0.17: + version "1.0.34" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@^1.0.33, readable-stream@~1.1.8, readable-stream@~1.1.9: + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@^2.0.1, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.6, readable-stream@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~1.0.6" + safe-buffer "~5.1.1" + string_decoder "~1.0.3" + util-deprecate "~1.0.1" + +readdirp@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78" + dependencies: + graceful-fs "^4.1.2" + minimatch "^3.0.2" + readable-stream "^2.0.2" + set-immediate-shim "^1.0.1" + +rechoir@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" + dependencies: + resolve "^1.1.6" + +recompose@^0.27.1: + version "0.27.1" + resolved "https://registry.yarnpkg.com/recompose/-/recompose-0.27.1.tgz#1a49e931f183634516633bbb4f4edbfd3f38a7ba" + dependencies: + babel-runtime "^6.26.0" + change-emitter "^0.1.2" + fbjs "^0.8.1" + hoist-non-react-statics "^2.3.1" + react-lifecycles-compat "^3.0.2" + symbol-observable "^1.0.4" + +redent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-2.0.0.tgz#c1b2007b42d57eb1389079b3c8333639d5e1ccaa" + dependencies: + indent-string "^3.0.0" + strip-indent "^2.0.0" + +reduce-css-calc@^1.2.6: + version "1.3.0" + resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz#747c914e049614a4c9cfbba629871ad1d2927716" + dependencies: + balanced-match "^0.4.2" + math-expression-evaluator "^1.2.14" + reduce-function-call "^1.0.1" + +reduce-function-call@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/reduce-function-call/-/reduce-function-call-1.0.2.tgz#5a200bf92e0e37751752fe45b0ab330fd4b6be99" + dependencies: + balanced-match "^0.4.2" + +reduce-reducers@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/reduce-reducers/-/reduce-reducers-0.1.2.tgz#fa1b4718bc5292a71ddd1e5d839c9bea9770f14b" + +redux-actions@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/redux-actions/-/redux-actions-2.6.1.tgz#42c06e94739fbe6db35db3605abb105bdb3724d8" + dependencies: + invariant "^2.2.1" + lodash.camelcase "^4.3.0" + lodash.curry "^4.1.1" + reduce-reducers "^0.1.0" + +redux-batched-actions@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/redux-batched-actions/-/redux-batched-actions-0.4.0.tgz#ad7ae145bffc0ff4eca2509314731ab358910429" + +redux-localstorage@0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/redux-localstorage/-/redux-localstorage-0.4.1.tgz#faf6d719c581397294d811473ffcedee065c933c" + +redux-thunk@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + +redux@4.0.0, redux@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.0.tgz#aa698a92b729315d22b34a0553d7e6533555cc03" + dependencies: + loose-envify "^1.1.0" + symbol-observable "^1.2.0" + +regenerate@^1.2.1: + version "1.3.3" + resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.3.tgz#0c336d3980553d755c39b586ae3b20aa49c82b7f" + +regenerator-runtime@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.0.tgz#7e54fe5b5ccd5d6624ea6255c3473be090b802e1" + +regenerator-transform@^0.10.0: + version "0.10.1" + resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.10.1.tgz#1e4996837231da8b7f3cf4114d71b5691a0680dd" + dependencies: + babel-runtime "^6.18.0" + babel-types "^6.19.0" + private "^0.1.6" + +regex-cache@^0.4.2: + version "0.4.4" + resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd" + dependencies: + is-equal-shallow "^0.1.3" + +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +regexpp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.0.tgz#b2a7534a85ca1b033bcf5ce9ff8e56d4e0755365" + +regexpu-core@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b" + dependencies: + regenerate "^1.2.1" + regjsgen "^0.2.0" + regjsparser "^0.1.4" + +regexpu-core@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-2.0.0.tgz#49d038837b8dcf8bfa5b9a42139938e6ea2ae240" + dependencies: + regenerate "^1.2.1" + regjsgen "^0.2.0" + regjsparser "^0.1.4" + +regjsgen@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7" + +regjsparser@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c" + dependencies: + jsesc "~0.5.0" + +remark-parse@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-5.0.0.tgz#4c077f9e499044d1d5c13f80d7a98cf7b9285d95" + dependencies: + collapse-white-space "^1.0.2" + is-alphabetical "^1.0.0" + is-decimal "^1.0.0" + is-whitespace-character "^1.0.0" + is-word-character "^1.0.0" + markdown-escapes "^1.0.0" + parse-entities "^1.1.0" + repeat-string "^1.5.4" + state-toggle "^1.0.0" + trim "0.0.1" + trim-trailing-lines "^1.0.0" + unherit "^1.0.4" + unist-util-remove-position "^1.0.0" + vfile-location "^2.0.0" + xtend "^4.0.1" + +remark-stringify@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-5.0.0.tgz#336d3a4d4a6a3390d933eeba62e8de4bd280afba" + dependencies: + ccount "^1.0.0" + is-alphanumeric "^1.0.0" + is-decimal "^1.0.0" + is-whitespace-character "^1.0.0" + longest-streak "^2.0.1" + markdown-escapes "^1.0.0" + markdown-table "^1.1.0" + mdast-util-compact "^1.0.0" + parse-entities "^1.0.2" + repeat-string "^1.5.4" + state-toggle "^1.0.0" + stringify-entities "^1.0.1" + unherit "^1.0.4" + xtend "^4.0.1" + +remark@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/remark/-/remark-9.0.0.tgz#c5cfa8ec535c73a67c4b0f12bfdbd3a67d8b2f60" + dependencies: + remark-parse "^5.0.0" + remark-stringify "^5.0.0" + unified "^6.0.0" + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + +repeat-element@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" + +repeat-string@^1.5.0, repeat-string@^1.5.2, repeat-string@^1.5.4, repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + +repeating@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda" + dependencies: + is-finite "^1.0.0" + +replace-ext@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924" + +replace-ext@1.0.0, replace-ext@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" + +request@^2.81.0: + version "2.82.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.82.0.tgz#2ba8a92cd7ac45660ea2b10a53ae67cd247516ea" + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.6.0" + caseless "~0.12.0" + combined-stream "~1.0.5" + extend "~3.0.1" + forever-agent "~0.6.1" + form-data "~2.3.1" + har-validator "~5.0.3" + hawk "~6.0.2" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.17" + oauth-sign "~0.8.2" + performance-now "^2.1.0" + qs "~6.5.1" + safe-buffer "^5.1.1" + stringstream "~0.0.5" + tough-cookie "~2.3.2" + tunnel-agent "^0.6.0" + uuid "^3.1.0" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + +require-from-string@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.1.tgz#c545233e9d7da6616e9d59adfb39fc9f588676ff" + +require-main-filename@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" + +require-nocache@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/require-nocache/-/require-nocache-1.0.0.tgz#a665d0b60a07e8249875790a4d350219d3c85fa3" + +require-uncached@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/require-uncached/-/require-uncached-1.0.3.tgz#4e0d56d6c9662fd31e43011c4b95aa49955421d3" + dependencies: + caller-path "^0.1.0" + resolve-from "^1.0.0" + +reselect@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-3.0.1.tgz#efdaa98ea7451324d092b2b2163a6a1d7a9a2147" + +resize-observer-polyfill@^1.4.1: + version "1.4.2" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.4.2.tgz#a37198e6209e888acb1532a9968e06d38b6788e5" + +resolve-dir@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-0.1.1.tgz#b219259a5602fac5c5c496ad894a6e8cc430261e" + dependencies: + expand-tilde "^1.2.2" + global-modules "^0.2.3" + +resolve-from@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226" + +resolve-from@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + +resolve-pathname@^2.0.0, resolve-pathname@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-2.2.0.tgz#7e9ae21ed815fd63ab189adeee64dc831eefa879" + +resolve-url@^0.2.1, resolve-url@~0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + +resolve@^1.1.5, resolve@^1.1.6, resolve@^1.1.7, resolve@~1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.4.0.tgz#a75be01c53da25d934a98ebd0e4c4a7312f92a86" + dependencies: + path-parse "^1.0.5" + +resolve@^1.3.2: + version "1.8.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26" + dependencies: + path-parse "^1.0.5" + +restore-cursor@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" + dependencies: + onetime "^2.0.0" + signal-exit "^3.0.2" + +resumer@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/resumer/-/resumer-0.0.0.tgz#f1e8f461e4064ba39e82af3cdc2a8c893d076759" + dependencies: + through "~2.3.4" + +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + +right-align@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef" + dependencies: + align-text "^0.1.1" + +rimraf@2, rimraf@^2.2.8, rimraf@^2.5.1, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" + dependencies: + glob "^7.0.5" + +ripemd160@^2.0.0, ripemd160@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.1.tgz#0f4584295c53a3628af7e6d79aca21ce57d1c6e7" + dependencies: + hash-base "^2.0.0" + inherits "^2.0.1" + +rocambole-indent@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/rocambole-indent/-/rocambole-indent-2.0.4.tgz#a18a24977ca0400b861daa4631e861dcb52d085c" + dependencies: + debug "^2.1.3" + mout "^0.11.0" + rocambole-token "^1.2.1" + +rocambole-linebreak@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/rocambole-linebreak/-/rocambole-linebreak-1.0.2.tgz#03621515b43b4721c97e5a1c1bca5a0366368f2f" + dependencies: + debug "^2.1.3" + rocambole-token "^1.2.1" + semver "^4.3.1" + +rocambole-node@~1.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/rocambole-node/-/rocambole-node-1.0.0.tgz#db5b49de7407b0080dd514872f28e393d0f7ff3f" + +rocambole-token@^1.1.2, rocambole-token@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/rocambole-token/-/rocambole-token-1.2.1.tgz#c785df7428dc3cb27ad7897047bd5238cc070d35" + +rocambole-whitespace@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/rocambole-whitespace/-/rocambole-whitespace-1.0.0.tgz#63330949256b29941f59b190459f999c6b1d3bf9" + dependencies: + debug "^2.1.3" + repeat-string "^1.5.0" + rocambole-token "^1.2.1" + +"rocambole@>=0.7 <2.0", rocambole@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/rocambole/-/rocambole-0.7.0.tgz#f6c79505517dc42b6fb840842b8b953b0f968585" + dependencies: + esprima "^2.1" + +run-async@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" + dependencies: + is-promise "^2.1.0" + +run-queue@^1.0.0, run-queue@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47" + dependencies: + aproba "^1.1.1" + +run-sequence@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/run-sequence/-/run-sequence-2.2.1.tgz#1ce643da36fd8c7ea7e1a9329da33fc2b8898495" + dependencies: + chalk "^1.1.3" + fancy-log "^1.3.2" + plugin-error "^0.1.2" + +rxjs@^6.1.0: + version "6.3.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.3.2.tgz#6a688b16c4e6e980e62ea805ec30648e1c60907f" + dependencies: + tslib "^1.9.0" + +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + +safe-json-parse@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/safe-json-parse/-/safe-json-parse-1.0.1.tgz#3e76723e38dfdda13c9b1d29a1e07ffee4b30b57" + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + dependencies: + ret "~0.1.10" + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + +sane@^1.6.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/sane/-/sane-1.7.0.tgz#b3579bccb45c94cf20355cc81124990dfd346e30" + dependencies: + anymatch "^1.3.0" + exec-sh "^0.2.0" + fb-watchman "^2.0.0" + minimatch "^3.0.2" + minimist "^1.1.1" + walker "~1.0.5" + watch "~0.10.0" + +sax@~1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + +schedule@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/schedule/-/schedule-0.5.0.tgz#c128fffa0b402488b08b55ae74bb9df55cc29cc8" + dependencies: + object-assign "^4.1.1" + +schema-utils@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.3.0.tgz#f5877222ce3e931edae039f17eb3716e7137f8cf" + dependencies: + ajv "^5.0.0" + +schema-utils@^0.4.5: + version "0.4.5" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.5.tgz#21836f0608aac17b78f9e3e24daff14a5ca13a3e" + dependencies: + ajv "^6.1.0" + ajv-keywords "^3.1.0" + +schema-utils@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" + dependencies: + ajv "^6.1.0" + ajv-errors "^1.0.0" + ajv-keywords "^3.1.0" + +section-iterator@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a" + +select@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" + +"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1: + version "5.5.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.1.tgz#7dfdd8814bdb7cabc7be0fb1d734cfb66c940477" + +semver@^4.1.0, semver@^4.3.1: + version "4.3.6" + resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.6.tgz#300bc6e0e86374f7ba61068b5b1ecd57fc6532da" + +sequencify@~0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/sequencify/-/sequencify-0.0.7.tgz#90cff19d02e07027fd767f5ead3e7b95d1e7380c" + +serialize-javascript@^1.4.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.5.0.tgz#1aa336162c88a890ddad5384baebc93a655161fe" + +serializerr@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/serializerr/-/serializerr-1.0.3.tgz#12d4c5aa1c3ffb8f6d1dc5f395aa9455569c3f91" + dependencies: + protochain "^1.0.5" + +set-blocking@^2.0.0, set-blocking@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + +set-immediate-shim@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" + +set-value@^0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1" + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.1" + to-object-path "^0.3.0" + +set-value@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274" + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +setimmediate@^1.0.4, setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + +sha.js@^2.4.0, sha.js@^2.4.8: + version "2.4.9" + resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.9.tgz#98f64880474b74f4a38b8da9d3c0f2d104633e7d" + dependencies: + inherits "^2.0.1" + safe-buffer "^5.0.1" + +shallow-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.0.0.tgz#508d1838b3de590ab8757b011b25e430900945f7" + +shallowequal@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.0.2.tgz#1561dbdefb8c01408100319085764da3fcf83f8f" + +shallowequal@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + +sigmund@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" + +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" + +signalr@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/signalr/-/signalr-2.4.0.tgz#92af008e6b527ad4b36e94fbb340e135087a60d2" + dependencies: + jquery ">=1.6.4" + +slash@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" + +slice-ansi@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-1.0.0.tgz#044f1a49d8842ff307aad6b505ed178bd950134d" + dependencies: + is-fullwidth-code-point "^2.0.0" + +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^3.1.0" + +sntp@2.x.x: + version "2.0.2" + resolved "https://registry.yarnpkg.com/sntp/-/sntp-2.0.2.tgz#5064110f0af85f7cfdb7d6b67a40028ce52b4b2b" + dependencies: + hoek "4.x.x" + +sort-keys@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" + dependencies: + is-plain-obj "^1.0.0" + +source-list-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.0.tgz#aaa47403f7b245a92fbc97ea08f250d6087ed085" + +source-map-resolve@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.3.1.tgz#610f6122a445b8dd51535a2a71b783dfc1248761" + dependencies: + atob "~1.1.0" + resolve-url "~0.2.1" + source-map-url "~0.3.0" + urix "~0.1.0" + +source-map-resolve@^0.5.0: + version "0.5.2" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259" + dependencies: + atob "^2.1.1" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-support@^0.4.15: + version "0.4.18" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f" + dependencies: + source-map "^0.5.6" + +source-map-url@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" + +source-map-url@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.3.0.tgz#7ecaf13b57bcd09da8a40c5d269db33799d4aaf9" + +source-map@^0.1.38: + version "0.1.43" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.1.43.tgz#c24bc146ca517c1471f5dacbe2571b2b7f9e3346" + dependencies: + amdefine ">=0.0.4" + +source-map@^0.5.0, source-map@^0.5.1, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7, source-map@~0.5.1, source-map@~0.5.3: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + +source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + +sparkles@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/sparkles/-/sparkles-1.0.0.tgz#1acbbfb592436d10bbe8f785b7cc6f82815012c3" + +spdx-correct@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.0.0.tgz#05a5b4d7153a195bc92c3c425b69f3b2a9524c82" + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.1.0.tgz#2c7ae61056c714a5b9b9b2b2af7d311ef5c78fe9" + +spdx-expression-parse@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0" + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.1.tgz#e2a303236cac54b04031fa7a5a79c7e701df852f" + +specificity@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/specificity/-/specificity-0.4.1.tgz#aab5e645012db08ba182e151165738d00887b019" + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + dependencies: + extend-shallow "^3.0.0" + +split@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/split/-/split-1.0.1.tgz#605bd9be303aa59fb35f9229fbea0ddec9ea07d9" + dependencies: + through "2" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + +sshpk@^1.7.0: + version "1.13.1" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.13.1.tgz#512df6da6287144316dc4c18fe1cf1d940739be3" + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + dashdash "^1.12.0" + getpass "^0.1.1" + optionalDependencies: + bcrypt-pbkdf "^1.0.0" + ecc-jsbn "~0.1.1" + jsbn "~0.1.0" + tweetnacl "~0.14.0" + +ssri@^5.2.4: + version "5.3.0" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-5.3.0.tgz#ba3872c9c6d33a0704a7d71ff045e5ec48999d06" + dependencies: + safe-buffer "^5.1.1" + +state-toggle@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.1.tgz#c3cb0974f40a6a0f8e905b96789eb41afa1cde3a" + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + +stdin@*: + version "0.0.1" + resolved "https://registry.yarnpkg.com/stdin/-/stdin-0.0.1.tgz#d3041981aaec3dfdbc77a1b38d6372e38f5fb71e" + +stream-browserify@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db" + dependencies: + inherits "~2.0.1" + readable-stream "^2.0.2" + +stream-combiner@^0.2.2: + version "0.2.2" + resolved "http://registry.npmjs.org/stream-combiner/-/stream-combiner-0.2.2.tgz#aec8cbac177b56b6f4fa479ced8c1912cee52858" + dependencies: + duplexer "~0.1.1" + through "~2.3.4" + +stream-consume@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/stream-consume/-/stream-consume-0.1.0.tgz#a41ead1a6d6081ceb79f65b061901b6d8f3d1d0f" + +stream-each@^1.1.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.2.tgz#8e8c463f91da8991778765873fe4d960d8f616bd" + dependencies: + end-of-stream "^1.1.0" + stream-shift "^1.0.0" + +stream-http@^2.3.1: + version "2.7.2" + resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.7.2.tgz#40a050ec8dc3b53b33d9909415c02c0bf1abfbad" + dependencies: + builtin-status-codes "^3.0.0" + inherits "^2.0.1" + readable-stream "^2.2.6" + to-arraybuffer "^1.0.0" + xtend "^4.0.0" + +stream-shift@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.0.tgz#d5c752825e5367e786f78e18e445ea223a155952" + +streamqueue@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/streamqueue/-/streamqueue-1.1.2.tgz#6c99c7c20d62b57f5819296bf9ec942542380192" + dependencies: + isstream "^0.1.2" + readable-stream "^2.3.3" + +strict-uri-encode@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" + +string-template@~0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" + +string-width@^1.0.1, string-width@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +string.prototype.trim@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.1.2.tgz#d04de2c89e137f4d7d206f086b5ed2fae6be8cea" + dependencies: + define-properties "^1.1.2" + es-abstract "^1.5.0" + function-bind "^1.0.2" + +string_decoder@0.10, string_decoder@^0.10.25, string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + +string_decoder@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab" + dependencies: + safe-buffer "~5.1.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + dependencies: + safe-buffer "~5.1.0" + +stringify-entities@^1.0.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-1.3.2.tgz#a98417e5471fd227b3e45d3db1861c11caf668f7" + dependencies: + character-entities-html4 "^1.0.0" + character-entities-legacy "^1.0.0" + is-alphanumerical "^1.0.0" + is-hexadecimal "^1.0.0" + +stringstream@~0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + dependencies: + ansi-regex "^3.0.0" + +strip-bom-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-2.0.0.tgz#f87db5ef2613f6968aa545abfe1ec728b6a829ca" + dependencies: + first-chunk-stream "^2.0.0" + strip-bom "^2.0.0" + +strip-bom-string@1.X: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz#e5211e9224369fbb81d633a2f00044dc8cedad92" + +strip-bom@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-1.0.0.tgz#85b8862f3844b5a6d5ec8467a93598173a36f794" + dependencies: + first-chunk-stream "^1.0.0" + is-utf8 "^0.2.0" + +strip-bom@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" + dependencies: + is-utf8 "^0.2.0" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + +strip-indent@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68" + +strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + +strip-json-comments@~0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-0.1.3.tgz#164c64e370a8a3cc00c9e01b539e569823f0ee54" + +style-loader@0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.19.1.tgz#591ffc80bcefe268b77c5d9ebc0505d772619f85" + dependencies: + loader-utils "^1.0.2" + schema-utils "^0.3.0" + +style-search@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/style-search/-/style-search-0.1.0.tgz#7958c793e47e32e07d2b5cafe5c0bf8e12e77902" + +stylelint-order@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/stylelint-order/-/stylelint-order-1.0.0.tgz#089fc3d5cdf7e7d4ac1882f65b60b25db750413c" + dependencies: + lodash "^4.17.10" + postcss "^7.0.2" + postcss-sorting "^4.0.0" + +stylelint@9.5.0: + version "9.5.0" + resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-9.5.0.tgz#f7afb45342abc4acf28a8da8a48373e9f79c1fb4" + dependencies: + autoprefixer "^9.0.0" + balanced-match "^1.0.0" + chalk "^2.4.1" + cosmiconfig "^5.0.0" + debug "^3.0.0" + execall "^1.0.0" + file-entry-cache "^2.0.0" + get-stdin "^6.0.0" + globby "^8.0.0" + globjoin "^0.1.4" + html-tags "^2.0.0" + ignore "^4.0.0" + import-lazy "^3.1.0" + imurmurhash "^0.1.4" + known-css-properties "^0.6.0" + lodash "^4.17.4" + log-symbols "^2.0.0" + mathml-tag-names "^2.0.1" + meow "^5.0.0" + micromatch "^2.3.11" + normalize-selector "^0.2.0" + pify "^4.0.0" + postcss "^7.0.0" + postcss-html "^0.33.0" + postcss-jsx "^0.33.0" + postcss-less "^2.0.0" + postcss-markdown "^0.33.0" + postcss-media-query-parser "^0.2.3" + postcss-reporter "^5.0.0" + postcss-resolve-nested-selector "^0.1.1" + postcss-safe-parser "^4.0.0" + postcss-sass "^0.3.0" + postcss-scss "^2.0.0" + postcss-selector-parser "^3.1.0" + postcss-styled "^0.33.0" + postcss-syntax "^0.33.0" + postcss-value-parser "^3.3.0" + resolve-from "^4.0.0" + signal-exit "^3.0.2" + specificity "^0.4.0" + string-width "^2.1.0" + style-search "^0.1.0" + sugarss "^2.0.0" + svg-tags "^1.0.0" + table "^4.0.1" + +sugarss@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/sugarss/-/sugarss-1.0.0.tgz#65e51b3958432fb70d5451a68bb33e32d0cf1ef7" + dependencies: + postcss "^6.0.0" + +sugarss@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/sugarss/-/sugarss-2.0.0.tgz#ddd76e0124b297d40bf3cca31c8b22ecb43bc61d" + dependencies: + postcss "^7.0.2" + +supports-color@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-1.3.1.tgz#15758df09d8ff3b4acc307539fabe27095e1042d" + +supports-color@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" + +supports-color@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6" + dependencies: + has-flag "^1.0.0" + +supports-color@^4.2.1: + version "4.4.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.4.0.tgz#883f7ddabc165142b2a61427f3352ded195d1a3e" + dependencies: + has-flag "^2.0.0" + +supports-color@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.5.0.tgz#be7a0de484dec5c5cddf8b3d59125044912f635b" + dependencies: + has-flag "^2.0.0" + +supports-color@^5.3.0, supports-color@^5.4.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + dependencies: + has-flag "^3.0.0" + +svg-tags@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764" + +svgo@^0.7.0: + version "0.7.2" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5" + dependencies: + coa "~1.0.1" + colors "~1.1.2" + csso "~2.3.1" + js-yaml "~3.7.0" + mkdirp "~0.5.1" + sax "~1.2.1" + whet.extend "~0.9.9" + +symbol-observable@^1.0.4, symbol-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + +sync-exec@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/sync-exec/-/sync-exec-0.6.2.tgz#717d22cc53f0ce1def5594362f3a89a2ebb91105" + +table@^4.0.1, table@^4.0.3: + version "4.0.3" + resolved "http://registry.npmjs.org/table/-/table-4.0.3.tgz#00b5e2b602f1794b9acaf9ca908a76386a7813bc" + dependencies: + ajv "^6.0.1" + ajv-keywords "^3.0.0" + chalk "^2.1.0" + lodash "^4.17.4" + slice-ansi "1.0.0" + string-width "^2.1.1" + +tapable@^0.2.7: + version "0.2.8" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.2.8.tgz#99372a5c999bf2df160afc0d74bed4f47948cd22" + +tape@^4.6.3: + version "4.8.0" + resolved "https://registry.yarnpkg.com/tape/-/tape-4.8.0.tgz#f6a9fec41cc50a1de50fa33603ab580991f6068e" + dependencies: + deep-equal "~1.0.1" + defined "~1.0.0" + for-each "~0.3.2" + function-bind "~1.1.0" + glob "~7.1.2" + has "~1.0.1" + inherits "~2.0.3" + minimist "~1.2.0" + object-inspect "~1.3.0" + resolve "~1.4.0" + resumer "~0.0.0" + string.prototype.trim "~1.1.2" + through "~2.3.8" + +tar-pack@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/tar-pack/-/tar-pack-3.4.0.tgz#23be2d7f671a8339376cbdb0b8fe3fdebf317984" + dependencies: + debug "^2.2.0" + fstream "^1.0.10" + fstream-ignore "^1.0.5" + once "^1.3.3" + readable-stream "^2.1.4" + rimraf "^2.5.1" + tar "^2.2.1" + uid-number "^0.0.6" + +tar.gz@1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/tar.gz/-/tar.gz-1.0.7.tgz#577ef2c595faaa73452ef0415fed41113212257b" + dependencies: + bluebird "^2.9.34" + commander "^2.8.1" + fstream "^1.0.8" + mout "^0.11.0" + tar "^2.1.1" + +tar@^2.1.1, tar@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1" + dependencies: + block-stream "*" + fstream "^1.0.2" + inherits "2" + +tether@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/tether/-/tether-1.4.3.tgz#fd547024c47b6e5c9b87e1880f997991a9a6ad54" + +text-table@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" + +through2@2.0.3, through2@2.X, through2@^2.0.0, through2@^2.0.1, through2@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be" + dependencies: + readable-stream "^2.1.5" + xtend "~4.0.1" + +through2@^0.4.1: + version "0.4.2" + resolved "https://registry.yarnpkg.com/through2/-/through2-0.4.2.tgz#dbf5866031151ec8352bb6c4db64a2292a840b9b" + dependencies: + readable-stream "~1.0.17" + xtend "~2.1.1" + +through2@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/through2/-/through2-0.5.1.tgz#dfdd012eb9c700e2323fd334f38ac622ab372da7" + dependencies: + readable-stream "~1.0.17" + xtend "~3.0.0" + +through2@^0.6.1: + version "0.6.5" + resolved "https://registry.yarnpkg.com/through2/-/through2-0.6.5.tgz#41ab9c67b29d57209071410e1d7a7a968cd3ad48" + dependencies: + readable-stream ">=1.0.33-1 <1.1.0-0" + xtend ">=4.0.0 <4.1.0-0" + +through@2, through@^2.3.6, through@^2.3.8, through@~2.3, through@~2.3.4, through@~2.3.8: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + +tildify@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/tildify/-/tildify-1.2.0.tgz#dcec03f55dca9b7aa3e5b04f21817eb56e63588a" + dependencies: + os-homedir "^1.0.0" + +time-stamp@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-1.1.0.tgz#764a5a11af50561921b133f3b44e618687e0f5c3" + +timers-browserify@^2.0.2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.4.tgz#96ca53f4b794a5e7c0e1bd7cc88a372298fa01e6" + dependencies: + setimmediate "^1.0.4" + +timers-ext@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/timers-ext/-/timers-ext-0.1.2.tgz#61cc47a76c1abd3195f14527f978d58ae94c5204" + dependencies: + es5-ext "~0.10.14" + next-tick "1" + +tiny-emitter@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.0.2.tgz#82d27468aca5ade8e5fd1e6d22b57dd43ebdfb7c" + +tiny-lr@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tiny-lr/-/tiny-lr-1.1.1.tgz#9fa547412f238fedb068ee295af8b682c98b2aab" + dependencies: + body "^5.1.0" + debug "^3.1.0" + faye-websocket "~0.10.0" + livereload-js "^2.3.0" + object-assign "^4.1.0" + qs "^6.4.0" + +tmp@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + dependencies: + os-tmpdir "~1.0.2" + +tmpl@1.0.x: + version "1.0.4" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" + +to-arraybuffer@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" + +to-camel-case@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-camel-case/-/to-camel-case-1.0.0.tgz#1a56054b2f9d696298ce66a60897322b6f423e46" + dependencies: + to-space-case "^1.0.0" + +to-fast-properties@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + +to-no-case@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/to-no-case/-/to-no-case-1.0.2.tgz#c722907164ef6b178132c8e69930212d1b4aa16a" + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + +to-space-case@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-space-case/-/to-space-case-1.0.0.tgz#b052daafb1b2b29dc770cea0163e5ec0ebc9fc17" + dependencies: + to-no-case "^1.0.0" + +tough-cookie@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.3.2.tgz#f081f76e4c85720e6c37a5faced737150d84072a" + dependencies: + punycode "^1.4.1" + +traverse@~0.6.3: + version "0.6.6" + resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.6.6.tgz#cbdf560fd7b9af632502fed40f918c157ea97137" + +trim-newlines@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-2.0.0.tgz#b403d0b91be50c331dfc4b82eeceb22c3de16d20" + +trim-right@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" + +trim-trailing-lines@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/trim-trailing-lines/-/trim-trailing-lines-1.1.1.tgz#e0ec0810fd3c3f1730516b45f49083caaf2774d9" + +trim@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd" + +trough@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.3.tgz#e29bd1614c6458d44869fc28b255ab7857ef7c24" + +tryit@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb" + +tslib@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" + +tty-browserify@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + dependencies: + prelude-ls "~1.1.2" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + +ua-parser-js@^0.7.18, ua-parser-js@^0.7.9: + version "0.7.18" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed" + +uglify-es@^3.3.4: + version "3.3.9" + resolved "https://registry.yarnpkg.com/uglify-es/-/uglify-es-3.3.9.tgz#0c1c4f0700bed8dbc124cdb304d2592ca203e677" + dependencies: + commander "~2.13.0" + source-map "~0.6.1" + +uglify-js@^2.8.29: + version "2.8.29" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd" + dependencies: + source-map "~0.5.1" + yargs "~3.10.0" + optionalDependencies: + uglify-to-browserify "~1.0.0" + +uglify-to-browserify@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7" + +uglifyjs-webpack-plugin@1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-1.2.5.tgz#2ef8387c8f1a903ec5e44fa36f9f3cbdcea67641" + dependencies: + cacache "^10.0.4" + find-cache-dir "^1.0.0" + schema-utils "^0.4.5" + serialize-javascript "^1.4.0" + source-map "^0.6.1" + uglify-es "^3.3.4" + webpack-sources "^1.1.0" + worker-farm "^1.5.2" + +uglifyjs-webpack-plugin@^0.4.6: + version "0.4.6" + resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-0.4.6.tgz#b951f4abb6bd617e66f63eb891498e391763e309" + dependencies: + source-map "^0.5.6" + uglify-js "^2.8.29" + webpack-sources "^1.0.1" + +uid-number@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" + +unc-path-regex@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" + +unherit@^1.0.4: + version "1.1.1" + resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.1.tgz#132748da3e88eab767e08fabfbb89c5e9d28628c" + dependencies: + inherits "^2.0.1" + xtend "^4.0.1" + +unified@^6.0.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/unified/-/unified-6.2.0.tgz#7fbd630f719126d67d40c644b7e3f617035f6dba" + dependencies: + bail "^1.0.0" + extend "^3.0.0" + is-plain-obj "^1.1.0" + trough "^1.0.0" + vfile "^2.0.0" + x-is-string "^0.1.0" + +union-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4" + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^0.4.3" + +uniq@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" + +uniqid@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/uniqid/-/uniqid-4.1.1.tgz#89220ddf6b751ae52b5f72484863528596bb84c1" + dependencies: + macaddress "^0.2.8" + +uniqs@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" + +unique-filename@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.0.tgz#d05f2fe4032560871f30e93cbe735eea201514f3" + dependencies: + unique-slug "^2.0.0" + +unique-slug@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.0.tgz#db6676e7c7cc0629878ff196097c78855ae9f4ab" + dependencies: + imurmurhash "^0.1.4" + +unique-stream@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-1.0.0.tgz#d59a4a75427447d9aa6c91e70263f8d26a4b104b" + +unist-util-find-all-after@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/unist-util-find-all-after/-/unist-util-find-all-after-1.0.2.tgz#9be49cfbae5ca1566b27536670a92836bf2f8d6d" + dependencies: + unist-util-is "^2.0.0" + +unist-util-is@^2.0.0, unist-util-is@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-2.1.2.tgz#1193fa8f2bfbbb82150633f3a8d2eb9a1c1d55db" + +unist-util-remove-position@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/unist-util-remove-position/-/unist-util-remove-position-1.1.2.tgz#86b5dad104d0bbfbeb1db5f5c92f3570575c12cb" + dependencies: + unist-util-visit "^1.1.0" + +unist-util-stringify-position@^1.0.0, unist-util-stringify-position@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-1.1.2.tgz#3f37fcf351279dcbca7480ab5889bb8a832ee1c6" + +unist-util-visit-parents@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-2.0.1.tgz#63fffc8929027bee04bfef7d2cce474f71cb6217" + dependencies: + unist-util-is "^2.1.2" + +unist-util-visit@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-1.4.0.tgz#1cb763647186dc26f5e1df5db6bd1e48b3cc2fb1" + dependencies: + unist-util-visit-parents "^2.0.0" + +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +uri-js@^4.2.1, uri-js@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" + dependencies: + punycode "^2.1.0" + +urix@^0.1.0, urix@~0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + +url-loader@0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-0.6.2.tgz#a007a7109620e9d988d14bce677a1decb9a993f7" + dependencies: + loader-utils "^1.0.2" + mime "^1.4.1" + schema-utils "^0.3.0" + +url@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + +user-home@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/user-home/-/user-home-1.1.1.tgz#2b5be23a32b63a7c9deb8d0f28d485724a3df190" + +user-home@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/user-home/-/user-home-2.0.0.tgz#9c70bfd8169bc1dcbf48604e0f04b8b49cde9e9f" + dependencies: + os-homedir "^1.0.0" + +util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + +util@0.10.3, util@^0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/util/-/util-0.10.3.tgz#7afb1afe50805246489e3db7fe0ed379336ac0f9" + dependencies: + inherits "2.0.1" + +uuid@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" + +v8flags@^2.0.2: + version "2.1.1" + resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.1.1.tgz#aab1a1fa30d45f88dd321148875ac02c0b55e5b4" + dependencies: + user-home "^1.1.1" + +validate-npm-package-license@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" + dependencies: + spdx-correct "^3.0.0" + spdx-expression-parse "^3.0.0" + +value-equal@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.2.1.tgz#c220a304361fce6994dbbedaa3c7e1a1b895871d" + +value-equal@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.4.0.tgz#c5bdd2f54ee093c04839d71ce2e4758a6890abc7" + +vendors@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.1.tgz#37ad73c8ee417fb3d580e785312307d274847f22" + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +vfile-location@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-2.0.3.tgz#083ba80e50968e8d420be49dd1ea9a992131df77" + +vfile-message@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-1.0.1.tgz#51a2ccd8a6b97a7980bb34efb9ebde9632e93677" + dependencies: + unist-util-stringify-position "^1.1.1" + +vfile@^2.0.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/vfile/-/vfile-2.3.0.tgz#e62d8e72b20e83c324bc6c67278ee272488bf84a" + dependencies: + is-buffer "^1.1.4" + replace-ext "1.0.0" + unist-util-stringify-position "^1.0.0" + vfile-message "^1.0.0" + +vinyl-bufferstream@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/vinyl-bufferstream/-/vinyl-bufferstream-1.0.1.tgz#0537869f580effa4ca45acb47579e4b9fe63081a" + dependencies: + bufferstreams "1.0.1" + +vinyl-file@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/vinyl-file/-/vinyl-file-2.0.0.tgz#a7ebf5ffbefda1b7d18d140fcb07b223efb6751a" + dependencies: + graceful-fs "^4.1.2" + pify "^2.3.0" + pinkie-promise "^2.0.0" + strip-bom "^2.0.0" + strip-bom-stream "^2.0.0" + vinyl "^1.1.0" + +vinyl-fs@^0.3.0: + version "0.3.14" + resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-0.3.14.tgz#9a6851ce1cac1c1cea5fe86c0931d620c2cfa9e6" + dependencies: + defaults "^1.0.0" + glob-stream "^3.1.5" + glob-watcher "^0.0.6" + graceful-fs "^3.0.0" + mkdirp "^0.5.0" + strip-bom "^1.0.0" + through2 "^0.6.1" + vinyl "^0.4.0" + +vinyl-map@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/vinyl-map/-/vinyl-map-1.0.2.tgz#a8b296025f973fa7cad62817967a48f1d176bf7c" + dependencies: + bl "^1.1.2" + new-from "0.0.3" + through2 "^0.4.1" + +vinyl-sourcemaps-apply@0.2.1, vinyl-sourcemaps-apply@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz#ab6549d61d172c2b1b87be5c508d239c8ef87705" + dependencies: + source-map "^0.5.1" + +vinyl@^0.4.0: + version "0.4.6" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-0.4.6.tgz#2f356c87a550a255461f36bbeb2a5ba8bf784847" + dependencies: + clone "^0.2.0" + clone-stats "^0.0.1" + +vinyl@^0.5.0: + version "0.5.3" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-0.5.3.tgz#b0455b38fc5e0cf30d4325132e461970c2091cde" + dependencies: + clone "^1.0.0" + clone-stats "^0.0.1" + replace-ext "0.0.1" + +vinyl@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-1.2.0.tgz#5c88036cf565e5df05558bfc911f8656df218884" + dependencies: + clone "^1.0.0" + clone-stats "^0.0.1" + replace-ext "0.0.1" + +vinyl@^2.0.0, vinyl@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.1.0.tgz#021f9c2cf951d6b939943c89eb5ee5add4fd924c" + dependencies: + clone "^2.1.1" + clone-buffer "^1.0.0" + clone-stats "^1.0.0" + cloneable-readable "^1.0.0" + remove-trailing-separator "^1.0.1" + replace-ext "^1.0.0" + +vinyl@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.0.tgz#d85b07da96e458d25b2ffe19fece9f2caa13ed86" + dependencies: + clone "^2.1.1" + clone-buffer "^1.0.0" + clone-stats "^1.0.0" + cloneable-readable "^1.0.0" + remove-trailing-separator "^1.0.1" + replace-ext "^1.0.0" + +vm-browserify@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73" + dependencies: + indexof "0.0.1" + +walker@~1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb" + dependencies: + makeerror "1.0.x" + +warning@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c" + dependencies: + loose-envify "^1.0.0" + +warning@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.2.tgz#aa6876480872116fa3e11d434b0d0d8d91e44607" + dependencies: + loose-envify "^1.0.0" + +watch@~0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/watch/-/watch-0.10.0.tgz#77798b2da0f9910d595f1ace5b0c2258521f21dc" + +watchpack@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.4.0.tgz#4a1472bcbb952bd0a9bb4036801f954dfb39faac" + dependencies: + async "^2.1.2" + chokidar "^1.7.0" + graceful-fs "^4.1.2" + +weak@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/weak/-/weak-1.0.1.tgz#ab99aab30706959aa0200cb8cf545bb9cb33b99e" + dependencies: + bindings "^1.2.1" + nan "^2.0.5" + +webpack-sources@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.0.1.tgz#c7356436a4d13123be2e2426a05d1dad9cbe65cf" + dependencies: + source-list-map "^2.0.0" + source-map "~0.5.3" + +webpack-sources@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.1.0.tgz#a101ebae59d6507354d71d8013950a3a8b7a5a54" + dependencies: + source-list-map "^2.0.0" + source-map "~0.6.1" + +webpack-stream@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/webpack-stream/-/webpack-stream-4.0.0.tgz#f3673dd907d6d9b1ea7bf51fcd1db85b5fd9e0f2" + dependencies: + gulp-util "^3.0.7" + lodash.clone "^4.3.2" + lodash.some "^4.2.2" + memory-fs "^0.4.1" + through "^2.3.8" + vinyl "^2.1.0" + webpack "^3.4.1" + +webpack@3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.10.0.tgz#5291b875078cf2abf42bdd23afe3f8f96c17d725" + dependencies: + acorn "^5.0.0" + acorn-dynamic-import "^2.0.0" + ajv "^5.1.5" + ajv-keywords "^2.0.0" + async "^2.1.2" + enhanced-resolve "^3.4.0" + escope "^3.6.0" + interpret "^1.0.0" + json-loader "^0.5.4" + json5 "^0.5.1" + loader-runner "^2.3.0" + loader-utils "^1.1.0" + memory-fs "~0.4.1" + mkdirp "~0.5.0" + node-libs-browser "^2.0.0" + source-map "^0.5.3" + supports-color "^4.2.1" + tapable "^0.2.7" + uglifyjs-webpack-plugin "^0.4.6" + watchpack "^1.4.0" + webpack-sources "^1.0.1" + yargs "^8.0.2" + +webpack@^3.4.1: + version "3.6.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.6.0.tgz#a89a929fbee205d35a4fa2cc487be9cbec8898bc" + dependencies: + acorn "^5.0.0" + acorn-dynamic-import "^2.0.0" + ajv "^5.1.5" + ajv-keywords "^2.0.0" + async "^2.1.2" + enhanced-resolve "^3.4.0" + escope "^3.6.0" + interpret "^1.0.0" + json-loader "^0.5.4" + json5 "^0.5.1" + loader-runner "^2.3.0" + loader-utils "^1.1.0" + memory-fs "~0.4.1" + mkdirp "~0.5.0" + node-libs-browser "^2.0.0" + source-map "^0.5.3" + supports-color "^4.2.1" + tapable "^0.2.7" + uglifyjs-webpack-plugin "^0.4.6" + watchpack "^1.4.0" + webpack-sources "^1.0.1" + yargs "^8.0.2" + +websocket-driver@>=0.5.1: + version "0.7.0" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.0.tgz#0caf9d2d755d93aee049d4bdd0d3fe2cca2a24eb" + dependencies: + http-parser-js ">=0.4.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.2.tgz#0e18781de629a18308ce1481650f67ffa2693a5d" + +whatwg-fetch@>=0.10.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" + +whet.extend@~0.9.9: + version "0.9.9" + resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1" + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + +which@^1.0.5, which@^1.2.12, which@^1.2.4, which@^1.2.9: + version "1.3.0" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.0.tgz#ff04bdfc010ee547d780bec38e1ac1c2777d253a" + dependencies: + isexe "^2.0.0" + +wide-align@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.2.tgz#571e0f1b0604636ebc0dfc21b0339bbe31341710" + dependencies: + string-width "^1.0.2" + +window-size@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d" + +wordwrap@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f" + +wordwrap@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + +worker-farm@^1.3.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.5.0.tgz#adfdf0cd40581465ed0a1f648f9735722afd5c8d" + dependencies: + errno "^0.1.4" + xtend "^4.0.1" + +worker-farm@^1.5.2: + version "1.6.0" + resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.6.0.tgz#aecc405976fab5a95526180846f0dba288f3a4a0" + dependencies: + errno "~0.1.7" + +wrap-ansi@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + +write@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/write/-/write-0.2.1.tgz#5fc03828e264cea3fe91455476f7a3c566cb0757" + dependencies: + mkdirp "^0.5.1" + +x-is-string@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82" + +"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" + +xtend@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-2.1.2.tgz#6efecc2a4dad8e6962c4901b337ce7ba87b5d28b" + dependencies: + object-keys "~0.4.0" + +xtend@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-3.0.0.tgz#5cce7407baf642cba7becda568111c493f59665a" + +y18n@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" + +y18n@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" + +yallist@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + +yargs-parser@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-10.1.0.tgz#7202265b89f7e9e9f2e5765e0fe735a905edbaa8" + dependencies: + camelcase "^4.1.0" + +yargs-parser@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-7.0.0.tgz#8d0ac42f16ea55debd332caf4c4038b3e3f5dfd9" + dependencies: + camelcase "^4.1.0" + +yargs@^8.0.1, yargs@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-8.0.2.tgz#6299a9055b1cefc969ff7e79c1d918dceb22c360" + dependencies: + camelcase "^4.1.0" + cliui "^3.2.0" + decamelize "^1.1.1" + get-caller-file "^1.0.1" + os-locale "^2.0.0" + read-pkg-up "^2.0.0" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^2.0.0" + which-module "^2.0.0" + y18n "^3.2.1" + yargs-parser "^7.0.0" + +yargs@~3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1" + dependencies: + camelcase "^1.0.2" + cliui "^2.1.0" + decamelize "^1.0.0" + window-size "0.1.0"