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/.gitignore b/.gitignore
index a340f3295..cfac0d699 100644
--- a/.gitignore
+++ b/.gitignore
@@ -120,8 +120,6 @@ _tests/
setup/Output/
*.~is
-UI.Phantom/
-
#VS outout folders
bin
obj
@@ -135,5 +133,3 @@ _start
_temp_*/**/*
src/.idea/
-/npm_start.bat
-/npm_start.bat
diff --git a/.idea/.name b/.idea/.name
deleted file mode 100644
index e402c1d9d..000000000
--- a/.idea/.name
+++ /dev/null
@@ -1 +0,0 @@
-Lidarr
\ No newline at end of file
diff --git a/.idea/Sonarr.iml b/.idea/Sonarr.iml
deleted file mode 100644
index aeec84bf6..000000000
--- a/.idea/Sonarr.iml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/codeStyleSettings.xml b/.idea/codeStyleSettings.xml
deleted file mode 100644
index 7598f4c8e..000000000
--- a/.idea/codeStyleSettings.xml
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
deleted file mode 100644
index 97626ba45..000000000
--- a/.idea/encodings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml
deleted file mode 100644
index b8387eb1b..000000000
--- a/.idea/jsLibraryMappings.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/libraries/Sonarr_node_modules.xml b/.idea/libraries/Sonarr_node_modules.xml
deleted file mode 100644
index 4eeebc5cc..000000000
--- a/.idea/libraries/Sonarr_node_modules.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 19f74da8e..000000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index 7cc2cf51b..000000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7f4..000000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 000000000..ad5884817
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+save-prefix=""
diff --git a/.yarnrc b/.yarnrc
new file mode 100644
index 000000000..fdd705c63
--- /dev/null
+++ b/.yarnrc
@@ -0,0 +1 @@
+save-prefix ""
diff --git a/README.md b/README.md
index e9eb6dd13..9b85b3d2f 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,8 @@
-[![Build status](https://ci.appveyor.com/api/projects/status/pu08kh1avj2gl1av?svg=true)](https://ci.appveyor.com/project/majora2007/lidarr)
-
-[![Codacy Badge](https://api.codacy.com/project/badge/Grade/43c18ff049df442fab086cea020c4642)](https://www.codacy.com/app/majora2007/Lidarr?utm_source=github.com&utm_medium=referral&utm_content=lidarr/Lidarr&utm_campaign=Badge_Grade)
-
## Lidarr
+[![Build status](https://ci.appveyor.com/api/projects/status/tpm5mj5milne88nc?svg=true)](https://ci.appveyor.com/project/lidarr/lidarr)
+[![Codacy Badge](https://api.codacy.com/project/badge/Grade/4e6d014aee9542189b4abb0b1439980f)](https://www.codacy.com/app/Lidarr/Lidarr?utm_source=github.com&utm_medium=referral&utm_content=lidarr/Lidarr&utm_campaign=Badge_Grade)
+
Lidarr is a music collection manager for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new tracks from your favorite artists and will grab, sort and rename them. It can also be configured to automatically upgrade the quality of files already downloaded when a better quality format becomes available.
## Major Features Include:
@@ -46,7 +45,7 @@ Lidarr is a music collection manager for Usenet and BitTorrent users. It can mon
### Development
-* Open `NzbDrone.sln` in Visual Studio
+* Open `Lidarr.sln` in Visual Studio
* Make sure `NzbDrone.Console` is set as the startup project
* Change build to 'Debug x86'
diff --git a/build-appveyor.cake b/build-appveyor.cake
index cce3fbd03..bd0fb3704 100644
--- a/build-appveyor.cake
+++ b/build-appveyor.cake
@@ -1,4 +1,5 @@
#addin "Cake.Npm"
+#addin "Cake.Yarn"
#addin "SharpZipLib"
#addin "Cake.Compression"
@@ -10,9 +11,9 @@ var outputFolderOsxApp = outputFolderOsx + "_app";
var testPackageFolder = "./_tests";
var testSearchPattern = "*.Test/bin/x86/Release";
var sourceFolder = "./src";
-var solutionFile = sourceFolder + "/NzbDrone.sln";
-var updateFolder = outputFolder + "/NzbDrone.Update";
-var updateFolderMono = outputFolderMono + "/NzbDrone.Update";
+var solutionFile = sourceFolder + "/Lidarr.sln";
+var updateFolder = outputFolder + "/Lidarr.Update";
+var updateFolderMono = outputFolderMono + "/Lidarr.Update";
// Artifact variables
var artifactsFolder = "./_artifacts";
@@ -135,8 +136,8 @@ Task("PackageMono").Does(() => {
DeleteFiles(outputFolderMono + "/sqlite3.*");
DeleteFiles(outputFolderMono + "/MediaInfo.*");
- // Adding NzbDrone.Core.dll.config (for dllmap)
- CopyFile(sourceFolder + "/NzbDrone.Core/NzbDrone.Core.dll.config", outputFolderMono + "/NzbDrone.Core.dll.config");
+ // Adding Lidarr.Core.dll.config (for dllmap)
+ CopyFile(sourceFolder + "/NzbDrone.Core/Lidarr.Core.dll.config", outputFolderMono + "/Lidarr.Core.dll.config");
// Adding CurlSharp.dll.config (for dllmap)
CopyFile(sourceFolder + "/NzbDrone.Common/CurlSharp.dll.config", outputFolderMono + "/CurlSharp.dll.config");
@@ -147,11 +148,11 @@ Task("PackageMono").Does(() => {
MoveFile(outputFolderMono + "/Lidarr.Console.exe.config", outputFolderMono + "/Lidarr.exe.config");
MoveFile(outputFolderMono + "/Lidarr.Console.exe.mdb", outputFolderMono + "/Lidarr.exe.mdb");
- // Remove NzbDrone.Windows.*
- DeleteFiles(outputFolderMono + "/NzbDrone.Windows.*");
+ // Remove Lidarr.Windows.*
+ DeleteFiles(outputFolderMono + "/Lidarr.Windows.*");
- // Adding NzbDrone.Mono to updatePackage
- CopyFiles(outputFolderMono + "/NzbDrone.Mono.*", updateFolderMono);
+ // Adding Lidarr.Mono to updatePackage
+ CopyFiles(outputFolderMono + "/Lidarr.Mono.*", updateFolderMono);
});
Task("PackageOsx").Does(() => {
@@ -226,8 +227,8 @@ Task("PackageTests").Does(() => {
// Clean
CleanFolder(testPackageFolder, true);
- // Adding NzbDrone.Core.dll.config (for dllmap)
- CopyFile(sourceFolder + "/NzbDrone.Core/NzbDrone.Core.dll.config", testPackageFolder + "/NzbDrone.Core.dll.config");
+ // Adding Lidarr.Core.dll.config (for dllmap)
+ CopyFile(sourceFolder + "/NzbDrone.Core/Lidarr.Core.dll.config", testPackageFolder + "/Lidarr.Core.dll.config");
// Adding CurlSharp.dll.config (for dllmap)
CopyFile(sourceFolder + "/NzbDrone.Common/CurlSharp.dll.config", testPackageFolder + "/CurlSharp.dll.config");
@@ -238,10 +239,10 @@ Task("PackageTests").Does(() => {
Task("CleanupWindowsPackage").Does(() => {
// Remove mono
- DeleteFiles(outputFolder + "/NzbDrone.Mono.*");
+ DeleteFiles(outputFolder + "/Lidarr.Mono.*");
- // Adding NzbDrone.Windows to updatePackage
- CopyFiles(outputFolder + "/NzbDrone.Windows.*", updateFolder);
+ // Adding Lidarr.Windows to updatePackage
+ CopyFiles(outputFolder + "/Lidarr.Windows.*", updateFolder);
});
Task("Build")
@@ -267,7 +268,7 @@ Task("ArtifactsWindows").Does(() => {
});
Task("ArtifactsWindowsInstaller").Does(() => {
- InnoSetup("./setup/nzbdrone.iss", new InnoSetupSettings {
+ InnoSetup("./setup/lidarr.iss", new InnoSetupSettings {
OutputDirectory = artifactsFolder,
ToolPath = "./setup/inno/ISCC.exe"
});
diff --git a/build.ps1 b/build.ps1
deleted file mode 100644
index 45b8ce783..000000000
--- a/build.ps1
+++ /dev/null
@@ -1 +0,0 @@
-Write-Warning "DEPRECATED -- Please use build.sh instead."
\ No newline at end of file
diff --git a/build.sh b/build.sh
index 1775ee34d..297a932e2 100755
--- a/build.sh
+++ b/build.sh
@@ -7,9 +7,9 @@ outputFolderOsxApp='./_output_osx_app'
testPackageFolder='./_tests/'
testSearchPattern='*.Test/bin/x86/Release'
sourceFolder='./src'
-slnFile=$sourceFolder/NzbDrone.sln
-updateFolder=$outputFolder/NzbDrone.Update
-updateFolderMono=$outputFolderMono/NzbDrone.Update
+slnFile=$sourceFolder/Lidarr.sln
+updateFolder=$outputFolder/Lidarr.Update
+updateFolderMono=$outputFolderMono/Lidarr.Update
nuget='tools/nuget/nuget.exe';
CheckExitCode()
@@ -39,9 +39,6 @@ CleanFolder()
find $path -name "FluentValidation.resources.dll" -exec rm "{}" \;
find $path -name "App.config" -exec rm "{}" \;
- echo "Removing .less files"
- find $path -name "*.less" -exec rm "{}" \;
-
echo "Removing vshost files"
find $path -name "*.vshost.exe" -exec rm "{}" \;
@@ -77,6 +74,17 @@ BuildWithXbuild()
CheckExitCode xbuild /p:Configuration=Release /p:Platform=x86 /t:Build /p:AllowedReferenceRelatedFileExtensions=.pdb $slnFile
}
+LintUI()
+{
+ ProgressStart 'ESLint'
+ CheckExitCode yarn eslint
+ ProgressEnd 'ESLint'
+
+ ProgressStart 'Stylelint'
+ CheckExitCode yarn stylelint
+ ProgressEnd 'Stylelint'
+}
+
Build()
{
echo "##teamcity[progressStart 'Build']"
@@ -101,13 +109,16 @@ Build()
RunGulp()
{
- echo "##teamcity[progressStart 'npm install']"
- npm-cache install npm || CheckExitCode npm install
- echo "##teamcity[progressFinish 'npm install']"
+ ProgressStart 'npm install'
+ yarn install
+ #npm-cache install npm || CheckExitCode npm install --no-optional --no-bin-links
+ ProgressEnd 'npm install'
+
+ LintUI
- echo "##teamcity[progressStart 'Running gulp']"
- CheckExitCode npm run build
- echo "##teamcity[progressFinish 'Running gulp']"
+ ProgressStart 'Running gulp'
+ CheckExitCode npm run build -- --production
+ ProgressEnd 'Running gulp'
}
CreateMdbs()
@@ -147,8 +158,8 @@ PackageMono()
rm -f $outputFolderMono/sqlite3.*
rm -f $outputFolderMono/MediaInfo.*
- echo "Adding NzbDrone.Core.dll.config (for dllmap)"
- cp $sourceFolder/NzbDrone.Core/NzbDrone.Core.dll.config $outputFolderMono
+ echo "Adding Lidarr.Core.dll.config (for dllmap)"
+ cp $sourceFolder/NzbDrone.Core/Lidarr.Core.dll.config $outputFolderMono
echo "Adding CurlSharp.dll.config (for dllmap)"
cp $sourceFolder/NzbDrone.Common/CurlSharp.dll.config $outputFolderMono
@@ -159,11 +170,11 @@ PackageMono()
mv "$file" "${file//.Console/}"
done
- echo "Removing NzbDrone.Windows"
- rm $outputFolderMono/NzbDrone.Windows.*
+ echo "Removing Lidarr.Windows"
+ rm $outputFolderMono/Lidarr.Windows.*
- echo "Adding NzbDrone.Mono to UpdatePackage"
- cp $outputFolderMono/NzbDrone.Mono.* $updateFolderMono
+ echo "Adding Lidarr.Mono to UpdatePackage"
+ cp $outputFolderMono/Lidarr.Mono.* $updateFolderMono
echo "##teamcity[progressFinish 'Creating Mono Package']"
}
@@ -223,8 +234,8 @@ PackageTests()
CleanFolder $testPackageFolder true
- echo "Adding NzbDrone.Core.dll.config (for dllmap)"
- cp $sourceFolder/NzbDrone.Core/NzbDrone.Core.dll.config $testPackageFolder
+ echo "Adding Lidarr.Core.dll.config (for dllmap)"
+ cp $sourceFolder/NzbDrone.Core/Lidarr.Core.dll.config $testPackageFolder
echo "Adding CurlSharp.dll.config (for dllmap)"
cp $sourceFolder/NzbDrone.Common/CurlSharp.dll.config $testPackageFolder
@@ -237,11 +248,11 @@ PackageTests()
CleanupWindowsPackage()
{
- echo "Removing NzbDrone.Mono"
- rm -f $outputFolder/NzbDrone.Mono.*
+ echo "Removing Lidarr.Mono"
+ rm -f $outputFolder/Lidarr.Mono.*
- echo "Adding NzbDrone.Windows to UpdatePackage"
- cp $outputFolder/NzbDrone.Windows.* $updateFolder
+ echo "Adding Lidarr.Windows to UpdatePackage"
+ cp $outputFolder/Lidarr.Windows.* $updateFolder
}
# Use mono or .net depending on OS
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..5208e3ad6
--- /dev/null
+++ b/frontend/.eslintrc
@@ -0,0 +1,288 @@
+{
+ "parser": "babel-eslint",
+
+ "env": {
+ "browser": true,
+ "commonjs": true,
+ "node": true,
+ "es6": true
+ },
+
+ "globals": {
+ "expect": false,
+ "chai": false,
+ "sinon": false
+ },
+
+ "parserOptions": {
+ "ecmaVersion": 6,
+ "sourceType": "module",
+ "ecmaFeatures": {
+ "modules": true,
+ "impliedStrict": true
+ }
+ },
+
+ "plugins": [
+ "filenames",
+ "react"
+ ],
+
+ "rules": {
+ "filenames/match-exported": ["error"],
+
+ # ECMAScript 6
+
+ "arrow-body-style": [0],
+ "arrow-parens": ["error", "always"],
+ "arrow-spacing": ["error", { "before": true, "after": true }],
+ "constructor-super": "error",
+ "generator-star-spacing": "off",
+ "no-class-assign": "error",
+ "no-confusing-arrow": "error",
+ "no-const-assign": "error",
+ "no-dupe-class-members": "error",
+ "no-duplicate-imports": "error",
+ "no-new-symbol": "error",
+ "no-this-before-super": "error",
+ "no-useless-escape": "error",
+ "no-useless-computed-key": "error",
+ "no-useless-constructor": "error",
+ "no-var": "warn",
+ "object-shorthand": ["error", "properties"],
+ "prefer-arrow-callback": "error",
+ "prefer-const": "warn",
+ "prefer-reflect": "off",
+ "prefer-rest-params": "off",
+ "prefer-spread": "warn",
+ "prefer-template": "error",
+ "require-yield": "off",
+ "template-curly-spacing": ["error", "never"],
+ "yield-star-spacing": "off",
+
+ # Possible Errors
+
+ "comma-dangle": "error",
+ "no-cond-assign": "error",
+ "no-console": "off",
+ "no-constant-condition": "warn",
+ "no-control-regex": "error",
+ "no-debugger": "off",
+ "no-dupe-args": "error",
+ "no-dupe-keys": "error",
+ "no-duplicate-case": "error",
+ "no-empty": "warn",
+ "no-empty-character-class": "error",
+ "no-ex-assign": "error",
+ "no-extra-boolean-cast": "error",
+ "no-extra-parens": ["error", "functions"],
+ "no-extra-semi": "error",
+ "no-func-assign": "error",
+ "no-inner-declarations": "error",
+ "no-invalid-regexp": "error",
+ "no-irregular-whitespace": "error",
+ "no-negated-in-lhs": "error",
+ "no-obj-calls": "error",
+ "no-regex-spaces": "error",
+ "no-sparse-arrays": "error",
+ "no-unexpected-multiline": "error",
+ "no-unreachable": "warn",
+ "no-unsafe-finally": "error",
+ "use-isnan": "error",
+ "valid-jsdoc": "off",
+ "valid-typeof": "error",
+
+ # Best Practices
+
+ "accessor-pairs": "off",
+ "array-callback-return": "warn",
+ "block-scoped-var": "warn",
+ "consistent-return": "off",
+ "curly": "error",
+ "default-case": "error",
+ "dot-location": ["error", "property"],
+ "dot-notation": "error",
+ "eqeqeq": ["error", "smart"],
+ "guard-for-in": "error",
+ "no-alert": "warn",
+ "no-caller": "error",
+ "no-case-declarations": "error",
+ "no-div-regex": "error",
+ "no-else-return": "error",
+ "no-empty-function": ["error", {"allow": ["arrowFunctions"]}],
+ "no-empty-pattern": "error",
+ "no-eval": "error",
+ "no-extend-native": "error",
+ "no-extra-bind": "error",
+ "no-fallthrough": "error",
+ "no-floating-decimal": "error",
+ "no-implicit-coercion": ["error", {
+ "boolean": false,
+ "number": true,
+ "string": true,
+ "allow": [/* "!!", "~", "*", "+" */]
+ }],
+ "no-implicit-globals": "error",
+ "no-implied-eval": "error",
+ "no-invalid-this": "off",
+ "no-iterator": "error",
+ "no-labels": "error",
+ "no-lone-blocks": "error",
+ "no-loop-func": "error",
+ "no-magic-numbers": ["off", {"ignoreArrayIndexes": true, "ignore": [0, 1] }],
+ "no-multi-spaces": "error",
+ "no-multi-str": "error",
+ "no-native-reassign": ["error", {"exceptions": ["console"]}],
+ "no-new": "off",
+ "no-new-func": "error",
+ "no-new-wrappers": "error",
+ "no-octal": "error",
+ "no-octal-escape": "error",
+ "no-param-reassign": "off",
+ "no-process-env": "off",
+ "no-proto": "error",
+ "no-redeclare": "error",
+ "no-return-assign": "warn",
+ "no-script-url": "error",
+ "no-self-assign": "error",
+ "no-self-compare": "error",
+ "no-sequences": "error",
+ "no-throw-literal": "error",
+ "no-unmodified-loop-condition": "error",
+ "no-unused-expressions": "error",
+ "no-unused-labels": "error",
+ "no-useless-call": "error",
+ "no-useless-concat": "error",
+ "no-void": "error",
+ "no-warning-comments": "off",
+ "no-with": "error",
+ "radix": ["error", "as-needed"],
+ "vars-on-top": "off",
+ "wrap-iife": ["error", "inside"],
+ "yoda": "error",
+
+ # Strict Mode
+
+ "strict": ["error", "never"],
+
+ # Variables
+
+ "init-declarations": ["error", "always"],
+ "no-catch-shadow": "error",
+ "no-delete-var": "error",
+ "no-label-var": "error",
+ "no-restricted-globals": "off",
+ "no-shadow": "error",
+ "no-shadow-restricted-names": "error",
+ "no-undef": "error",
+ "no-undef-init": "off",
+ "no-undefined": "off",
+ "no-unused-vars": ["error", { "args": "none" }],
+ "no-use-before-define": "error",
+
+ # Node.js and CommonJS
+
+ "callback-return": "warn",
+ "global-require": "error",
+ "handle-callback-err": "warn",
+ "no-mixed-requires": "error",
+ "no-new-require": "error",
+ "no-path-concat": "error",
+ "no-process-exit": "error",
+
+ # Stylistic Issues
+
+ "array-bracket-spacing": ["error", "never"],
+ "block-spacing": ["error", "always"],
+ "brace-style": ["error", "1tbs", { "allowSingleLine": false }],
+ "camelcase": "off",
+ "comma-spacing": ["error", {"before": false, "after": true}],
+ "comma-style": ["error", "last"],
+ "computed-property-spacing": ["error", "never"],
+ "consistent-this": ["error", "self"],
+ "eol-last": "error",
+ "func-names": "off",
+ "func-style": ["error", "declaration"],
+ "indent": ["error", 2, {"SwitchCase": 1}],
+ "key-spacing": ["error", {"beforeColon": false, "afterColon": true}],
+ "keyword-spacing": ["error", {before: true, after: true}],
+ "lines-around-comment": ["error", { "beforeBlockComment": true, "afterBlockComment": false }],
+ "max-depth": ["error", {"maximum": 5}],
+ "max-nested-callbacks": ["error", 4],
+ "max-params": ["error", 4],
+ "max-statements": "off",
+ "max-statements-per-line": ["error", { "max": 1 }],
+ "new-cap": ["error", {"capIsNewExceptions": ["$.Deferred"]}],
+ "new-parens": "error",
+ "newline-after-var": "off",
+ "newline-before-return": "off",
+ "newline-per-chained-call": "off",
+ "no-array-constructor": "error",
+ "no-bitwise": "error",
+ "no-continue": "error",
+ "no-inline-comments": "off",
+ "no-lonely-if": "warn",
+ "no-mixed-spaces-and-tabs": "error",
+ "no-multiple-empty-lines": ["error", {max: 1}],
+ "no-negated-condition": "warn",
+ "no-nested-ternary": "error",
+ "no-new-object": "error",
+ "no-plusplus": "off",
+ "no-restricted-syntax": "off",
+ "no-spaced-func": "error",
+ "no-ternary": "off",
+ "no-trailing-spaces": "error",
+ "no-underscore-dangle": ["error", { "allowAfterThis": true }],
+ "no-unneeded-ternary": "error",
+ "no-whitespace-before-property": "error",
+ "object-curly-spacing": ["error", "always"],
+ "one-var": ["error", "never"],
+ "one-var-declaration-per-line": ["error", "always"],
+ "operator-assignment": ["off", "never"],
+ "operator-linebreak": ["error", "after"],
+ "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", "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..57775146a
--- /dev/null
+++ b/frontend/.stylelintrc
@@ -0,0 +1,396 @@
+{
+"plugins": [
+ "stylelint-order"
+],
+"ignoreFiles": [
+ "frontend/src/Styles/scaffolding.css",
+ "frontend/src/Content/Fonts/font-awesome.css"
+],
+"rules": {
+ "at-rule-empty-line-before": [
+ "always",
+ {
+ "except": [
+ "inside-block"
+ ]
+ }
+ ],
+ "at-rule-name-case": "lower",
+ "at-rule-name-newline-after": "always-multi-line",
+ "at-rule-name-space-after": "always",
+ "at-rule-no-unknown": [
+ true,
+ {
+ "ignoreAtRules": [
+ "/^add\\-mixin$/",
+ "/^define\\-mixin$/"
+ ]
+ }
+ ],
+ "at-rule-no-vendor-prefix": true,
+ "at-rule-semicolon-newline-after": "always",
+ "at-rule-semicolon-space-before": "never",
+ "block-closing-brace-empty-line-before": "never",
+ "block-closing-brace-newline-after": "always",
+ "block-closing-brace-newline-before": "always",
+ "block-closing-brace-space-after": "always-single-line",
+ "block-closing-brace-space-before": "always-single-line",
+ "block-no-empty": true,
+ "block-opening-brace-newline-after": "always",
+ "block-opening-brace-newline-before": "never-single-line",
+ "block-opening-brace-space-after": "always-single-line",
+ "block-opening-brace-space-before": "always",
+ "color-hex-case": "lower",
+ "color-hex-length": "short",
+ "color-named": "never",
+ "color-no-invalid-hex": true,
+ "comment-whitespace-inside": "always",
+ "declaration-bang-space-after": "never",
+ "declaration-bang-space-before": "always",
+ "declaration-block-no-duplicate-properties": [
+ true,
+ {
+ "ignoreProperties": [
+ "composes"
+ ]
+ }
+ ],
+ "declaration-block-no-redundant-longhand-properties": true,
+ "declaration-block-no-shorthand-property-overrides": true,
+ "declaration-block-semicolon-newline-after": "always",
+ "declaration-block-semicolon-newline-before": "never-multi-line",
+ "declaration-block-semicolon-space-before": "never",
+ "declaration-block-single-line-max-declarations": 1,
+ "declaration-block-trailing-semicolon": "always",
+ "declaration-colon-space-after": "always",
+ "declaration-colon-space-before": "never",
+ "font-family-name-quotes": "always-unless-keyword",
+ "function-calc-no-unspaced-operator": true,
+ "function-comma-newline-after": "never-multi-line",
+ "function-comma-newline-before": "never-multi-line",
+ "function-comma-space-after": "always",
+ "function-comma-space-before": "never",
+ "function-linear-gradient-no-nonstandard-direction": true,
+ "function-name-case": "lower",
+ "function-parentheses-newline-inside": "never-multi-line",
+ "function-parentheses-space-inside": "never",
+ "function-url-quotes": "always",
+ "function-url-scheme-blacklist": [
+ "data"
+ ],
+ "function-whitespace-after": "always",
+ "indentation": 2,
+ "keyframe-declaration-no-important": true,
+ "length-zero-no-unit": true,
+ "max-empty-lines": 1,
+ "max-line-length": [
+ 100,
+ {
+ "ignore": [
+ "non-comments"
+ ]
+ }
+ ],
+ "max-nesting-depth": 2,
+ "media-feature-colon-space-after": "always",
+ "media-feature-colon-space-before": "never",
+ "media-feature-name-case": "lower",
+ "media-feature-name-no-vendor-prefix": true,
+ "media-feature-range-operator-space-after": "always",
+ "media-feature-range-operator-space-before": "always",
+ "no-empty-source": true,
+ "no-eol-whitespace": true,
+ "no-extra-semicolons": true,
+ "no-invalid-double-slash-comments": true,
+ "no-missing-end-of-source-newline": true,
+ "number-leading-zero": "always",
+ "number-no-trailing-zeros": true,
+ "order/order": [
+ "custom-properties",
+ "dollar-variables",
+ {
+ "hasBlock": false,
+ "name": "add-mixin",
+ "type": "at-rule"
+ },
+ "declarations",
+ "rules",
+ "at-rules"
+ ],
+ "order/properties-order": [
+ {
+ "emptyLineBefore": "always",
+ "properties": [
+ "composes"
+ ]
+ },
+ {
+ "emptyLineBefore": "always",
+ "properties": [
+ "position",
+ "top",
+ "right",
+ "bottom",
+ "left",
+ "z-index",
+ "display",
+ "visibility",
+ "align-content",
+ "align-items",
+ "align-self",
+ "justify-content",
+ "flex",
+ "flex-direction",
+ "flex-order",
+ "flex-pack",
+ "flex-align",
+ "flex-grow",
+ "flex-shrink",
+ "flex-basis",
+ "flex-wrap",
+ "flex-flow",
+ "float",
+ "clear",
+ "overflow",
+ "overflow-x",
+ "overflow-y",
+ "-webkit-overflow-scrolling",
+ "clip",
+ "box-sizing",
+ "margin",
+ "margin-top",
+ "margin-right",
+ "margin-bottom",
+ "margin-left",
+ "padding",
+ "padding-top",
+ "padding-right",
+ "padding-bottom",
+ "padding-left",
+ "min-width",
+ "min-height",
+ "max-width",
+ "max-height",
+ "width",
+ "height",
+ "outline",
+ "outline-width",
+ "outline-style",
+ "outline-color",
+ "outline-offset",
+ "border",
+ "border-spacing",
+ "border-collapse",
+ "border-width",
+ "border-style",
+ "border-color",
+ "border-top",
+ "border-top-width",
+ "border-top-style",
+ "border-top-color",
+ "border-right",
+ "border-right-width",
+ "border-right-style",
+ "border-right-color",
+ "border-bottom",
+ "border-bottom-width",
+ "border-bottom-style",
+ "border-bottom-color",
+ "border-left",
+ "border-left-width",
+ "border-left-style",
+ "border-left-color",
+ "border-radius",
+ "border-top-left-radius",
+ "border-top-right-radius",
+ "border-bottom-right-radius",
+ "border-bottom-left-radius",
+ "border-image",
+ "border-image-source",
+ "border-image-slice",
+ "border-image-width",
+ "border-image-outset",
+ "border-image-repeat",
+ "border-top-image",
+ "border-right-image",
+ "border-bottom-image",
+ "border-left-image",
+ "border-corner-image",
+ "border-top-left-image",
+ "border-top-right-image",
+ "border-bottom-right-image",
+ "border-bottom-left-image",
+ "background",
+ "background-color",
+ "background-image",
+ "background-attachment",
+ "background-position",
+ "background-position-x",
+ "background-position-y",
+ "background-clip",
+ "background-origin",
+ "background-size",
+ "background-repeat",
+ "box-decoration-break",
+ "box-shadow",
+ "color",
+ "table-layout",
+ "caption-side",
+ "empty-cells",
+ "list-style",
+ "list-style-position",
+ "list-style-type",
+ "list-style-image",
+ "quotes",
+ "content",
+ "counter-increment",
+ "counter-reset",
+ "-ms-writing-mode",
+ "vertical-align",
+ "text-align",
+ "text-align-last",
+ "text-decoration",
+ "text-emphasis",
+ "text-emphasis-position",
+ "text-emphasis-style",
+ "text-emphasis-color",
+ "text-indent",
+ "text-justify",
+ "text-outline",
+ "text-transform",
+ "text-wrap",
+ "text-overflow",
+ "text-overflow-ellipsis",
+ "text-overflow-mode",
+ "text-shadow",
+ "white-space",
+ "word-spacing",
+ "word-wrap",
+ "word-break",
+ "tab-size",
+ "hyphens",
+ "letter-spacing",
+ "font",
+ "font-weight",
+ "font-style",
+ "font-variant",
+ "font-size-adjust",
+ "font-stretch",
+ "font-size",
+ "font-family",
+ "font-smoothing",
+ "-moz-osx-font-smoothing",
+ "-webkit-font-smoothing",
+ "src",
+ "line-height",
+ "opacity",
+ "filter",
+ "resize",
+ "cursor",
+ "appearance",
+ "nav-index",
+ "nav-up",
+ "nav-right",
+ "nav-down",
+ "nav-left",
+ "transition",
+ "transition-delay",
+ "transition-timing-function",
+ "transition-duration",
+ "transition-property",
+ "transform",
+ "transform-origin",
+ "transform-style",
+ "backface-visibility",
+ "animation",
+ "animation-name",
+ "animation-duration",
+ "animation-play-state",
+ "animation-timing-function",
+ "animation-delay",
+ "animation-iteration-count",
+ "animation-direction",
+ "animation-fill-mode",
+ "pointer-events",
+ "user-select",
+ "touch-action",
+ "-webkit-tap-highlight-color",
+ "unicode-bidi",
+ "direction",
+ "columns",
+ "column-span",
+ "column-width",
+ "column-count",
+ "column-fill",
+ "column-gap",
+ "column-rule",
+ "column-rule-width",
+ "column-rule-style",
+ "column-rule-color",
+ "break-before",
+ "break-inside",
+ "break-after",
+ "page-break-before",
+ "page-break-inside",
+ "page-break-after",
+ "orphans",
+ "widows",
+ "zoom",
+ "max-zoom",
+ "min-zoom",
+ "user-zoom",
+ "orientation"
+ ]
+ }
+ ],
+ "property-case": "lower",
+ "property-no-vendor-prefix": true,
+ "rule-empty-line-before": [
+ "always",
+ {
+ "except": [
+ "first-nested"
+ ],
+ "ignore": [
+ "after-comment"
+ ]
+ }
+ ],
+ "selector-attribute-brackets-space-inside": "never",
+ "selector-attribute-operator-space-after": "never",
+ "selector-attribute-operator-space-before": "never",
+ "selector-attribute-quotes": "never",
+ "selector-class-pattern": "^[A-Za-z0-9]+$",
+ "selector-combinator-space-after": "always",
+ "selector-combinator-space-before": "always",
+ "selector-descendant-combinator-no-non-space": true,
+ "selector-list-comma-newline-after": "always",
+ "selector-list-comma-newline-before": "never-multi-line",
+ "selector-list-comma-space-before": "never",
+ "selector-max-attribute": 0,
+ "selector-max-class": 3,
+ "selector-max-compound-selectors": 3,
+ "selector-max-empty-lines": 0,
+ "selector-max-id": 0,
+ "selector-max-universal": 0,
+ "selector-pseudo-class-case": "lower",
+ "selector-pseudo-class-parentheses-space-inside": "never",
+ "selector-pseudo-element-case": "lower",
+ "selector-pseudo-element-colon-notation": "double",
+ "selector-pseudo-element-no-unknown": true,
+ "selector-type-case": "lower",
+ "selector-type-no-unknown": true,
+ "shorthand-property-no-redundant-values": true,
+ "string-no-newline": true,
+ "string-quotes": "single",
+ "time-min-milliseconds": 100,
+ "unit-case": "lower",
+ "unit-no-unknown": true,
+ "value-list-comma-newline-after": "never-multi-line",
+ "value-list-comma-newline-before": "never-multi-line",
+ "value-list-comma-space-after": "always",
+ "value-list-comma-space-before": "never",
+ "value-list-max-empty-lines": 0,
+ "value-no-vendor-prefix": true
+ }
+}
diff --git a/frontend/.tern-project b/frontend/.tern-project
new file mode 100644
index 000000000..aa9d76407
--- /dev/null
+++ b/frontend/.tern-project
@@ -0,0 +1,7 @@
+{
+ "ecmaVersion": 6,
+ "libs": [
+ "browser",
+ "jquery"
+ ]
+}
diff --git a/frontend/gulp/build.js b/frontend/gulp/build.js
new file mode 100644
index 000000000..cfeb5d138
--- /dev/null
+++ b/frontend/gulp/build.js
@@ -0,0 +1,15 @@
+const gulp = require('gulp');
+const runSequence = require('run-sequence');
+
+require('./clean');
+require('./copy');
+
+gulp.task('build', () => {
+ return runSequence('clean', [
+ 'webpack',
+ 'copyHtml',
+ 'copyFonts',
+ 'copyImages',
+ 'copyJs'
+ ]);
+});
diff --git a/frontend/gulp/clean.js b/frontend/gulp/clean.js
new file mode 100644
index 000000000..ac2e4026f
--- /dev/null
+++ b/frontend/gulp/clean.js
@@ -0,0 +1,8 @@
+const gulp = require('gulp');
+const del = require('del');
+
+const paths = require('./helpers/paths');
+
+gulp.task('clean', () => {
+ return del([paths.dest.root]);
+});
diff --git a/frontend/gulp/copy.js b/frontend/gulp/copy.js
new file mode 100644
index 000000000..d1d47c97e
--- /dev/null
+++ b/frontend/gulp/copy.js
@@ -0,0 +1,45 @@
+var path = require('path');
+var gulp = require('gulp');
+var print = require('gulp-print');
+var cache = require('gulp-cached');
+var livereload = require('gulp-livereload');
+var paths = require('./helpers/paths.js');
+
+gulp.task('copyJs', () => {
+ return gulp.src(
+ [
+ path.join(paths.src.root, 'polyfills.js')
+ ])
+ .pipe(cache('copyJs'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.root))
+ .pipe(livereload());
+});
+
+gulp.task('copyHtml', () => {
+ return gulp.src(paths.src.html)
+ .pipe(cache('copyHtml'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.root))
+ .pipe(livereload());
+});
+
+gulp.task('copyFonts', () => {
+ return gulp.src(
+ path.join(paths.src.fonts, '**', '*.*')
+ )
+ .pipe(cache('copyFonts'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.fonts))
+ .pipe(livereload());
+});
+
+gulp.task('copyImages', () => {
+ return gulp.src(
+ path.join(paths.src.images, '**', '*.*')
+ )
+ .pipe(cache('copyImages'))
+ .pipe(print())
+ .pipe(gulp.dest(paths.dest.images))
+ .pipe(livereload());
+});
diff --git a/frontend/gulp/gulpFile.js b/frontend/gulp/gulpFile.js
new file mode 100644
index 000000000..744dd8d7e
--- /dev/null
+++ b/frontend/gulp/gulpFile.js
@@ -0,0 +1,8 @@
+require('./build.js');
+require('./clean.js');
+require('./copy.js');
+require('./imageMin.js');
+require('./start.js');
+require('./stripBom.js');
+require('./watch.js');
+require('./webpack.js');
diff --git a/frontend/gulp/helpers/errorHandler.js b/frontend/gulp/helpers/errorHandler.js
new file mode 100644
index 000000000..f3e1c113b
--- /dev/null
+++ b/frontend/gulp/helpers/errorHandler.js
@@ -0,0 +1,6 @@
+const gulpUtil = require('gulp-util');
+
+module.exports = function errorHandler(error) {
+ gulpUtil.log(gulpUtil.colors.red(`Error (${error.plugin}): ${error.message}`));
+ this.emit('end');
+};
diff --git a/frontend/gulp/helpers/html-annotate-loader.js b/frontend/gulp/helpers/html-annotate-loader.js
new file mode 100644
index 000000000..6c7ce10b8
--- /dev/null
+++ b/frontend/gulp/helpers/html-annotate-loader.js
@@ -0,0 +1,15 @@
+const path = require('path');
+const rootPath = path.resolve(__dirname + '/../../src/');
+module.exports = function(source) {
+ if (this.cacheable) {
+ this.cacheable();
+ }
+
+ const resourcePath = this.resourcePath.replace(rootPath, '');
+ const wrappedSource =`
+
+ ${source}
+ `;
+
+ return wrappedSource;
+};
diff --git a/frontend/gulp/helpers/paths.js b/frontend/gulp/helpers/paths.js
new file mode 100644
index 000000000..b96b5aaeb
--- /dev/null
+++ b/frontend/gulp/helpers/paths.js
@@ -0,0 +1,23 @@
+const root = './frontend/src/';
+
+const paths = {
+ src: {
+ root,
+ html: root + '*.html',
+ scripts: root + '**/*.js',
+ content: root + 'Content/',
+ fonts: root + 'Content/Fonts/',
+ images: root + 'Content/Images/',
+ exclude: {
+ libs: `!${root}JsLibraries/**`
+ }
+ },
+ dest: {
+ root: './_output/UI/',
+ content: './_output/UI/Content/',
+ fonts: './_output/UI/Content/Fonts/',
+ images: './_output/UI/Content/Images/'
+ }
+};
+
+module.exports = paths;
diff --git a/frontend/gulp/imageMin.js b/frontend/gulp/imageMin.js
new file mode 100644
index 000000000..828143f28
--- /dev/null
+++ b/frontend/gulp/imageMin.js
@@ -0,0 +1,15 @@
+var gulp = require('gulp');
+var print = require('gulp-print');
+var paths = require('./helpers/paths.js');
+
+gulp.task('imageMin', () => {
+ var imagemin = require('gulp-imagemin');
+ return gulp.src(paths.src.images)
+ .pipe(imagemin({
+ progressive: false,
+ optimizationLevel: 4,
+ svgoPlugins: [{ removeViewBox: false }]
+ }))
+ .pipe(print())
+ .pipe(gulp.dest(paths.src.content + 'Images/'));
+});
diff --git a/frontend/gulp/start.js b/frontend/gulp/start.js
new file mode 100644
index 000000000..e2f65660b
--- /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.lidarr.audio/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 + '/Lidarr/*.*')
+ .pipe(gulp.dest('./_output/'));
+ });
+ });
+ });
+});
+
+gulp.task('startSonarr', function() {
+ var ls = spawn('mono', ['--debug', './_output/Lidarr.exe']);
+
+ ls.stdout.on('data', function(data) {
+ process.stdout.write(data);
+ });
+
+ ls.stderr.on('data', function(data) {
+ process.stdout.write(data);
+ });
+
+ ls.on('close', function(code) {
+ console.log('child process exited with code ' + code);
+ });
+});
diff --git a/frontend/gulp/stripBom.js b/frontend/gulp/stripBom.js
new file mode 100644
index 000000000..080b86dfe
--- /dev/null
+++ b/frontend/gulp/stripBom.js
@@ -0,0 +1,13 @@
+const gulp = require('gulp');
+const paths = require('./helpers/paths.js');
+const stripbom = require('gulp-stripbom');
+
+function stripBom(dest) {
+ gulp.src([paths.src.scripts, paths.src.exclude.libs])
+ .pipe(stripbom({ showLog: false }))
+ .pipe(gulp.dest(dest));
+}
+
+gulp.task('stripBom', () => {
+ stripBom(paths.src.root);
+});
diff --git a/frontend/gulp/watch.js b/frontend/gulp/watch.js
new file mode 100644
index 000000000..dae893c38
--- /dev/null
+++ b/frontend/gulp/watch.js
@@ -0,0 +1,27 @@
+var gulp = require('gulp');
+var livereload = require('gulp-livereload');
+var watch = require('gulp-watch');
+var paths = require('./helpers/paths.js');
+
+require('./copy.js');
+require('./webpack.js');
+
+function watchTask(glob, task) {
+ var options = {
+ name: `watch: ${task}`,
+ verbose: true
+ };
+ return watch(glob, options, () => {
+ gulp.start(task);
+ });
+}
+
+gulp.task('watch', ['copyHtml', 'copyFonts', 'copyImages', 'copyJs'], () => {
+ livereload.listen();
+
+ gulp.start('webpackWatch');
+
+ watchTask(paths.src.html, 'copyHtml');
+ watchTask(paths.src.fonts + '**/*.*', 'copyFonts');
+ watchTask(paths.src.images + '**/*.*', 'copyImages');
+});
diff --git a/frontend/gulp/webpack.js b/frontend/gulp/webpack.js
new file mode 100644
index 000000000..5faec4c87
--- /dev/null
+++ b/frontend/gulp/webpack.js
@@ -0,0 +1,195 @@
+const gulp = require('gulp');
+const webpackStream = require('webpack-stream');
+const livereload = require('gulp-livereload');
+const path = require('path');
+const webpack = require('webpack');
+const errorHandler = require('./helpers/errorHandler');
+const ExtractTextPlugin = require('extract-text-webpack-plugin');
+
+const uiFolder = 'UI';
+const root = path.join(__dirname, '..', 'src');
+const isProduction = process.argv.indexOf('--production') > -1;
+
+console.log('ROOT:', root);
+console.log('isProduction:', isProduction);
+
+const cssVarsFiles = [
+ '../src/Styles/Variables/colors',
+ '../src/Styles/Variables/dimensions',
+ '../src/Styles/Variables/fonts',
+ '../src/Styles/Variables/animations'
+].map(require.resolve);
+
+const extractCSSPlugin = new ExtractTextPlugin({
+ filename: path.join('_output', uiFolder, 'Content', 'styles.css'),
+ allChunks: true,
+ disable: false,
+ ignoreOrder: true
+});
+
+const config = {
+ devtool: '#source-map',
+ stats: {
+ children: false
+ },
+ watchOptions: {
+ ignored: /node_modules/
+ },
+ entry: {
+ preload: 'preload.js',
+ vendor: 'vendor.js',
+ index: 'index.js'
+ },
+ resolve: {
+ modules: [
+ root,
+ path.join(root, 'Shims'),
+ 'node_modules'
+ ],
+ alias: {
+ jquery: 'jquery/src/jquery'
+ }
+ },
+ output: {
+ filename: path.join('_output', uiFolder, '[name].js'),
+ sourceMapFilename: '[file].map'
+ },
+ plugins: [
+ extractCSSPlugin,
+ new webpack.optimize.CommonsChunkPlugin({
+ name: 'vendor'
+ }),
+
+ new webpack.DefinePlugin({
+ __DEV__: !isProduction,
+ 'process.env': {
+ NODE_ENV: isProduction ? JSON.stringify('production') : JSON.stringify('development')
+ }
+ })
+ ],
+ resolveLoader: {
+ modules: [
+ 'node_modules',
+ 'frontend/gulp/webpack/'
+ ]
+ },
+ // TODO: Do we need this loader?
+ // eslint: {
+ // formatter: function(results) {
+ // return JSON.stringify(results);
+ // }
+ // },
+ module: {
+ rules: [
+ {
+ test: /\.js?$/,
+ exclude: /(node_modules|JsLibraries)/,
+ loader: 'babel-loader',
+ query: {
+ plugins: ['transform-class-properties'],
+ presets: ['es2015', 'decorators-legacy', 'react', 'stage-2'],
+ env: {
+ development: {
+ plugins: ['transform-react-jsx-source']
+ }
+ }
+ }
+ },
+
+ // CSS Modules
+ {
+ test: /\.css$/,
+ exclude: /(node_modules|globals.css)/,
+ use: extractCSSPlugin.extract({
+ fallback: 'style-loader',
+ use: [
+ {
+ loader: 'css-variables-loader',
+ options: {
+ cssVarsFiles
+ }
+ },
+ {
+ loader: 'css-loader',
+ options: {
+ modules: true,
+ importLoaders: 1,
+ localIdentName: '[name]-[local]-[hash:base64:5]',
+ sourceMap: true
+ }
+ },
+ {
+ loader: 'postcss-loader',
+ options: {
+ config: {
+ ctx: {
+ cssVarsFiles
+ },
+ path: 'frontend/postcss.config.js'
+ }
+ }
+ }
+ ]
+ })
+ },
+
+ // Global styles
+ {
+ test: /\.css$/,
+ include: /(node_modules|globals.css)/,
+ use: [
+ 'style-loader',
+ {
+ loader: 'css-loader'
+ }
+ ]
+ },
+
+ // Fonts
+ {
+ test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/,
+ use: [
+ {
+ loader: 'url-loader',
+ options: {
+ limit: 10240,
+ mimetype: 'application/font-woff',
+ emitFile: false,
+ name: 'Content/Fonts/[name].[ext]'
+ }
+ }
+ ]
+ },
+
+ {
+ test: /\.(ttf|eot|eot?#iefix|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
+ use: [
+ {
+ loader: 'file-loader',
+ options: {
+ emitFile: false,
+ name: 'Content/Fonts/[name].[ext]'
+ }
+ }
+ ]
+ }
+ ]
+ }
+};
+
+gulp.task('webpack', () => {
+ return gulp.src('index.js')
+ .pipe(webpackStream(config))
+ .pipe(gulp.dest(''));
+});
+
+gulp.task('webpackWatch', () => {
+ config.watch = true;
+ return gulp.src('')
+ .pipe(webpackStream(config))
+ .on('error', errorHandler)
+ .pipe(gulp.dest(''))
+ .on('error', errorHandler)
+ .pipe(livereload())
+ .on('error', errorHandler);
+});
diff --git a/frontend/gulp/webpack/css-variables-loader.js b/frontend/gulp/webpack/css-variables-loader.js
new file mode 100644
index 000000000..5683c98be
--- /dev/null
+++ b/frontend/gulp/webpack/css-variables-loader.js
@@ -0,0 +1,11 @@
+const loaderUtils = require('loader-utils');
+
+module.exports = function cssVariablesLoader(source) {
+ const options = loaderUtils.getOptions(this);
+
+ options.cssVarsFiles.forEach((cssVarsFile) => {
+ this.addDependency(cssVarsFile);
+ });
+
+ return source;
+};
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 000000000..f82554ba8
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,33 @@
+const reload = require('require-nocache')(module);
+
+module.exports = (ctx, configPath, options) => {
+ const config = {
+ plugins: {
+ 'postcss-mixins': {
+ mixinsDir: [
+ 'frontend/src/Styles/Mixins'
+ ]
+ },
+ 'postcss-simple-vars': {
+ variables: () =>
+ ctx.options.cssVarsFiles.reduce((acc, vars) => {
+ return Object.assign(acc, reload(vars));
+ }, {})
+ },
+ 'postcss-nested': {},
+ autoprefixer: {
+ browsers: [
+ 'Chrome >= 30',
+ 'Firefox >= 30',
+ 'Safari >= 6',
+ 'Edge >= 12',
+ 'Explorer >= 11',
+ 'iOS >= 7',
+ 'Android >= 4.4'
+ ]
+ }
+ }
+ };
+
+ return config;
+};
diff --git a/frontend/src/.vscode/settings.json b/frontend/src/.vscode/settings.json
new file mode 100644
index 000000000..0fb2bf460
--- /dev/null
+++ b/frontend/src/.vscode/settings.json
@@ -0,0 +1,4 @@
+// Place your settings in this file to overwrite default and user settings.
+{
+ "files.insertFinalNewline": true
+}
\ No newline at end of file
diff --git a/frontend/src/Activity/Blacklist/Blacklist.js b/frontend/src/Activity/Blacklist/Blacklist.js
new file mode 100644
index 000000000..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..82c0d7ba4
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistConnector.js
@@ -0,0 +1,133 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import * as blacklistActions from 'Store/Actions/blacklistActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import Blacklist from './Blacklist';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.blacklist,
+ createCommandsSelector(),
+ (blacklist, commands) => {
+ const isClearingBlacklistExecuting = _.some(commands, { name: commandNames.CLEAR_BLACKLIST });
+
+ return {
+ isClearingBlacklistExecuting,
+ ...blacklist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...blacklistActions,
+ executeCommand
+};
+
+class BlacklistConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ registerPagePopulator(this.repopulate);
+ this.props.gotoBlacklistFirstPage();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.isClearingBlacklistExecuting && !this.props.isClearingBlacklistExecuting) {
+ this.props.gotoBlacklistFirstPage();
+ }
+ }
+
+ componentWillUnmount() {
+ 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,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(BlacklistConnector);
diff --git a/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js
new file mode 100644
index 000000000..356512a9d
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistDetailsModal.js
@@ -0,0 +1,89 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+class BlacklistDetailsModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ sourceTitle,
+ protocol,
+ indexer,
+ message,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+
+ Details
+
+
+
+
+
+
+
+
+ {
+ !!message &&
+
+ }
+
+ {
+ !!message &&
+
+ }
+
+
+
+
+
+ Close
+
+
+
+
+ );
+ }
+}
+
+BlacklistDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ protocol: PropTypes.string.isRequired,
+ indexer: PropTypes.string,
+ message: PropTypes.string,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default BlacklistDetailsModal;
diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.css b/frontend/src/Activity/Blacklist/BlacklistRow.css
new file mode 100644
index 000000000..030dfe98a
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRow.css
@@ -0,0 +1,18 @@
+.language,
+.quality {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
+
+.indexer {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 80px;
+}
+
+.details {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 30px;
+}
diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.js b/frontend/src/Activity/Blacklist/BlacklistRow.js
new file mode 100644
index 000000000..1acbe5133
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRow.js
@@ -0,0 +1,177 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import EpisodeLanguage from 'Album/EpisodeLanguage';
+import EpisodeQuality from 'Album/EpisodeQuality';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import BlacklistDetailsModal from './BlacklistDetailsModal';
+import styles from './BlacklistRow.css';
+
+class BlacklistRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDetailsPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artist,
+ sourceTitle,
+ language,
+ quality,
+ date,
+ protocol,
+ indexer,
+ message,
+ columns
+ } = this.props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'artist.sortName') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'sourceTitle') {
+ return (
+
+ {sourceTitle}
+
+ );
+ }
+
+ if (name === 'language') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'quality') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'date') {
+ return (
+
+ );
+ }
+
+ if (name === 'indexer') {
+ return (
+
+ {indexer}
+
+ );
+ }
+
+ if (name === 'details') {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+ );
+ }
+
+}
+
+BlacklistRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ artist: PropTypes.object.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ language: PropTypes.object.isRequired,
+ quality: PropTypes.object.isRequired,
+ date: PropTypes.string.isRequired,
+ protocol: PropTypes.string.isRequired,
+ indexer: PropTypes.string,
+ message: PropTypes.string,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default BlacklistRow;
diff --git a/frontend/src/Activity/Blacklist/BlacklistRowConnector.js b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js
new file mode 100644
index 000000000..f4f9217bf
--- /dev/null
+++ b/frontend/src/Activity/Blacklist/BlacklistRowConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import BlacklistRow from './BlacklistRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (artist) => {
+ return {
+ artist
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(BlacklistRow);
diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js
new file mode 100644
index 000000000..b2adf1171
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetails.js
@@ -0,0 +1,237 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatAge from 'Utilities/Number/formatAge';
+import Link from 'Components/Link/Link';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
+import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
+
+function HistoryDetails(props) {
+ const {
+ eventType,
+ sourceTitle,
+ data,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ if (eventType === 'grabbed') {
+ const {
+ indexer,
+ releaseGroup,
+ nzbInfoUrl,
+ downloadClient,
+ downloadId,
+ age,
+ ageHours,
+ ageMinutes,
+ publishedDate
+ } = data;
+
+ return (
+
+
+
+ {
+ !!indexer &&
+
+ }
+
+ {
+ !!releaseGroup &&
+
+ }
+
+ {
+ !!nzbInfoUrl &&
+
+
+ Info URL
+
+
+
+ {nzbInfoUrl}
+
+
+ }
+
+ {
+ !!downloadClient &&
+
+ }
+
+ {
+ !!downloadId &&
+
+ }
+
+ {
+ !!indexer &&
+
+ }
+
+ {
+ !!publishedDate &&
+
+ }
+
+ );
+ }
+
+ if (eventType === 'downloadFailed') {
+ const {
+ message
+ } = data;
+
+ return (
+
+
+
+ {
+ !!message &&
+
+ }
+
+ );
+ }
+
+ if (eventType === 'downloadFolderImported') {
+ const {
+ droppedPath,
+ importedPath
+ } = data;
+
+ return (
+
+
+
+ {
+ !!droppedPath &&
+
+ }
+
+ {
+ !!importedPath &&
+
+ }
+
+ );
+ }
+
+ if (eventType === 'trackFileDeleted') {
+ const {
+ reason
+ } = data;
+
+ let reasonMessage = '';
+
+ switch (reason) {
+ case 'Manual':
+ reasonMessage = 'File was deleted by via UI';
+ break;
+ case 'MissingFromDisk':
+ reasonMessage = 'Lidarr was unable to find the file on disk so it was removed';
+ break;
+ case 'Upgrade':
+ reasonMessage = 'File was deleted to import an upgrade';
+ break;
+ default:
+ reasonMessage = '';
+ }
+
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (eventType === 'trackFileRenamed') {
+ const {
+ sourcePath,
+ sourceRelativePath,
+ path,
+ relativePath
+ } = data;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+HistoryDetails.propTypes = {
+ eventType: PropTypes.string.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ data: PropTypes.object.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default HistoryDetails;
diff --git a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js
new file mode 100644
index 000000000..0848c7905
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js
@@ -0,0 +1,19 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import HistoryDetails from './HistoryDetails';
+
+function createMapStateToProps() {
+ return createSelector(
+ createUISettingsSelector(),
+ (uiSettings) => {
+ return _.pick(uiSettings, [
+ 'shortDateFormat',
+ 'timeFormat'
+ ]);
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(HistoryDetails);
diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.css b/frontend/src/Activity/History/Details/HistoryDetailsModal.css
new file mode 100644
index 000000000..bdcb7f918
--- /dev/null
+++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.css
@@ -0,0 +1,5 @@
+.markAsFailedButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.js
new file mode 100644
index 000000000..ca8b9ca3a
--- /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 'Track Imported';
+ case 'trackFileDeleted':
+ return 'Track File Deleted';
+ case 'trackFileRenamed':
+ return 'Track File Renamed';
+ default:
+ return 'Unknown';
+ }
+}
+
+function HistoryDetailsModal(props) {
+ const {
+ isOpen,
+ eventType,
+ sourceTitle,
+ data,
+ isMarkingAsFailed,
+ shortDateFormat,
+ timeFormat,
+ onMarkAsFailedPress,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ {getHeaderTitle(eventType)}
+
+
+
+
+
+
+
+ {
+ eventType === 'grabbed' &&
+
+ Mark as Failed
+
+ }
+
+
+ Close
+
+
+
+
+ );
+}
+
+HistoryDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ eventType: PropTypes.string.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ data: PropTypes.object.isRequired,
+ isMarkingAsFailed: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+HistoryDetailsModal.defaultProps = {
+ isMarkingAsFailed: false
+};
+
+export default HistoryDetailsModal;
diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js
new file mode 100644
index 000000000..f8990cfbf
--- /dev/null
+++ b/frontend/src/Activity/History/History.js
@@ -0,0 +1,217 @@
+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 MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import HistoryRowConnector from './HistoryRowConnector';
+
+class History extends Component {
+
+ //
+ // Lifecycle
+
+ shouldComponentUpdate(nextProps) {
+ // Don't update when fetching has completed if items have changed,
+ // before albums start fetching or when albums start fetching.
+
+ if (
+ (
+ this.props.isFetching &&
+ nextProps.isPopulated &&
+ hasDifferentItems(this.props.items, nextProps.items)
+ ) ||
+ (!this.props.isAlbumsFetching && nextProps.isAlbumsFetching)
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ filterKey,
+ filterValue,
+ totalRecords,
+ isAlbumsFetching,
+ isAlbumsPopulated,
+ episodesError,
+ onFilterSelect,
+ onFirstPagePress,
+ ...otherProps
+ } = this.props;
+
+ const isFetchingAny = isFetching || isAlbumsFetching;
+ const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length);
+ const hasError = error || episodesError;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ All
+
+
+
+ Grabbed
+
+
+
+ Imported
+
+
+
+ Failed
+
+
+
+ Deleted
+
+
+
+ Renamed
+
+
+
+
+
+
+
+ {
+ isFetchingAny && !isAllPopulated &&
+
+ }
+
+ {
+ !isFetchingAny && hasError &&
+ Unable to load history
+ }
+
+ {
+ // If history isPopulated and it's empty show no history found and don't
+ // wait for the albums to populate because they are never coming.
+
+ isPopulated && !hasError && !items.length &&
+
+ No history found
+
+ }
+
+ {
+ isAllPopulated && !hasError && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ }
+
+
+ );
+ }
+}
+
+History.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.string,
+ totalRecords: PropTypes.number,
+ isAlbumsFetching: PropTypes.bool.isRequired,
+ isAlbumsPopulated: 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..c3eb9d8b1
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryConnector.js
@@ -0,0 +1,138 @@
+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 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 {
+ isAlbumsFetching: episodes.isFetching,
+ isAlbumsPopulated: episodes.isPopulated,
+ episodesError: episodes.error,
+ ...history
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...historyActions,
+ fetchEpisodes,
+ clearEpisodes
+};
+
+class HistoryConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ registerPagePopulator(this.repopulate);
+ this.props.gotoHistoryFirstPage();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const albumIds = selectUniqueIds(this.props.items, 'albumId');
+ this.props.fetchEpisodes({ albumIds });
+ }
+ }
+
+ 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 = (filterKey, filterValue) => {
+ this.props.setHistoryFilter({ filterKey, filterValue });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setHistoryTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoHistoryFirstPage();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+HistoryConnector.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchHistory: PropTypes.func.isRequired,
+ gotoHistoryFirstPage: PropTypes.func.isRequired,
+ gotoHistoryPreviousPage: PropTypes.func.isRequired,
+ gotoHistoryNextPage: PropTypes.func.isRequired,
+ gotoHistoryLastPage: PropTypes.func.isRequired,
+ gotoHistoryPage: PropTypes.func.isRequired,
+ setHistorySort: PropTypes.func.isRequired,
+ setHistoryFilter: PropTypes.func.isRequired,
+ setHistoryTableOption: PropTypes.func.isRequired,
+ clearHistory: PropTypes.func.isRequired,
+ fetchEpisodes: PropTypes.func.isRequired,
+ clearEpisodes: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector);
diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.css b/frontend/src/Activity/History/HistoryEventTypeCell.css
new file mode 100644
index 000000000..086354783
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryEventTypeCell.css
@@ -0,0 +1,3 @@
+.cell {
+ width: 35px;
+}
diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.js
new file mode 100644
index 000000000..065ab0492
--- /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 'artistFolderImported':
+ return icons.DRIVE;
+ case 'downloadFolderImported':
+ return icons.DOWNLOADED;
+ case 'downloadFailed':
+ return icons.DOWNLOADING;
+ case 'trackFileDeleted':
+ return icons.DELETE;
+ case 'trackFileRenamed':
+ 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 `Album grabbed from ${data.indexer} and sent to ${data.downloadClient}`;
+ case 'artistFolderImported':
+ return 'Track imported from artist folder';
+ case 'downloadFolderImported':
+ return 'Track downloaded successfully and picked up from download client';
+ case 'downloadFailed':
+ return 'Album download failed';
+ case 'trackFileDeleted':
+ return 'Track file deleted';
+ case 'trackFileRenamed':
+ return 'Track file renamed';
+ 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..cf48c36d2
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryRow.js
@@ -0,0 +1,255 @@
+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 'Album/episodeEntities';
+import EpisodeTitleLink from 'Album/EpisodeTitleLink';
+import EpisodeLanguage from 'Album/EpisodeLanguage';
+import EpisodeQuality from 'Album/EpisodeQuality';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import HistoryEventTypeCell from './HistoryEventTypeCell';
+import HistoryDetailsModal from './Details/HistoryDetailsModal';
+import styles from './HistoryRow.css';
+
+class HistoryRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ prevProps.isMarkingAsFailed &&
+ !this.props.isMarkingAsFailed &&
+ !this.props.markAsFailedError
+ ) {
+ this.setState({ isDetailsModalOpen: false });
+ }
+ }
+
+ //
+ // Listeners
+
+ onDetailsPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ albumId,
+ artist,
+ album,
+ track,
+ language,
+ quality,
+ eventType,
+ sourceTitle,
+ date,
+ data,
+ isMarkingAsFailed,
+ columns,
+ shortDateFormat,
+ timeFormat,
+ onMarkAsFailedPress
+ } = this.props;
+
+ if (!album) {
+ return null;
+ }
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'eventType') {
+ return (
+
+ );
+ }
+
+ if (name === 'artist.sortName') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'episodeTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'trackTitle') {
+ return (
+
+ {track.title}
+
+ );
+ }
+
+ 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 = {
+ albumId: PropTypes.number,
+ artist: PropTypes.object.isRequired,
+ album: PropTypes.object,
+ track: PropTypes.object,
+ language: PropTypes.object.isRequired,
+ quality: PropTypes.object.isRequired,
+ eventType: PropTypes.string.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ date: PropTypes.string.isRequired,
+ data: PropTypes.object.isRequired,
+ isMarkingAsFailed: PropTypes.bool,
+ markAsFailedError: PropTypes.object,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired
+};
+
+HistoryRow.defaultProps = {
+ track: {
+ title: ''
+ }
+};
+
+export default HistoryRow;
diff --git a/frontend/src/Activity/History/HistoryRowConnector.js b/frontend/src/Activity/History/HistoryRowConnector.js
new file mode 100644
index 000000000..d8fab748d
--- /dev/null
+++ b/frontend/src/Activity/History/HistoryRowConnector.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 { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import HistoryRow from './HistoryRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createEpisodeSelector(),
+ createUISettingsSelector(),
+ (artist, album, uiSettings) => {
+ return {
+ artist,
+ album,
+ shortDateFormat: uiSettings.shortDateFormat,
+ timeFormat: uiSettings.timeFormat
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchHistory,
+ markAsFailed
+};
+
+class HistoryRowConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ if (
+ prevProps.isMarkingAsFailed &&
+ !this.props.isMarkingAsFailed &&
+ !this.props.markAsFailedError
+ ) {
+ this.props.fetchHistory();
+ }
+ }
+
+ //
+ // Listeners
+
+ onMarkAsFailedPress = () => {
+ this.props.markAsFailed({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+HistoryRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ isMarkingAsFailed: PropTypes.bool,
+ markAsFailedError: PropTypes.object,
+ fetchHistory: PropTypes.func.isRequired,
+ markAsFailed: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(HistoryRowConnector);
diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css b/frontend/src/Activity/Queue/ProtocolLabel.css
new file mode 100644
index 000000000..15e8e4fc6
--- /dev/null
+++ b/frontend/src/Activity/Queue/ProtocolLabel.css
@@ -0,0 +1,13 @@
+.torrent {
+ composes: label from 'Components/Label.css';
+
+ border-color: $torrentColor;
+ background-color: $torrentColor;
+}
+
+.usenet {
+ composes: label from 'Components/Label.css';
+
+ border-color: $usenetColor;
+ background-color: $usenetColor;
+}
diff --git a/frontend/src/Activity/Queue/ProtocolLabel.js b/frontend/src/Activity/Queue/ProtocolLabel.js
new file mode 100644
index 000000000..e8a08943c
--- /dev/null
+++ b/frontend/src/Activity/Queue/ProtocolLabel.js
@@ -0,0 +1,20 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Label from 'Components/Label';
+import styles from './ProtocolLabel.css';
+
+function ProtocolLabel({ protocol }) {
+ const protocolName = protocol === 'usenet' ? 'nzb' : protocol;
+
+ return (
+
+ {protocolName}
+
+ );
+}
+
+ProtocolLabel.propTypes = {
+ protocol: PropTypes.string.isRequired
+};
+
+export default ProtocolLabel;
diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js
new file mode 100644
index 000000000..8cfa4c77d
--- /dev/null
+++ b/frontend/src/Activity/Queue/Queue.js
@@ -0,0 +1,264 @@
+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 QueueRowConnector from './QueueRowConnector';
+
+class Queue extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isPendingSelected: false,
+ isConfirmRemoveModalOpen: false
+ };
+ }
+
+ shouldComponentUpdate(nextProps) {
+ // Don't update when fetching has completed if items have changed,
+ // before albums start fetching or when albums start fetching.
+
+ if (
+ (
+ this.props.isFetching &&
+ nextProps.isPopulated &&
+ hasDifferentItems(this.props.items, nextProps.items)
+ ) ||
+ (!this.props.isAlbumsFetching && nextProps.isAlbumsFetching)
+ ) {
+ 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,
+ isAlbumsFetching,
+ isAlbumsPopulated,
+ episodesError,
+ columns,
+ totalRecords,
+ isGrabbing,
+ isRemoving,
+ isCheckForFinishedDownloadExecuting,
+ onRefreshPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmRemoveModalOpen,
+ isPendingSelected
+ } = this.state;
+
+ const isRefreshing = isFetching || isAlbumsFetching || isCheckForFinishedDownloadExecuting;
+ const isAllPopulated = isPopulated && (isAlbumsPopulated || !items.length);
+ 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,
+ isAlbumsFetching: PropTypes.bool.isRequired,
+ isAlbumsPopulated: 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..2e5c36ced
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueConnector.js
@@ -0,0 +1,162 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+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.paged,
+ createCommandsSelector(),
+ (episodes, queue, commands) => {
+ const isCheckForFinishedDownloadExecuting = _.some(commands, { name: commandNames.CHECK_FOR_FINISHED_DOWNLOAD });
+
+ return {
+ isAlbumsFetching: episodes.isFetching,
+ isAlbumsPopulated: episodes.isPopulated,
+ episodesError: episodes.error,
+ isCheckForFinishedDownloadExecuting,
+ ...queue
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...queueActions,
+ fetchEpisodes,
+ clearEpisodes,
+ executeCommand
+};
+
+class QueueConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ registerPagePopulator(this.repopulate);
+ this.props.gotoQueueFirstPage();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const albumIds = selectUniqueIds(this.props.items, 'albumId');
+ this.props.fetchEpisodes({ albumIds });
+
+ }
+ }
+
+ 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 connect(createMapStateToProps, mapDispatchToProps)(QueueConnector);
diff --git a/frontend/src/Activity/Queue/QueueDetails.js b/frontend/src/Activity/Queue/QueueDetails.js
new file mode 100644
index 000000000..f6e360c0a
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueDetails.js
@@ -0,0 +1,97 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+
+function QueueDetails(props) {
+ const {
+ title,
+ size,
+ sizeleft,
+ estimatedCompletionTime,
+ status: queueStatus,
+ errorMessage,
+ progressBar
+ } = props;
+
+ const status = queueStatus.toLowerCase();
+
+ const progress = (100 - sizeleft / size * 100);
+
+ if (status === 'pending') {
+ return (
+
+ );
+ }
+
+ if (status === 'completed') {
+ if (errorMessage) {
+ return (
+
+ );
+ }
+
+ // TODO: show an icon when download is complete, but not imported yet?
+ }
+
+ if (errorMessage) {
+ return (
+
+ );
+ }
+
+ if (status === 'failed') {
+ return (
+
+ );
+ }
+
+ if (status === 'warning') {
+ return (
+
+ );
+ }
+
+ if (progress < 5) {
+ return (
+
+ );
+ }
+
+ return progressBar;
+}
+
+QueueDetails.propTypes = {
+ title: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ sizeleft: PropTypes.number.isRequired,
+ estimatedCompletionTime: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ errorMessage: PropTypes.string,
+ progressBar: PropTypes.node.isRequired
+};
+
+export default QueueDetails;
diff --git a/frontend/src/Activity/Queue/QueueRow.css b/frontend/src/Activity/Queue/QueueRow.css
new file mode 100644
index 000000000..6aa4a1622
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueRow.css
@@ -0,0 +1,23 @@
+.quality {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
+
+.protocol {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
+
+.progress {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 70px;
+}
diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js
new file mode 100644
index 000000000..fcb13a9f8
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueRow.js
@@ -0,0 +1,331 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import ProgressBar from 'Components/ProgressBar';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
+import EpisodeTitleLink from 'Album/EpisodeTitleLink';
+import EpisodeQuality from 'Album/EpisodeQuality';
+import SeasonEpisodeNumber from 'Album/SeasonEpisodeNumber';
+import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import QueueStatusCell from './QueueStatusCell';
+import TimeleftCell from './TimeleftCell';
+import RemoveQueueItemModal from './RemoveQueueItemModal';
+import styles from './QueueRow.css';
+
+class QueueRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRemoveQueueItemModalOpen: false,
+ isInteractiveImportModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onRemoveQueueItemPress = () => {
+ this.setState({ isRemoveQueueItemModalOpen: true });
+ }
+
+ onRemoveQueueItemModalConfirmed = (blacklist) => {
+ this.props.onRemoveQueueItemPress(blacklist);
+ this.setState({ isRemoveQueueItemModalOpen: false });
+ }
+
+ onRemoveQueueItemModalClose = () => {
+ this.setState({ isRemoveQueueItemModalOpen: false });
+ }
+
+ onInteractiveImportPress = () => {
+ this.setState({ isInteractiveImportModalOpen: true });
+ }
+
+ onInteractiveImportModalClose = () => {
+ this.setState({ isInteractiveImportModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ downloadId,
+ title,
+ status,
+ trackedDownloadStatus,
+ statusMessages,
+ errorMessage,
+ artist,
+ 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 === 'artist.sortName') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'artist') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'episodeTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'quality') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'protocol') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'indexer') {
+ return (
+
+ {indexer}
+
+ );
+ }
+
+ if (name === 'downloadClient') {
+ return (
+
+ {downloadClient}
+
+ );
+ }
+
+ if (name === 'estimatedCompletionTime') {
+ return (
+
+ );
+ }
+
+ if (name === 'progress') {
+ return (
+
+ {
+ !!progress &&
+
+ }
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ {
+ showInteractiveImport &&
+
+ }
+
+ {
+ isPending &&
+
+ }
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+
+
+ );
+ }
+
+}
+
+QueueRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ downloadId: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ status: PropTypes.string.isRequired,
+ trackedDownloadStatus: PropTypes.string,
+ statusMessages: PropTypes.arrayOf(PropTypes.object),
+ errorMessage: PropTypes.string,
+ artist: PropTypes.object.isRequired,
+ episode: PropTypes.object.isRequired,
+ quality: PropTypes.object.isRequired,
+ protocol: PropTypes.string.isRequired,
+ indexer: PropTypes.string,
+ downloadClient: PropTypes.string,
+ estimatedCompletionTime: PropTypes.string,
+ timeleft: PropTypes.string,
+ size: PropTypes.number,
+ sizeleft: PropTypes.number,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ isGrabbing: PropTypes.bool.isRequired,
+ grabError: PropTypes.object,
+ isRemoving: PropTypes.bool.isRequired,
+ isSelected: PropTypes.bool,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSelectedChange: PropTypes.func.isRequired,
+ onGrabPress: PropTypes.func.isRequired,
+ onRemoveQueueItemPress: PropTypes.func.isRequired
+};
+
+QueueRow.defaultProps = {
+ isGrabbing: false,
+ isRemoving: false
+};
+
+export default QueueRow;
diff --git a/frontend/src/Activity/Queue/QueueRowConnector.js b/frontend/src/Activity/Queue/QueueRowConnector.js
new file mode 100644
index 000000000..bc6221d84
--- /dev/null
+++ b/frontend/src/Activity/Queue/QueueRowConnector.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 { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import QueueRow from './QueueRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createEpisodeSelector(),
+ createUISettingsSelector(),
+ (artist, episode, uiSettings) => {
+ const result = _.pick(uiSettings, [
+ 'showRelativeDates',
+ 'shortDateFormat',
+ 'timeFormat'
+ ]);
+
+ result.artist = artist;
+ result.episode = episode;
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ grabQueueItem,
+ removeQueueItem
+};
+
+class QueueRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onGrabPress = () => {
+ this.props.grabQueueItem({ id: this.props.id });
+ }
+
+ onRemoveQueueItemPress = (blacklist) => {
+ this.props.removeQueueItem({ id: this.props.id, blacklist });
+ }
+
+ //
+ // Render
+
+ render() {
+ if (!this.props.episode) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+QueueRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ 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..915174dbf
--- /dev/null
+++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.js
@@ -0,0 +1,114 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './RemoveQueueItemModal.css';
+
+class RemoveQueueItemModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ blacklist: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onBlacklistChange = ({ value }) => {
+ this.setState({ blacklist: value });
+ }
+
+ onRemoveQueueItemConfirmed = () => {
+ const blacklist = this.state.blacklist;
+
+ this.setState({ blacklist: false });
+ this.props.onRemovePress(blacklist);
+ }
+
+ onModalClose = () => {
+ this.setState({ blacklist: false });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ sourceTitle
+ } = this.props;
+
+ const blacklist = this.state.blacklist;
+
+ return (
+
+
+
+ Remove - {sourceTitle}
+
+
+
+
+ Are you sure you want to remove '{sourceTitle}' from the queue?
+
+
+
+ Blacklist Release
+
+
+
+
+
+
+
+ Close
+
+
+
+ Remove
+
+
+
+
+ );
+ }
+}
+
+RemoveQueueItemModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ onRemovePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default RemoveQueueItemModal;
diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.css b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css
new file mode 100644
index 000000000..c9ef59ec1
--- /dev/null
+++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.css
@@ -0,0 +1,3 @@
+.message {
+ margin-bottom: 30px;
+}
diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js
new file mode 100644
index 000000000..e97857236
--- /dev/null
+++ b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js
@@ -0,0 +1,114 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './RemoveQueueItemsModal.css';
+
+class RemoveQueueItemsModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ blacklist: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onBlacklistChange = ({ value }) => {
+ this.setState({ blacklist: value });
+ }
+
+ onRemoveQueueItemConfirmed = () => {
+ const blacklist = this.state.blacklist;
+
+ this.setState({ blacklist: false });
+ this.props.onRemovePress(blacklist);
+ }
+
+ onModalClose = () => {
+ this.setState({ blacklist: false });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ selectedCount
+ } = this.props;
+
+ const blacklist = this.state.blacklist;
+
+ return (
+
+
+
+ Remove Selected Item{selectedCount > 1 ? 's' : ''}
+
+
+
+
+ Are you sure you want to remove {selectedCount} item{selectedCount > 1 ? 's' : ''} from the queue?
+
+
+
+ Blacklist Release
+
+
+
+
+
+
+
+ Close
+
+
+
+ Remove
+
+
+
+
+ );
+ }
+}
+
+RemoveQueueItemsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ selectedCount: PropTypes.number.isRequired,
+ onRemovePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default RemoveQueueItemsModal;
diff --git a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js
new file mode 100644
index 000000000..c8419a8f8
--- /dev/null
+++ b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQueueStatus } from 'Store/Actions/queueActions';
+import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app,
+ (state) => state.queue.queueStatus,
+ (app, status) => {
+ return {
+ isConnected: app.isConnected,
+ isReconnecting: app.isReconnecting,
+ isPopulated: status.isPopulated,
+ ...status.item
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQueueStatus
+};
+
+class QueueStatusConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.isPopulated) {
+ this.props.fetchQueueStatus();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.isConnected && prevProps.isReconnecting) {
+ this.props.fetchQueueStatus();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QueueStatusConnector.propTypes = {
+ isConnected: PropTypes.bool.isRequired,
+ isReconnecting: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ fetchQueueStatus: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector);
diff --git a/frontend/src/Activity/Queue/TimeleftCell.css b/frontend/src/Activity/Queue/TimeleftCell.css
new file mode 100644
index 000000000..eb58cf297
--- /dev/null
+++ b/frontend/src/Activity/Queue/TimeleftCell.css
@@ -0,0 +1,5 @@
+.timeleft {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Activity/Queue/TimeleftCell.js b/frontend/src/Activity/Queue/TimeleftCell.js
new file mode 100644
index 000000000..c9515f172
--- /dev/null
+++ b/frontend/src/Activity/Queue/TimeleftCell.js
@@ -0,0 +1,82 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatTime from 'Utilities/Date/formatTime';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './TimeleftCell.css';
+
+function TimeleftCell(props) {
+ const {
+ estimatedCompletionTime,
+ timeleft,
+ status,
+ size,
+ sizeleft,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ if (status === 'Delay') {
+ const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates);
+ const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
+
+ return (
+
+ -
+
+ );
+ }
+
+ if (status === 'DownloadClientUnavailable') {
+ const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates);
+ const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
+
+ return (
+
+ -
+
+ );
+ }
+
+ if (!timeleft) {
+ return (
+
+ -
+
+ );
+ }
+
+ const totalSize = formatBytes(size);
+ const remainingSize = formatBytes(sizeleft);
+
+ return (
+
+ {formatTimeSpan(timeleft)}
+
+ );
+}
+
+TimeleftCell.propTypes = {
+ estimatedCompletionTime: PropTypes.string,
+ timeleft: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ sizeleft: PropTypes.number.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default TimeleftCell;
diff --git a/frontend/src/Activity/activity.less b/frontend/src/Activity/activity.less
new file mode 100644
index 000000000..c6d9b6d2a
--- /dev/null
+++ b/frontend/src/Activity/activity.less
@@ -0,0 +1,27 @@
+
+.queue-status-cell .popover {
+ max-width: 800px;
+}
+
+.queue {
+ .protocol-cell {
+ text-align: center;
+ width: 80px;
+ }
+
+ .episode-number-cell {
+ min-width: 90px;
+ }
+}
+
+.remove-from-queue-modal {
+ .form-horizontal {
+ margin-top: 20px;
+ }
+}
+
+.history-detail-modal {
+ .info {
+ word-wrap: break-word;
+ }
+}
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtist.css b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.css
new file mode 100644
index 000000000..c1ec4fbe3
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.css
@@ -0,0 +1,54 @@
+.searchContainer {
+ display: flex;
+ margin-bottom: 10px;
+}
+
+.searchIconContainer {
+ width: 58px;
+ height: 46px;
+ border: 1px solid $inputBorderColor;
+ border-right: none;
+ border-radius: 4px;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ background-color: #edf1f2;
+ text-align: center;
+ line-height: 46px;
+}
+
+.searchInput {
+ composes: text from 'Components/Form/TextInput.css';
+
+ height: 46px;
+ border-radius: 0;
+ font-size: 18px;
+}
+
+.clearLookupButton {
+ border: 1px solid $inputBorderColor;
+ border-left: none;
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 4px;
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+}
+
+.message {
+ margin-top: 30px;
+ text-align: center;
+}
+
+.helpText {
+ margin-bottom: 10px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.noResults {
+ margin-bottom: 10px;
+ font-weight: 300;
+ font-size: 30px;
+}
+
+.searchResults {
+ margin-top: 30px;
+}
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js
new file mode 100644
index 000000000..59c9e2e7f
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.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 AddNewArtistSearchResultConnector from './AddNewArtistSearchResultConnector';
+import styles from './AddNewArtist.css';
+
+class AddNewArtist extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ term: props.term || '',
+ isFetching: false
+ };
+ }
+
+ componentDidMount() {
+ const term = this.state.term;
+
+ if (term) {
+ this.props.onArtistLookupChange(term);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ term,
+ isFetching
+ } = this.props;
+
+ if (term && term !== prevProps.term) {
+ this.setState({
+ term,
+ isFetching: true
+ });
+ this.props.onArtistLookupChange(term);
+ } else if (isFetching !== prevProps.isFetching) {
+ this.setState({
+ isFetching
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onSearchInputChange = ({ value }) => {
+ const hasValue = !!value.trim();
+
+ this.setState({ term: value, isFetching: hasValue }, () => {
+ if (hasValue) {
+ this.props.onArtistLookupChange(value);
+ } else {
+ this.props.onClearArtistLookup();
+ }
+ });
+ }
+
+ onClearArtistLookupPress = () => {
+ this.setState({ term: '' });
+ this.props.onClearArtistLookup();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ error,
+ items
+ } = this.props;
+
+ const term = this.state.term;
+ const isFetching = this.state.isFetching;
+
+ return (
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Failed to load search results, please try again.
+ }
+
+ {
+ !isFetching && !error && !!items.length &&
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !isFetching && !error && !items.length && !!term &&
+
+
Couldn't find any results for '{term}'
+
You can also search using MusicBrainz ID of a show. eg. lidarr:71663
+
+
+ Why can't I find my artist?
+
+
+
+ }
+
+ {
+ !term &&
+
+
It's easy to add a new artist, just start typing the name the artist you want to add.
+
You can also search using MusicBrainz ID of a show. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234
+
+ }
+
+
+
+
+ );
+ }
+}
+
+AddNewArtist.propTypes = {
+ term: PropTypes.string,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isAdding: PropTypes.bool.isRequired,
+ addError: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onArtistLookupChange: PropTypes.func.isRequired,
+ onClearArtistLookup: PropTypes.func.isRequired
+};
+
+export default AddNewArtist;
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistConnector.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistConnector.js
new file mode 100644
index 000000000..a2e322b40
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistConnector.js
@@ -0,0 +1,102 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import queryString from 'query-string';
+import { lookupArtist, clearAddArtist } from 'Store/Actions/addArtistActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import AddNewArtist from './AddNewArtist';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addArtist,
+ (state) => state.routing.location,
+ (addArtist, location) => {
+ const query = queryString.parse(location.search);
+
+ return {
+ term: query.term,
+ ...addArtist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ lookupArtist,
+ clearAddArtist,
+ fetchRootFolders
+};
+
+class AddNewArtistConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._artistLookupTimeout = null;
+ }
+
+ componentDidMount() {
+ this.props.fetchRootFolders();
+ }
+
+ componentWillUnmount() {
+ if (this._artistLookupTimeout) {
+ clearTimeout(this._artistLookupTimeout);
+ }
+
+ this.props.clearAddArtist();
+ }
+
+ //
+ // Listeners
+
+ onArtistLookupChange = (term) => {
+ if (this._artistLookupTimeout) {
+ clearTimeout(this._artistLookupTimeout);
+ }
+
+ if (term.trim() === '') {
+ this.props.clearAddArtist();
+ } else {
+ this._artistLookupTimeout = setTimeout(() => {
+ this.props.lookupArtist({ term });
+ }, 300);
+ }
+ }
+
+ onClearArtistLookup = () => {
+ this.props.clearAddArtist();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ term,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+AddNewArtistConnector.propTypes = {
+ term: PropTypes.string,
+ lookupArtist: PropTypes.func.isRequired,
+ clearAddArtist: PropTypes.func.isRequired,
+ fetchRootFolders: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddNewArtistConnector);
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModal.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModal.js
new file mode 100644
index 000000000..e94a8a229
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddNewArtistModalContentConnector from './AddNewArtistModalContentConnector';
+
+function AddNewArtistModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+AddNewArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNewArtistModal;
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css
new file mode 100644
index 000000000..c478d8b0c
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css
@@ -0,0 +1,76 @@
+.container {
+ display: flex;
+}
+
+.year {
+ margin-left: 5px;
+ color: $disabledColor;
+}
+
+.poster {
+ flex: 0 0 170px;
+ margin-right: 20px;
+ height: 250px;
+}
+
+.info {
+ flex-grow: 1;
+}
+
+.overview {
+ margin-bottom: 30px;
+ max-height: 230px;
+ text-align: justify;
+}
+
+.labelIcon {
+ margin-left: 8px;
+}
+
+.searchForMissingAlbumsLabelContainer {
+ display: flex;
+ margin-top: 2px;
+}
+
+.searchForMissingAlbumsLabel {
+ margin-right: 8px;
+ font-weight: normal;
+}
+
+.searchForMissingAlbumsContainer {
+ composes: container from 'Components/Form/CheckInput.css';
+
+ flex: 0 1 0;
+}
+
+.searchForMissingAlbumsInput {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin-top: 0;
+}
+
+.modalFooter {
+ composes: modalFooter from 'Components/Modal/ModalFooter.css';
+}
+
+.addButton {
+ @add-mixin truncate;
+ composes: button from 'Components/Link/SpinnerButton.css';
+}
+
+.hideLanguageProfile {
+ composes: group from 'Components/Form/FormGroup.css';
+
+ display: none;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .modalFooter {
+ display: block;
+ text-align: center;
+ }
+
+ .addButton {
+ margin-top: 10px;
+ }
+}
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js
new file mode 100644
index 000000000..6f0b0d838
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js
@@ -0,0 +1,239 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextTruncate from 'react-text-truncate';
+import { icons, kinds, inputTypes, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import CheckInput from 'Components/Form/CheckInput';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Popover from 'Components/Tooltip/Popover';
+import ArtistPoster from 'Artist/ArtistPoster';
+import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent';
+import styles from './AddNewArtistModalContent.css';
+
+class AddNewArtistModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ searchForMissingAlbums: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onSearchForMissingAlbumsChange = ({ value }) => {
+ this.setState({ searchForMissingAlbums: value });
+ }
+
+ onQualityProfileIdChange = ({ value }) => {
+ this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
+ }
+
+ onLanguageProfileIdChange = ({ value }) => {
+ this.props.onInputChange({ name: 'languageProfileId', value: parseInt(value) });
+ }
+
+ onAddArtistPress = () => {
+ this.props.onAddArtistPress(this.state.searchForMissingAlbums);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistName,
+ overview,
+ images,
+ isAdding,
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ albumFolder,
+ primaryAlbumTypes,
+ secondaryAlbumTypes,
+ tags,
+ showLanguageProfile,
+ isSmallScreen,
+ onModalClose,
+ onInputChange
+ } = this.props;
+
+ return (
+
+
+ {artistName}
+
+
+
+
+ {
+ !isSmallScreen &&
+
+ }
+
+
+
+
+
+
+
+
+ Start search for missing albums
+
+
+
+
+
+
+ Add {artistName}
+
+
+
+ );
+ }
+}
+
+AddNewArtistModalContent.propTypes = {
+ artistName: PropTypes.string.isRequired,
+ overview: PropTypes.string,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isAdding: PropTypes.bool.isRequired,
+ addError: PropTypes.object,
+ rootFolderPath: PropTypes.object,
+ monitor: PropTypes.object.isRequired,
+ qualityProfileId: PropTypes.object,
+ languageProfileId: PropTypes.object,
+ albumFolder: PropTypes.object.isRequired,
+ primaryAlbumTypes: PropTypes.object.isRequired,
+ secondaryAlbumTypes: PropTypes.object.isRequired,
+ tags: PropTypes.object.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onAddArtistPress: PropTypes.func.isRequired
+};
+
+export default AddNewArtistModalContent;
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js
new file mode 100644
index 000000000..b971432c3
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js
@@ -0,0 +1,111 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setAddArtistDefault, addArtist } from 'Store/Actions/addArtistActions';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import selectSettings from 'Store/Selectors/selectSettings';
+import AddNewArtistModalContent from './AddNewArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addArtist,
+ (state) => state.settings.languageProfiles,
+ createDimensionsSelector(),
+ (addArtistState, languageProfiles, dimensions) => {
+ const {
+ isAdding,
+ addError,
+ defaults
+ } = addArtistState;
+
+ const {
+ settings,
+ validationErrors,
+ validationWarnings
+ } = selectSettings(defaults, {}, addError);
+
+ return {
+ isAdding,
+ addError,
+ showLanguageProfile: languageProfiles.length > 1,
+ isSmallScreen: dimensions.isSmallScreen,
+ validationErrors,
+ validationWarnings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setAddArtistDefault,
+ addArtist
+};
+
+class AddNewArtistModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setAddArtistDefault({ [name]: value });
+ }
+
+ onAddArtistPress = (searchForMissingAlbums) => {
+ const {
+ foreignArtistId,
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ albumFolder,
+ primaryAlbumTypes,
+ secondaryAlbumTypes,
+ tags
+ } = this.props;
+
+ this.props.addArtist({
+ foreignArtistId,
+ rootFolderPath: rootFolderPath.value,
+ monitor: monitor.value,
+ qualityProfileId: qualityProfileId.value,
+ languageProfileId: languageProfileId.value,
+ albumFolder: albumFolder.value,
+ primaryAlbumTypes: primaryAlbumTypes.value,
+ secondaryAlbumTypes: secondaryAlbumTypes.value,
+ tags: tags.value,
+ searchForMissingAlbums
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddNewArtistModalContentConnector.propTypes = {
+ foreignArtistId: PropTypes.string.isRequired,
+ rootFolderPath: PropTypes.object,
+ monitor: PropTypes.object.isRequired,
+ qualityProfileId: PropTypes.object,
+ languageProfileId: PropTypes.object,
+ albumFolder: PropTypes.object.isRequired,
+ primaryAlbumTypes: PropTypes.object.isRequired,
+ secondaryAlbumTypes: PropTypes.object.isRequired,
+ tags: PropTypes.object.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ setAddArtistDefault: PropTypes.func.isRequired,
+ addArtist: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddNewArtistModalContentConnector);
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.css b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.css
new file mode 100644
index 000000000..c56765538
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.css
@@ -0,0 +1,42 @@
+.searchResult {
+ display: flex;
+ margin: 20px 0;
+ padding: 20px;
+ width: 100%;
+ background-color: $white;
+ color: inherit;
+ transition: background 500ms;
+
+ &:hover {
+ background-color: #eaf2ff;
+ color: inherit;
+ text-decoration: none;
+ }
+}
+
+.poster {
+ flex: 0 0 170px;
+ margin-right: 20px;
+ height: 250px;
+}
+
+.name {
+ font-weight: 300;
+ font-size: 36px;
+}
+
+.year {
+ margin-left: 10px;
+ color: $disabledColor;
+}
+
+.alreadyExistsIcon {
+ margin-left: 10px;
+ color: #37bc9b;
+}
+
+.overview {
+ overflow: hidden;
+ margin-top: 20px;
+ text-align: justify;
+}
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.js
new file mode 100644
index 000000000..5cae6fbb4
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResult.js
@@ -0,0 +1,209 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextTruncate from 'react-text-truncate';
+import dimensions from 'Styles/Variables/dimensions';
+import fonts from 'Styles/Variables/fonts';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import HeartRating from 'Components/HeartRating';
+import Icon from 'Components/Icon';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import ArtistPoster from 'Artist/ArtistPoster';
+import AddNewArtistModal from './AddNewArtistModal';
+import styles from './AddNewArtistSearchResult.css';
+
+const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
+const defaultFontSize = parseInt(fonts.defaultFontSize);
+const lineHeight = parseFloat(fonts.lineHeight);
+
+function calculateHeight(rowHeight, isSmallScreen) {
+ let height = rowHeight - 45;
+
+ if (isSmallScreen) {
+ height -= columnPaddingSmallScreen;
+ } else {
+ height -= columnPadding;
+ }
+
+ return height;
+}
+
+class AddNewArtistSearchResult extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isNewAddArtistModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!prevProps.isExistingArtist && this.props.isExistingArtist) {
+ this.onAddSerisModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.setState({ isNewAddArtistModalOpen: true });
+ }
+
+ onAddSerisModalClose = () => {
+ this.setState({ isNewAddArtistModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ foreignArtistId,
+ artistName,
+ nameSlug,
+ year,
+ disambiguation,
+ artistType,
+ status,
+ overview,
+ albumCount,
+ ratings,
+ images,
+ isExistingArtist,
+ isSmallScreen
+ } = this.props;
+
+ const linkProps = isExistingArtist ? { to: `/artist/${nameSlug}` } : { onPress: this.onPress };
+ let albums = '1 Album';
+
+ if (albumCount > 1) {
+ albums = `${albumCount} Albums`;
+ }
+
+ const height = calculateHeight(230, isSmallScreen);
+
+ return (
+
+ {
+ !isSmallScreen &&
+
+ }
+
+
+
+ {artistName}
+
+ {
+ !name.contains(year) && !!year &&
+ ({year})
+ }
+
+ {
+ !!disambiguation &&
+ ({disambiguation})
+ }
+
+ {
+ isExistingArtist &&
+
+ }
+
+
+
+
+
+
+
+ {
+ !!artistType &&
+
+ {artistType}
+
+ }
+
+ {
+ !!albumCount &&
+
+ {albums}
+
+ }
+
+ {
+ status === 'ended' &&
+
+ Ended
+
+ }
+
+
+
+
+
+
+
+ );
+ }
+}
+
+AddNewArtistSearchResult.propTypes = {
+ foreignArtistId: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ nameSlug: PropTypes.string.isRequired,
+ year: PropTypes.number,
+ disambiguation: PropTypes.string,
+ artistType: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ overview: PropTypes.string,
+ albumCount: PropTypes.number,
+ ratings: PropTypes.object.isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isExistingArtist: PropTypes.bool.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired
+};
+
+export default AddNewArtistSearchResult;
diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResultConnector.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResultConnector.js
new file mode 100644
index 000000000..45165c04d
--- /dev/null
+++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistSearchResultConnector.js
@@ -0,0 +1,20 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createExistingArtistSelector from 'Store/Selectors/createExistingArtistSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import AddNewArtistSearchResult from './AddNewArtistSearchResult';
+
+function createMapStateToProps() {
+ return createSelector(
+ createExistingArtistSelector(),
+ createDimensionsSelector(),
+ (isExistingArtist, dimensions) => {
+ return {
+ isExistingArtist,
+ isSmallScreen: dimensions.isSmallScreen
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(AddNewArtistSearchResult);
diff --git a/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js
new file mode 100644
index 000000000..89851ce4d
--- /dev/null
+++ b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+
+function ArtistMonitoringOptionsPopoverContent() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default ArtistMonitoringOptionsPopoverContent;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js
new file mode 100644
index 000000000..3c4bea3b6
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js
@@ -0,0 +1,173 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import ImportArtistTableConnector from './ImportArtistTableConnector';
+import ImportArtistFooterConnector from './ImportArtistFooterConnector';
+
+class ImportArtist extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ contentBody: null,
+ scrollTop: 0
+ };
+ }
+
+ //
+ // Control
+
+ setContentBodyRef = (ref) => {
+ this.setState({ contentBody: ref });
+ }
+
+ //
+ // Listeners
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState, { parseIds: false });
+ }
+
+ onSelectAllChange = ({ value }) => {
+ // Only select non-dupes
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onRemoveSelectedStateItem = (id) => {
+ this.setState((state) => {
+ const selectedState = Object.assign({}, state.selectedState);
+ delete selectedState[id];
+
+ return {
+ ...state,
+ selectedState
+ };
+ });
+ }
+
+ onInputChange = ({ name, value }) => {
+ this.props.onInputChange(this.getSelectedIds(), name, value);
+ }
+
+ onImportPress = () => {
+ this.props.onImportPress(this.getSelectedIds());
+ }
+
+ onScroll = ({ scrollTop }) => {
+ this.setState({ scrollTop });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ rootFolderId,
+ path,
+ rootFoldersFetching,
+ rootFoldersPopulated,
+ rootFoldersError,
+ unmappedFolders,
+ 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 artist in {path} have been imported
+
+ }
+
+ {
+ !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && contentBody &&
+
+ }
+
+
+ {
+ !rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length &&
+
+ }
+
+ );
+ }
+}
+
+ImportArtist.propTypes = {
+ rootFolderId: PropTypes.number.isRequired,
+ path: PropTypes.string,
+ rootFoldersFetching: PropTypes.bool.isRequired,
+ rootFoldersPopulated: PropTypes.bool.isRequired,
+ rootFoldersError: PropTypes.object,
+ unmappedFolders: PropTypes.arrayOf(PropTypes.object),
+ items: PropTypes.arrayOf(PropTypes.object),
+ showLanguageProfile: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onImportPress: PropTypes.func.isRequired
+};
+
+ImportArtist.defaultProps = {
+ unmappedFolders: []
+};
+
+export default ImportArtist;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js
new file mode 100644
index 000000000..504cb54dd
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js
@@ -0,0 +1,121 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setImportArtistValue, importArtist, clearImportArtist } from 'Store/Actions/importArtistActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import { setAddArtistDefault } from 'Store/Actions/addArtistActions';
+import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape';
+import ImportArtist from './ImportArtist';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { match }) => match,
+ (state) => state.rootFolders,
+ (state) => state.addArtist,
+ (state) => state.importArtist,
+ (state) => state.settings.languageProfiles,
+ (match, rootFolders, addArtist, importArtistState, languageProfiles) => {
+ const {
+ isFetching: rootFoldersFetching,
+ isPopulated: rootFoldersPopulated,
+ error: rootFoldersError,
+ items
+ } = rootFolders;
+
+ const rootFolderId = parseInt(match.params.rootFolderId);
+
+ const result = {
+ rootFolderId,
+ rootFoldersFetching,
+ rootFoldersPopulated,
+ rootFoldersError,
+ showLanguageProfile: languageProfiles.items.length > 1
+ };
+
+ if (items.length) {
+ const rootFolder = _.find(items, { id: rootFolderId });
+
+ return {
+ ...result,
+ ...rootFolder,
+ items: importArtistState.items
+ };
+ }
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setImportArtistValue,
+ importArtist,
+ clearImportArtist,
+ fetchRootFolders,
+ setAddArtistDefault
+};
+
+class ImportArtistConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.rootFoldersPopulated) {
+ this.props.fetchRootFolders();
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearImportArtist();
+ }
+
+ //
+ // Listeners
+
+ onInputChange = (ids, name, value) => {
+ this.props.setAddArtistDefault({ [name]: value });
+
+ ids.forEach((id) => {
+ this.props.setImportArtistValue({
+ id,
+ [name]: value
+ });
+ });
+ }
+
+ onImportPress = (ids) => {
+ this.props.importArtist({ ids });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+const routeMatchShape = createRouteMatchShape({
+ rootFolderId: PropTypes.string.isRequired
+});
+
+ImportArtistConnector.propTypes = {
+ match: routeMatchShape.isRequired,
+ rootFoldersPopulated: PropTypes.bool.isRequired,
+ setImportArtistValue: PropTypes.func.isRequired,
+ importArtist: PropTypes.func.isRequired,
+ clearImportArtist: PropTypes.func.isRequired,
+ fetchRootFolders: PropTypes.func.isRequired,
+ setAddArtistDefault: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistConnector);
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css
new file mode 100644
index 000000000..1df1b8c90
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css
@@ -0,0 +1,27 @@
+.inputContainer {
+ margin-right: 20px;
+ min-width: 150px;
+}
+
+.label {
+ margin-bottom: 3px;
+ font-weight: bold;
+}
+
+.importButtonContainer {
+ display: flex;
+ align-items: center;
+}
+
+.importButton {
+ composes: button from 'Components/Link/SpinnerButton.css';
+
+ height: 35px;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin: 0 10px 0 12px;
+ text-align: left;
+}
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js
new file mode 100644
index 000000000..76fe864f8
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js
@@ -0,0 +1,261 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import CheckInput from 'Components/Form/CheckInput';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import PageContentFooter from 'Components/Page/PageContentFooter';
+import styles from './ImportArtistFooter.css';
+
+const MIXED = 'mixed';
+
+class ImportArtistFooter extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ defaultMonitor,
+ defaultQualityProfileId,
+ defaultLanguageProfileId,
+ defaultAlbumFolder,
+ defaultPrimaryAlbumTypes,
+ defaultSecondaryAlbumTypes
+ } = props;
+
+ this.state = {
+ monitor: defaultMonitor,
+ qualityProfileId: defaultQualityProfileId,
+ languageProfileId: defaultLanguageProfileId,
+ albumFolder: defaultAlbumFolder,
+ primaryAlbumTypes: defaultPrimaryAlbumTypes,
+ secondaryAlbumTypes: defaultSecondaryAlbumTypes
+ };
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ defaultMonitor,
+ defaultQualityProfileId,
+ defaultLanguageProfileId,
+ defaultAlbumFolder,
+ defaultPrimaryAlbumTypes,
+ defaultSecondaryAlbumTypes,
+ isMonitorMixed,
+ isQualityProfileIdMixed,
+ isLanguageProfileIdMixed,
+ isAlbumFolderMixed,
+ isPrimaryAlbumTypesMixed,
+ isSecondaryAlbumTypesMixed
+ } = this.props;
+
+ const {
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ albumFolder,
+ primaryAlbumTypes,
+ secondaryAlbumTypes
+ } = 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 (isAlbumFolderMixed && albumFolder != null) {
+ newState.albumFolder = null;
+ } else if (!isAlbumFolderMixed && albumFolder !== defaultAlbumFolder) {
+ newState.albumFolder = defaultAlbumFolder;
+ }
+
+ if (isPrimaryAlbumTypesMixed && primaryAlbumTypes != null) {
+ newState.primaryAlbumTypes = null;
+ } else if (!isPrimaryAlbumTypesMixed && primaryAlbumTypes !== defaultPrimaryAlbumTypes) {
+ newState.primaryAlbumTypes = defaultPrimaryAlbumTypes;
+ }
+
+ if (isSecondaryAlbumTypesMixed && secondaryAlbumTypes != null) {
+ newState.secondaryAlbumTypes = null;
+ } else if (!isSecondaryAlbumTypesMixed && secondaryAlbumTypes !== defaultSecondaryAlbumTypes) {
+ newState.secondaryAlbumTypes = defaultSecondaryAlbumTypes;
+ }
+
+ if (!_.isEmpty(newState)) {
+ this.setState(newState);
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+ this.props.onInputChange({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedCount,
+ isImporting,
+ isLookingUpArtist,
+ isMonitorMixed,
+ isQualityProfileIdMixed,
+ isLanguageProfileIdMixed,
+ showLanguageProfile,
+ onImportPress
+ } = this.props;
+
+ const {
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ albumFolder,
+ primaryAlbumTypes,
+ secondaryAlbumTypes
+ } = this.state;
+
+ return (
+
+
+
+
+
+ Quality Profile
+
+
+
+
+
+ {
+ showLanguageProfile &&
+
+
+
+ Language Profile
+
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+ Import {selectedCount} Artist(s)
+
+
+ {
+ isLookingUpArtist &&
+
+ }
+
+ {
+ isLookingUpArtist &&
+ 'Processing Folders'
+ }
+
+
+
+ );
+ }
+}
+
+ImportArtistFooter.propTypes = {
+ selectedCount: PropTypes.number.isRequired,
+ isImporting: PropTypes.bool.isRequired,
+ isLookingUpArtist: PropTypes.bool.isRequired,
+ defaultMonitor: PropTypes.string.isRequired,
+ defaultQualityProfileId: PropTypes.number,
+ defaultLanguageProfileId: PropTypes.number,
+ defaultAlbumFolder: PropTypes.bool.isRequired,
+ defaultPrimaryAlbumTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
+ defaultSecondaryAlbumTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
+ isMonitorMixed: PropTypes.bool.isRequired,
+ isQualityProfileIdMixed: PropTypes.bool.isRequired,
+ isLanguageProfileIdMixed: PropTypes.bool.isRequired,
+ isAlbumFolderMixed: PropTypes.bool.isRequired,
+ isPrimaryAlbumTypesMixed: PropTypes.bool.isRequired,
+ isSecondaryAlbumTypesMixed: PropTypes.bool.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onImportPress: PropTypes.func.isRequired
+};
+
+export default ImportArtistFooter;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js
new file mode 100644
index 000000000..938efd916
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js
@@ -0,0 +1,61 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import ImportArtistFooter from './ImportArtistFooter';
+
+function isMixed(items, selectedIds, defaultValue, key) {
+ return _.some(items, (artist) => {
+ return selectedIds.indexOf(artist.id) > -1 && artist[key] !== defaultValue;
+ });
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addArtist,
+ (state) => state.importArtist,
+ (state, { selectedIds }) => selectedIds,
+ (addArtist, importArtist, selectedIds) => {
+ const {
+ monitor: defaultMonitor,
+ qualityProfileId: defaultQualityProfileId,
+ languageProfileId: defaultLanguageProfileId,
+ albumFolder: defaultAlbumFolder,
+ primaryAlbumTypes: defaultPrimaryAlbumTypes,
+ secondaryAlbumTypes: defaultSecondaryAlbumTypes
+ } = addArtist.defaults;
+
+ const items = importArtist.items;
+
+ const isLookingUpArtist = _.some(importArtist.items, (artist) => {
+ return !artist.isPopulated && artist.error == null;
+ });
+
+ const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor');
+ const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId');
+ const isLanguageProfileIdMixed = isMixed(items, selectedIds, defaultLanguageProfileId, 'languageProfileId');
+ const isAlbumFolderMixed = isMixed(items, selectedIds, defaultAlbumFolder, 'albumFolder');
+ const isPrimaryAlbumTypesMixed = isMixed(items, selectedIds, defaultPrimaryAlbumTypes, 'primaryAlbumTypes');
+ const isSecondaryAlbumTypesMixed = isMixed(items, selectedIds, defaultSecondaryAlbumTypes, 'secondaryAlbumTypes');
+
+ return {
+ selectedCount: selectedIds.length,
+ isImporting: importArtist.isImporting,
+ isLookingUpArtist,
+ defaultMonitor,
+ defaultQualityProfileId,
+ defaultLanguageProfileId,
+ defaultAlbumFolder,
+ defaultPrimaryAlbumTypes,
+ defaultSecondaryAlbumTypes,
+ isMonitorMixed,
+ isQualityProfileIdMixed,
+ isLanguageProfileIdMixed,
+ isAlbumFolderMixed,
+ isPrimaryAlbumTypesMixed,
+ isSecondaryAlbumTypesMixed
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(ImportArtistFooter);
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css
new file mode 100644
index 000000000..a42c0c696
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css
@@ -0,0 +1,38 @@
+.folder {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 200px;
+}
+
+.monitor {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 200px;
+ min-width: 185px;
+}
+
+.qualityProfile,
+.languageProfile {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 250px;
+ min-width: 170px;
+}
+
+.albumFolder {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 150px;
+ min-width: 120px;
+}
+
+.artist {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 1 400px;
+ min-width: 300px;
+}
+
+.detailsIcon {
+ margin-left: 8px;
+}
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js
new file mode 100644
index 000000000..edb07beb4
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js
@@ -0,0 +1,96 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Popover from 'Components/Tooltip/Popover';
+import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
+import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
+import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
+import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent';
+// import SeriesTypePopoverContent from 'AddArtist/SeriesTypePopoverContent';
+import styles from './ImportArtistHeader.css';
+
+function ImportArtistHeader(props) {
+ const {
+ showLanguageProfile,
+ allSelected,
+ allUnselected,
+ onSelectAllChange
+ } = props;
+
+ return (
+
+
+
+
+ Folder
+
+
+
+ Monitor
+
+
+ }
+ title="Monitoring Options"
+ body={ }
+ position={tooltipPositions.RIGHT}
+ />
+
+
+
+ Quality Profile
+
+
+ {
+ showLanguageProfile &&
+
+ Language Profile
+
+ }
+
+
+ Album Folder
+
+
+
+ Artist
+
+
+ );
+}
+
+ImportArtistHeader.propTypes = {
+ showLanguageProfile: PropTypes.bool.isRequired,
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired
+};
+
+export default ImportArtistHeader;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css
new file mode 100644
index 000000000..26e0998f3
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.css
@@ -0,0 +1,45 @@
+.selectInput {
+ composes: input from 'Components/Form/CheckInput.css';
+}
+
+.folder {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 1 0 200px;
+ line-height: 36px;
+}
+
+.monitor {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 200px;
+ min-width: 185px;
+}
+
+.qualityProfile,
+.languageProfile {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 250px;
+ min-width: 170px;
+}
+
+.albumFolder {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 150px;
+ min-width: 120px;
+}
+
+.artist {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 1 400px;
+ min-width: 300px;
+}
+
+.hideLanguageProfile {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ display: none;
+}
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js
new file mode 100644
index 000000000..ddfc3cd10
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRow.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import VirtualTableRow from 'Components/Table/VirtualTableRow';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
+import ImportArtistSelectArtistConnector from './SelectArtist/ImportArtistSelectArtistConnector';
+import styles from './ImportArtistRow.css';
+
+function ImportArtistRow(props) {
+ const {
+ style,
+ id,
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ albumFolder,
+ selectedArtist,
+ isExistingArtist,
+ showLanguageProfile,
+ isSelected,
+ onSelectedChange,
+ onInputChange
+ } = props;
+
+ return (
+
+
+
+
+ {id}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+ImportArtistRow.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.string.isRequired,
+ monitor: PropTypes.string.isRequired,
+ qualityProfileId: PropTypes.number.isRequired,
+ languageProfileId: PropTypes.number.isRequired,
+ albumFolder: PropTypes.bool.isRequired,
+ selectedArtist: PropTypes.object,
+ isExistingArtist: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ queued: PropTypes.bool.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+ImportArtistRow.defaultsProps = {
+ items: []
+};
+
+export default ImportArtistRow;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.js
new file mode 100644
index 000000000..219b82e86
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistRowConnector.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 { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import ImportArtistRow from './ImportArtistRow';
+
+function createImportArtistItemSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.importArtist.items,
+ (id, items) => {
+ return _.find(items, { id }) || {};
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createImportArtistItemSelector(),
+ createAllArtistSelector(),
+ (item, artist) => {
+ const selectedArtist = item && item.selectedArtist;
+ const isExistingArtist = !!selectedArtist && _.some(artist, { foreignArtistId: selectedArtist.foreignArtistId });
+
+ return {
+ ...item,
+ isExistingArtist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ queueLookupArtist,
+ setImportArtistValue
+};
+
+class ImportArtistRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setImportArtistValue({
+ id: this.props.id,
+ [name]: value
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ // Don't show the row until we have the information we require for it.
+
+ const {
+ items,
+ monitor,
+ albumFolder
+ } = this.props;
+
+ if (!items || !monitor || !albumFolder == null) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+ImportArtistRowConnector.propTypes = {
+ rootFolderId: PropTypes.number.isRequired,
+ id: PropTypes.string.isRequired,
+ monitor: PropTypes.string,
+ albumFolder: PropTypes.bool,
+ items: PropTypes.arrayOf(PropTypes.object),
+ queueLookupArtist: PropTypes.func.isRequired,
+ setImportArtistValue: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistRowConnector);
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css
new file mode 100644
index 000000000..efc6dccb3
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistSelected.css
@@ -0,0 +1,3 @@
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+}
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js
new file mode 100644
index 000000000..724be157d
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js
@@ -0,0 +1,216 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import VirtualTable from 'Components/Table/VirtualTable';
+import ImportArtistHeader from './ImportArtistHeader';
+import ImportArtistRowConnector from './ImportArtistRowConnector';
+
+class ImportArtistTable extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._table = null;
+ }
+
+ componentDidMount() {
+ const {
+ unmappedFolders,
+ defaultMonitor,
+ defaultQualityProfileId,
+ defaultLanguageProfileId,
+ defaultAlbumFolder,
+ defaultPrimaryAlbumTypes,
+ defaultSecondaryAlbumTypes,
+ onArtistLookup,
+ onSetImportArtistValue
+ } = this.props;
+
+ const values = {
+ monitor: defaultMonitor,
+ qualityProfileId: defaultQualityProfileId,
+ languageProfileId: defaultLanguageProfileId,
+ albumFolder: defaultAlbumFolder,
+ primaryAlbumTypes: defaultPrimaryAlbumTypes,
+ secondaryAlbumTypes: defaultSecondaryAlbumTypes
+ };
+
+ unmappedFolders.forEach((unmappedFolder) => {
+ const id = unmappedFolder.name;
+
+ onArtistLookup(id, unmappedFolder.path);
+
+ onSetImportArtistValue({
+ id,
+ ...values
+ });
+ });
+ }
+
+ // This isn't great, but it's the most reliable way to ensure the items
+ // are checked off even if they aren't actually visible since the cells
+ // are virtualized.
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ selectedState,
+ onSelectedChange,
+ onRemoveSelectedStateItem
+ } = this.props;
+
+ prevProps.items.forEach((prevItem) => {
+ const {
+ id
+ } = prevItem;
+
+ const item = _.find(items, { id });
+
+ if (!item) {
+ onRemoveSelectedStateItem(id);
+ return;
+ }
+
+ const selectedArtist = item.selectedArtist;
+ const isSelected = selectedState[id];
+
+ const isExistingArtist = !!selectedArtist &&
+ _.some(prevProps.allArtists, { foreignArtistId: selectedArtist.foreignArtistId });
+
+ // Props doesn't have a selected artist or
+ // the selected artist is an existing artist.
+ if ((selectedArtist && !prevItem.selectedArtist) || (isExistingArtist && !prevItem.selectedArtist)) {
+ onSelectedChange({ id, value: false });
+
+ return;
+ }
+
+ // State is selected, but a artist isn't selected or
+ // the selected artist is an existing artist.
+ if (isSelected && (!selectedArtist || isExistingArtist)) {
+ onSelectedChange({ id, value: false });
+
+ return;
+ }
+
+ // A artist is being selected that wasn't previously selected.
+ if (selectedArtist && selectedArtist !== prevItem.selectedArtist) {
+ onSelectedChange({ id, value: true });
+
+ return;
+ }
+ });
+
+ // Forces the table to re-render if the selected state
+ // has changed otherwise it will be stale.
+
+ if (prevProps.selectedState !== selectedState && this._table) {
+ this._table.forceUpdateGrid();
+ }
+ }
+
+ //
+ // Control
+
+ setTableRef = (ref) => {
+ this._table = ref;
+ }
+
+ rowRenderer = ({ key, rowIndex, style }) => {
+ const {
+ rootFolderId,
+ items,
+ selectedState,
+ showLanguageProfile,
+ onSelectedChange
+ } = this.props;
+
+ const item = items[rowIndex];
+
+ return (
+
+ );
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ allSelected,
+ allUnselected,
+ isSmallScreen,
+ contentBody,
+ showLanguageProfile,
+ scrollTop,
+ onSelectAllChange,
+ onScroll
+ } = this.props;
+
+ if (!items.length) {
+ return null;
+ }
+
+ return (
+
+ }
+ onScroll={onScroll}
+ />
+ );
+ }
+}
+
+ImportArtistTable.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,
+ defaultAlbumFolder: PropTypes.bool.isRequired,
+ defaultPrimaryAlbumTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
+ defaultSecondaryAlbumTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ selectedState: PropTypes.object.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ allArtists: PropTypes.arrayOf(PropTypes.object),
+ contentBody: PropTypes.object.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired,
+ onSelectedChange: PropTypes.func.isRequired,
+ onRemoveSelectedStateItem: PropTypes.func.isRequired,
+ onArtistLookup: PropTypes.func.isRequired,
+ onSetImportArtistValue: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default ImportArtistTable;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js
new file mode 100644
index 000000000..c232db77f
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTableConnector.js
@@ -0,0 +1,45 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import ImportArtistTable from './ImportArtistTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.addArtist,
+ (state) => state.importArtist,
+ (state) => state.app.dimensions,
+ createAllArtistSelector(),
+ (addArtist, importArtist, dimensions, allArtists) => {
+ return {
+ defaultMonitor: addArtist.defaults.monitor,
+ defaultQualityProfileId: addArtist.defaults.qualityProfileId,
+ defaultLanguageProfileId: addArtist.defaults.languageProfileId,
+ defaultAlbumFolder: addArtist.defaults.albumFolder,
+ defaultPrimaryAlbumTypes: addArtist.defaults.primaryAlbumTypes,
+ defaultSecondaryAlbumTypes: addArtist.defaults.secondaryAlbumTypes,
+ items: importArtist.items,
+ isSmallScreen: dimensions.isSmallScreen,
+ allArtists
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onArtistLookup(name, path) {
+ dispatch(queueLookupArtist({
+ name,
+ path,
+ term: name
+ }));
+ },
+
+ onSetImportArtistValue(values) {
+ dispatch(setImportArtistValue(values));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ImportArtistTable);
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css
new file mode 100644
index 000000000..263e91fda
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.css
@@ -0,0 +1,22 @@
+.artistNameContainer {
+ display: flex;
+ align-items: center;
+}
+
+.artistName {
+ margin-right: 5px;
+}
+
+.disambiguation {
+ margin-right: 5px;
+ color: $disabledColor;
+}
+
+.year {
+ margin-left: 5px;
+ color: $disabledColor;
+}
+
+.existing {
+ margin-left: 5px;
+}
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js
new file mode 100644
index 000000000..25d4edd16
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistName.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+import styles from './ImportArtistName.css';
+
+function ImportArtistName(props) {
+ const {
+ artistName,
+ disambiguation,
+ // year,
+ isExistingArtist
+ } = props;
+
+ return (
+
+
+ {artistName}
+
+
+ {disambiguation}
+
+
+ {
+ isExistingArtist &&
+
+ Existing
+
+ }
+
+ );
+}
+
+ImportArtistName.propTypes = {
+ artistName: PropTypes.string.isRequired,
+ disambiguation: PropTypes.string,
+ // year: PropTypes.number.isRequired,
+ isExistingArtist: PropTypes.bool.isRequired
+};
+
+export default ImportArtistName;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css
new file mode 100644
index 000000000..421a55237
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.css
@@ -0,0 +1,8 @@
+.artist {
+ padding: 10px 20px;
+ width: 100%;
+
+ &:hover {
+ background-color: $menuItemHoverColor;
+ }
+}
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js
new file mode 100644
index 000000000..aa489f0fb
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResult.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import ImportArtistName from './ImportArtistName';
+import styles from './ImportArtistSearchResult.css';
+
+class ImportArtistSearchResult extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onPress(this.props.foreignArtistId);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistName,
+ disambiguation,
+ // year,
+ isExistingArtist
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+ImportArtistSearchResult.propTypes = {
+ foreignArtistId: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ disambiguation: PropTypes.string,
+ // year: PropTypes.number.isRequired,
+ isExistingArtist: PropTypes.bool.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default ImportArtistSearchResult;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js
new file mode 100644
index 000000000..cdbcc03b3
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSearchResultConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createExistingArtistSelector from 'Store/Selectors/createExistingArtistSelector';
+import ImportArtistSearchResult from './ImportArtistSearchResult';
+
+function createMapStateToProps() {
+ return createSelector(
+ createExistingArtistSelector(),
+ (isExistingArtist) => {
+ return {
+ isExistingArtist
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(ImportArtistSearchResult);
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css
new file mode 100644
index 000000000..4d9159a70
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css
@@ -0,0 +1,71 @@
+.tether {
+ z-index: 2000;
+}
+
+.button {
+ composes: link from 'Components/Link/Link.css';
+
+ position: relative;
+ display: flex;
+ align-items: center;
+ padding: 6px 16px;
+ width: 100%;
+ height: 35px;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+}
+
+.loading {
+ display: inline-block;
+}
+
+.warningIcon {
+ margin-right: 8px;
+}
+
+.existing {
+ margin-left: 5px;
+}
+
+.dropdownArrowContainer {
+ position: absolute;
+ right: 16px;
+}
+
+.contentContainer {
+ margin-top: 4px;
+ padding: 0 8px;
+ width: 400px;
+}
+
+.content {
+ padding: 4px;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
+
+.searchContainer {
+ display: flex;
+}
+
+.searchIconContainer {
+ width: 58px;
+ border: 1px solid $inputBorderColor;
+ border-right: none;
+ border-radius: 4px;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ background-color: #edf1f2;
+ text-align: center;
+ line-height: 33px;
+}
+
+.searchInput {
+ composes: text from 'Components/Form/TextInput.css';
+
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js
new file mode 100644
index 000000000..e0c883449
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js
@@ -0,0 +1,267 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import TetherComponent from 'react-tether';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import SpinnerIcon from 'Components/SpinnerIcon';
+import Link from 'Components/Link/Link';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import TextInput from 'Components/Form/TextInput';
+import ImportArtistSearchResultConnector from './ImportArtistSearchResultConnector';
+import ImportArtistName from './ImportArtistName';
+import styles from './ImportArtistSelectArtist.css';
+
+const tetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ],
+ attachment: 'top center',
+ targetAttachment: 'bottom center'
+};
+
+class ImportArtistSelectArtist extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._artistLookupTimeout = 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._artistLookupTimeout) {
+ clearTimeout(this._artistLookupTimeout);
+ }
+
+ this.setState({ term: value }, () => {
+ this._artistLookupTimeout = setTimeout(() => {
+ this.props.onSearchInputChange(value);
+ }, 200);
+ });
+ }
+
+ onArtistSelect = (foreignArtistId) => {
+ this.setState({ isOpen: false });
+
+ this.props.onArtistSelect(foreignArtistId);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedArtist,
+ isExistingArtist,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ queued
+ } = this.props;
+
+ const errorMessage = error &&
+ error.responseJSON &&
+ error.responseJSON.message;
+
+ return (
+
+
+ {
+ queued && !isPopulated &&
+
+ }
+
+ {
+ isPopulated && selectedArtist && isExistingArtist &&
+
+ }
+
+ {
+ isPopulated && selectedArtist &&
+
+ }
+
+ {
+ isPopulated && !selectedArtist &&
+
+
+
+ No match found!
+
+ }
+
+ {
+ !isFetching && !!error &&
+
+
+
+ Search failed, please try again later.
+
+ }
+
+
+
+
+
+
+ {
+ this.state.isOpen &&
+
+
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+ );
+ }
+}
+
+ImportArtistSelectArtist.propTypes = {
+ id: PropTypes.string.isRequired,
+ selectedArtist: PropTypes.object,
+ isExistingArtist: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ queued: PropTypes.bool.isRequired,
+ onSearchInputChange: PropTypes.func.isRequired,
+ onArtistSelect: PropTypes.func.isRequired
+};
+
+ImportArtistSelectArtist.defaultProps = {
+ isFetching: true,
+ isPopulated: false,
+ items: [],
+ queued: true
+};
+
+export default ImportArtistSelectArtist;
diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js
new file mode 100644
index 000000000..21662faa7
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.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 { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions';
+import createImportArtistItemSelector from 'Store/Selectors/createImportArtistItemSelector';
+import ImportArtistSelectArtist from './ImportArtistSelectArtist';
+
+function createMapStateToProps() {
+ return createSelector(
+ createImportArtistItemSelector(),
+ (item) => {
+ return item;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ queueLookupArtist,
+ setImportArtistValue
+};
+
+class ImportArtistSelectArtistConnector extends Component {
+
+ //
+ // Listeners
+
+ onSearchInputChange = (term) => {
+ this.props.queueLookupArtist({
+ name: this.props.id,
+ term
+ });
+ }
+
+ onArtistSelect = (foreignArtistId) => {
+ const {
+ id,
+ items
+ } = this.props;
+
+ this.props.setImportArtistValue({
+ id,
+ selectedArtist: _.find(items, { foreignArtistId })
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ImportArtistSelectArtistConnector.propTypes = {
+ id: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object),
+ selectedArtist: PropTypes.object,
+ isSelected: PropTypes.bool,
+ queueLookupArtist: PropTypes.func.isRequired,
+ setImportArtistValue: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistSelectArtistConnector);
diff --git a/frontend/src/AddArtist/ImportArtist/ImportArtist.js b/frontend/src/AddArtist/ImportArtist/ImportArtist.js
new file mode 100644
index 000000000..ce5ec27ee
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/ImportArtist.js
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+import { Route } from 'react-router-dom';
+import Switch from 'Components/Router/Switch';
+import ImportArtistSelectFolderConnector from 'AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector';
+import ImportArtistConnector from 'AddArtist/ImportArtist/Import/ImportArtistConnector';
+
+class ImportArtist extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+export default ImportArtist;
diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.css b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.css
new file mode 100644
index 000000000..d9c5ccb01
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.css
@@ -0,0 +1,18 @@
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ display: block;
+}
+
+.freeSpace,
+.unmappedFolders {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 45px;
+}
diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.js
new file mode 100644
index 000000000..8a4e7b982
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRow.js
@@ -0,0 +1,64 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './ImportArtistRootFolderRow.css';
+
+function ImportArtistRootFolderRow(props) {
+ const {
+ id,
+ path,
+ freeSpace,
+ unmappedFolders,
+ onDeletePress
+ } = props;
+
+ const unmappedFoldersCount = unmappedFolders.length || '-';
+
+ return (
+
+
+
+ {path}
+
+
+
+
+ {formatBytes(freeSpace) || '-'}
+
+
+
+ {unmappedFoldersCount}
+
+
+
+
+
+
+ );
+}
+
+ImportArtistRootFolderRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ freeSpace: PropTypes.number.isRequired,
+ unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onDeletePress: PropTypes.func.isRequired
+};
+
+ImportArtistRootFolderRow.defaultProps = {
+ freeSpace: 0,
+ unmappedFolders: []
+};
+
+export default ImportArtistRootFolderRow;
diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRowConnector.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRowConnector.js
new file mode 100644
index 000000000..194e6e37c
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistRootFolderRowConnector.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 ImportArtistRootFolderRow from './ImportArtistRootFolderRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ () => {
+ return {
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ deleteRootFolder
+};
+
+class ImportArtistRootFolderRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onDeletePress = () => {
+ this.props.deleteRootFolder({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ImportArtistRootFolderRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ deleteRootFolder: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistRootFolderRowConnector);
diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css
new file mode 100644
index 000000000..030da96fb
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.css
@@ -0,0 +1,32 @@
+.header {
+ margin-bottom: 40px;
+ text-align: center;
+ font-weight: 300;
+ font-size: 36px;
+}
+
+.tips {
+ font-size: 20px;
+}
+
+.tip {
+ font-size: $defaultFontSize;
+}
+
+.code {
+ font-size: 12px;
+ font-family: $monoSpaceFontFamily;
+}
+
+.recentFolders {
+ margin-top: 40px;
+}
+
+.startImport {
+ margin-top: 40px;
+ text-align: center;
+}
+
+.importButtonIcon {
+ margin-right: 8px;
+}
diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js
new file mode 100644
index 000000000..63eabcfa6
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolder.js
@@ -0,0 +1,185 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import ImportArtistRootFolderRowConnector from './ImportArtistRootFolderRowConnector';
+import styles from './ImportArtistSelectFolder.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 ImportArtistSelectFolder extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddNewRootFolderModalOpen: false
+ };
+ }
+
+ //
+ // Lifecycle
+
+ onAddNewRootFolderPress = () => {
+ this.setState({ isAddNewRootFolderModalOpen: true });
+ }
+
+ onNewRootFolderSelect = ({ value }) => {
+ this.props.onNewRootFolderSelect(value);
+ }
+
+ onAddRootFolderModalClose = () => {
+ this.setState({ isAddNewRootFolderModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items
+ } = this.props;
+
+ return (
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load root folders
+ }
+
+ {
+ !error && isPopulated &&
+
+
+ Import artist(s) you already have
+
+
+
+ Some tips to ensure the import goes smoothly:
+
+
+ Point Lidarr to the folder containing all of your music not a specific artist. eg. "\music\" and not "\music\alien ant farm\"
+
+
+
+
+ {
+ items.length > 0 ?
+
+
+
+
+ {
+ items.map((rootFolder) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+ Choose another folder
+
+
:
+
+
+
+
+ Start Import
+
+
+ }
+
+
+
+ }
+
+
+ );
+ }
+}
+
+ImportArtistSelectFolder.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onNewRootFolderSelect: PropTypes.func.isRequired,
+ onDeleteRootFolderPress: PropTypes.func.isRequired
+};
+
+export default ImportArtistSelectFolder;
diff --git a/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.js
new file mode 100644
index 000000000..da95019cb
--- /dev/null
+++ b/frontend/src/AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector.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 { push } from 'react-router-redux';
+import { fetchRootFolders, addRootFolder, deleteRootFolder } from 'Store/Actions/rootFolderActions';
+import ImportArtistSelectFolder from './ImportArtistSelectFolder';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.rootFolders,
+ (rootFolders) => {
+ return rootFolders;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchRootFolders,
+ addRootFolder,
+ deleteRootFolder,
+ push
+};
+
+class ImportArtistSelectFolderConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchRootFolders();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ isSaving,
+ saveError
+ } = this.props;
+
+ if (prevProps.isSaving && !isSaving && !saveError) {
+ const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id);
+
+ if (newRootFolders.length === 1) {
+ this.props.push(`${window.Sonarr.urlBase}/add/import/${newRootFolders[0].id}`);
+ }
+ }
+ }
+
+ //
+ // Listeners
+
+ onNewRootFolderSelect = (path) => {
+ this.props.addRootFolder({ path });
+ }
+
+ onDeleteRootFolderPress = (id) => {
+ this.props.deleteRootFolder({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ImportArtistSelectFolderConnector.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchRootFolders: PropTypes.func.isRequired,
+ addRootFolder: PropTypes.func.isRequired,
+ deleteRootFolder: PropTypes.func.isRequired,
+ push: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistSelectFolderConnector);
diff --git a/frontend/src/Album/EpisodeDetailsModal.js b/frontend/src/Album/EpisodeDetailsModal.js
new file mode 100644
index 000000000..945c2fb8e
--- /dev/null
+++ b/frontend/src/Album/EpisodeDetailsModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import EpisodeDetailsModalContentConnector from './EpisodeDetailsModalContentConnector';
+
+class EpisodeDetailsModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+EpisodeDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EpisodeDetailsModal;
diff --git a/frontend/src/Album/EpisodeDetailsModalContent.css b/frontend/src/Album/EpisodeDetailsModalContent.css
new file mode 100644
index 000000000..9d428208d
--- /dev/null
+++ b/frontend/src/Album/EpisodeDetailsModalContent.css
@@ -0,0 +1,44 @@
+.artistName {
+ margin-left: 5px;
+}
+
+.separator {
+ margin: 0 5px;
+}
+
+.tabs {
+ margin-top: -32px;
+}
+
+.tabList {
+ margin: 0 0 10px;
+ padding: 0;
+}
+
+.tab {
+ position: relative;
+ bottom: -1px;
+ display: inline-block;
+ padding: 6px 12px;
+ border: 1px solid transparent;
+ border-top: none;
+ list-style: none;
+ cursor: pointer;
+}
+
+.selectedTab {
+ border-color: $borderColor;
+ border-radius: 0 0 5px 5px;
+ background-color: rgba(239, 239, 239, 0.4);
+ color: $black;
+}
+
+.tabPanel {
+ margin-top: 20px;
+}
+
+.openSeriesButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Album/EpisodeDetailsModalContent.js b/frontend/src/Album/EpisodeDetailsModalContent.js
new file mode 100644
index 000000000..ceb212a0f
--- /dev/null
+++ b/frontend/src/Album/EpisodeDetailsModalContent.js
@@ -0,0 +1,196 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Tab, Tabs, TabList, TabPanel } from 'react-tabs';
+import episodeEntities from 'Album/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 AlbumHistoryConnector from './History/AlbumHistoryConnector';
+import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
+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 {
+ albumId,
+ episodeEntity,
+ artistId,
+ artistName,
+ nameSlug,
+ albumLabel,
+ artistMonitored,
+ episodeTitle,
+ releaseDate,
+ monitored,
+ isSaving,
+ showOpenArtistButton,
+ startInteractiveSearch,
+ onMonitorAlbumPress,
+ onModalClose
+ } = this.props;
+
+ const artistLink = `/artist/${nameSlug}`;
+
+ return (
+
+
+
+
+
+ {artistName}
+
+
+ -
+
+ {episodeTitle}
+
+
+
+
+
+
+ Details
+
+
+
+ History
+
+
+
+ Search
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ showOpenArtistButton &&
+
+ Open Artist
+
+ }
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+EpisodeDetailsModalContent.propTypes = {
+ albumId: PropTypes.number.isRequired,
+ episodeEntity: PropTypes.string.isRequired,
+ artistId: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ nameSlug: PropTypes.string.isRequired,
+ artistMonitored: PropTypes.bool.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ albumLabel: PropTypes.arrayOf(PropTypes.string).isRequired,
+ episodeTitle: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool,
+ showOpenArtistButton: PropTypes.bool,
+ selectedTab: PropTypes.string.isRequired,
+ startInteractiveSearch: PropTypes.bool.isRequired,
+ onMonitorAlbumPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+EpisodeDetailsModalContent.defaultProps = {
+ selectedTab: 'details',
+ albumLabel: ['Unknown'],
+ episodeEntity: episodeEntities.EPISODES,
+ startInteractiveSearch: false
+};
+
+export default EpisodeDetailsModalContent;
diff --git a/frontend/src/Album/EpisodeDetailsModalContentConnector.js b/frontend/src/Album/EpisodeDetailsModalContentConnector.js
new file mode 100644
index 000000000..0e84426f5
--- /dev/null
+++ b/frontend/src/Album/EpisodeDetailsModalContentConnector.js
@@ -0,0 +1,119 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { clearReleases } from 'Store/Actions/releaseActions';
+import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
+import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import episodeEntities from 'Album/episodeEntities';
+import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
+import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions';
+import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createEpisodeSelector(),
+ createArtistSelector(),
+ (album, artist) => {
+ const {
+ artistName,
+ nameSlug,
+ monitored: artistMonitored
+ } = artist;
+
+ return {
+ artistName,
+ nameSlug,
+ artistMonitored,
+ ...album
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ clearReleases,
+ fetchTracks,
+ clearTracks,
+ fetchTrackFiles,
+ clearTrackFiles,
+ toggleEpisodeMonitored
+};
+
+class EpisodeDetailsModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+ componentDidMount() {
+ this._populate();
+ }
+
+ componentWillUnmount() {
+ // Clear pending releases here so we can reshow the search
+ // results even after switching tabs.
+ this._unpopulate();
+ this.props.clearReleases();
+ }
+
+ //
+ // Control
+
+ _populate() {
+ const artistId = this.props.artistId;
+ const albumId = this.props.albumId;
+ this.props.fetchTracks({ artistId, albumId });
+ // this.props.fetchTrackFiles({ artistId, albumId });
+ }
+
+ _unpopulate() {
+ this.props.clearTracks();
+ // this.props.clearTrackFiles();
+ }
+
+ //
+ // Listeners
+
+ onMonitorAlbumPress = (monitored) => {
+ const {
+ albumId,
+ episodeEntity
+ } = this.props;
+
+ this.props.toggleEpisodeMonitored({
+ episodeEntity,
+ albumId,
+ monitored
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EpisodeDetailsModalContentConnector.propTypes = {
+ albumId: PropTypes.number.isRequired,
+ episodeEntity: PropTypes.string.isRequired,
+ artistId: PropTypes.number.isRequired,
+ fetchTracks: PropTypes.func.isRequired,
+ clearTracks: PropTypes.func.isRequired,
+ fetchTrackFiles: PropTypes.func.isRequired,
+ clearTrackFiles: PropTypes.func.isRequired,
+ clearReleases: PropTypes.func.isRequired,
+ toggleEpisodeMonitored: PropTypes.func.isRequired
+};
+
+EpisodeDetailsModalContentConnector.defaultProps = {
+ episodeEntity: episodeEntities.EPISODES
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeDetailsModalContentConnector);
diff --git a/frontend/src/Album/EpisodeLanguage.js b/frontend/src/Album/EpisodeLanguage.js
new file mode 100644
index 000000000..fc784ef51
--- /dev/null
+++ b/frontend/src/Album/EpisodeLanguage.js
@@ -0,0 +1,23 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Label from 'Components/Label';
+
+function EpisodeLanguage(props) {
+ const language = props.language;
+
+ if (!language) {
+ return null;
+ }
+
+ return (
+
+ {language.name}
+
+ );
+}
+
+EpisodeLanguage.propTypes = {
+ language: PropTypes.object
+};
+
+export default EpisodeLanguage;
diff --git a/frontend/src/Album/EpisodeNumber.css b/frontend/src/Album/EpisodeNumber.css
new file mode 100644
index 000000000..1c5072d02
--- /dev/null
+++ b/frontend/src/Album/EpisodeNumber.css
@@ -0,0 +1,7 @@
+.absoluteEpisodeNumber {
+ margin-left: 5px;
+}
+
+.warning {
+ margin-left: 8px;
+}
diff --git a/frontend/src/Album/EpisodeNumber.js b/frontend/src/Album/EpisodeNumber.js
new file mode 100644
index 000000000..88d1cb0e8
--- /dev/null
+++ b/frontend/src/Album/EpisodeNumber.js
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Popover from 'Components/Tooltip/Popover';
+import SceneInfo from './SceneInfo';
+import styles from './EpisodeNumber.css';
+
+function EpisodeNumber(props) {
+ const {
+ episodeNumber,
+ absoluteEpisodeNumber,
+ sceneSeasonNumber,
+ sceneEpisodeNumber,
+ sceneAbsoluteEpisodeNumber,
+ unverifiedSceneNumbering,
+ alternateTitles,
+ artistType
+ } = props;
+
+ const hasSceneInformation = sceneSeasonNumber !== undefined ||
+ sceneEpisodeNumber !== undefined ||
+ (artistType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined) ||
+ !!alternateTitles.length;
+
+ return (
+
+ {
+ hasSceneInformation ?
+
+ {episodeNumber}
+
+ {
+ artistType === 'anime' && !!absoluteEpisodeNumber &&
+
+ ({absoluteEpisodeNumber})
+
+ }
+
+ }
+ title="Scene Information"
+ body={
+
+ }
+ position={tooltipPositions.RIGHT}
+ /> :
+
+ {episodeNumber}
+
+ {
+ artistType === 'anime' && !!absoluteEpisodeNumber &&
+
+ ({absoluteEpisodeNumber})
+
+ }
+
+ }
+
+ {
+ unverifiedSceneNumbering &&
+
+ }
+
+ {
+ artistType === 'anime' && !absoluteEpisodeNumber &&
+
+ }
+
+ );
+}
+
+EpisodeNumber.propTypes = {
+ seasonNumber: PropTypes.number.isRequired,
+ episodeNumber: PropTypes.number.isRequired,
+ absoluteEpisodeNumber: PropTypes.number,
+ sceneSeasonNumber: PropTypes.number,
+ sceneEpisodeNumber: PropTypes.number,
+ sceneAbsoluteEpisodeNumber: PropTypes.number,
+ unverifiedSceneNumbering: PropTypes.bool.isRequired,
+ alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ artistType: PropTypes.string
+};
+
+EpisodeNumber.defaultProps = {
+ unverifiedSceneNumbering: false
+};
+
+export default EpisodeNumber;
diff --git a/frontend/src/Album/EpisodeQuality.js b/frontend/src/Album/EpisodeQuality.js
new file mode 100644
index 000000000..1202f8706
--- /dev/null
+++ b/frontend/src/Album/EpisodeQuality.js
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function getTooltip(title, quality, size) {
+ const revision = quality.revision;
+
+ if (revision.real && revision.real > 0) {
+ title += ' [REAL]';
+ }
+
+ if (revision.version && revision.version > 1) {
+ title += ' [PROPER]';
+ }
+
+ if (size) {
+ title += ` - ${formatBytes(size)}`;
+ }
+
+ return title;
+}
+
+function EpisodeQuality(props) {
+ const {
+ title,
+ quality,
+ size,
+ isCutoffNotMet
+ } = props;
+
+ return (
+
+ {quality.quality.name}
+
+ );
+}
+
+EpisodeQuality.propTypes = {
+ title: PropTypes.string,
+ quality: PropTypes.object.isRequired,
+ size: PropTypes.number,
+ isCutoffNotMet: PropTypes.bool
+};
+
+EpisodeQuality.defaultProps = {
+ title: ''
+};
+
+export default EpisodeQuality;
diff --git a/frontend/src/Album/EpisodeSearchCell.css b/frontend/src/Album/EpisodeSearchCell.css
new file mode 100644
index 000000000..5e99b51eb
--- /dev/null
+++ b/frontend/src/Album/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/Album/EpisodeSearchCell.js b/frontend/src/Album/EpisodeSearchCell.js
new file mode 100644
index 000000000..eebc9aebf
--- /dev/null
+++ b/frontend/src/Album/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 {
+ albumId,
+ artistId,
+ episodeTitle,
+ isSearching,
+ onSearchPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
+
+EpisodeSearchCell.propTypes = {
+ albumId: PropTypes.number.isRequired,
+ artistId: PropTypes.number.isRequired,
+ episodeTitle: PropTypes.string.isRequired,
+ isSearching: PropTypes.bool.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+export default EpisodeSearchCell;
diff --git a/frontend/src/Album/EpisodeSearchCellConnector.js b/frontend/src/Album/EpisodeSearchCellConnector.js
new file mode 100644
index 000000000..20bffae07
--- /dev/null
+++ b/frontend/src/Album/EpisodeSearchCellConnector.js
@@ -0,0 +1,47 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import EpisodeSearchCell from './EpisodeSearchCell';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { albumId }) => albumId,
+ (state, { sceneSeasonNumber }) => sceneSeasonNumber,
+ createArtistSelector(),
+ createCommandsSelector(),
+ (albumId, sceneSeasonNumber, artist, commands) => {
+ const isSearching = _.some(commands, (command) => {
+ const episodeSearch = command.name === commandNames.ALBUM_SEARCH;
+
+ if (!episodeSearch) {
+ return false;
+ }
+
+ return command.body.albumIds.indexOf(albumId) > -1;
+ });
+
+ return {
+ artistMonitored: artist.monitored,
+ artistType: artist.artistType,
+ isSearching
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onSearchPress(name, path) {
+ dispatch(executeCommand({
+ name: commandNames.ALBUM_SEARCH,
+ albumIds: [props.albumId]
+ }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSearchCell);
diff --git a/frontend/src/Album/EpisodeStatus.css b/frontend/src/Album/EpisodeStatus.css
new file mode 100644
index 000000000..3833887df
--- /dev/null
+++ b/frontend/src/Album/EpisodeStatus.css
@@ -0,0 +1,4 @@
+.center {
+ display: flex;
+ justify-content: center;
+}
diff --git a/frontend/src/Album/EpisodeStatus.js b/frontend/src/Album/EpisodeStatus.js
new file mode 100644
index 000000000..9cdbd1923
--- /dev/null
+++ b/frontend/src/Album/EpisodeStatus.js
@@ -0,0 +1,127 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import isBefore from 'Utilities/Date/isBefore';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import ProgressBar from 'Components/ProgressBar';
+import QueueDetails from 'Activity/Queue/QueueDetails';
+import EpisodeQuality from './EpisodeQuality';
+import styles from './EpisodeStatus.css';
+
+function EpisodeStatus(props) {
+ const {
+ airDateUtc,
+ monitored,
+ grabbed,
+ queueItem,
+ trackFile
+ } = props;
+
+ const hasTrackFile = !!trackFile;
+ const isQueued = !!queueItem;
+ const hasAired = isBefore(airDateUtc);
+
+ if (isQueued) {
+ const {
+ sizeleft,
+ size
+ } = queueItem;
+
+ const progress = (100 - sizeleft / size * 100);
+
+ return (
+
+
+ }
+ />
+
+ );
+ }
+
+ if (grabbed) {
+ return (
+
+
+
+ );
+ }
+
+ if (hasTrackFile) {
+ const quality = trackFile.quality;
+ const isCutoffNotMet = trackFile.qualityCutoffNotMet;
+
+ return (
+
+
+
+ );
+ }
+
+ if (!airDateUtc) {
+ return (
+
+
+
+ );
+ }
+
+ if (!monitored) {
+ return (
+
+
+
+ );
+ }
+
+ if (hasAired) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+EpisodeStatus.propTypes = {
+ airDateUtc: PropTypes.string,
+ monitored: PropTypes.bool,
+ grabbed: PropTypes.bool,
+ queueItem: PropTypes.object,
+ trackFile: PropTypes.object
+};
+
+export default EpisodeStatus;
diff --git a/frontend/src/Album/EpisodeStatusConnector.js b/frontend/src/Album/EpisodeStatusConnector.js
new file mode 100644
index 000000000..f19513b8b
--- /dev/null
+++ b/frontend/src/Album/EpisodeStatusConnector.js
@@ -0,0 +1,53 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
+import EpisodeStatus from './EpisodeStatus';
+
+function createMapStateToProps() {
+ return createSelector(
+ createEpisodeSelector(),
+ createQueueItemSelector(),
+ createTrackFileSelector(),
+ (episode, queueItem, trackFile) => {
+ const result = _.pick(episode, [
+ 'airDateUtc',
+ 'monitored',
+ 'grabbed'
+ ]);
+
+ result.queueItem = queueItem;
+ result.trackFile = trackFile;
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+};
+
+class EpisodeStatusConnector extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EpisodeStatusConnector.propTypes = {
+ albumId: PropTypes.number.isRequired,
+ trackFileId: PropTypes.number.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeStatusConnector);
diff --git a/frontend/src/Album/EpisodeTitleLink.css b/frontend/src/Album/EpisodeTitleLink.css
new file mode 100644
index 000000000..6022be8a4
--- /dev/null
+++ b/frontend/src/Album/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/Album/EpisodeTitleLink.js b/frontend/src/Album/EpisodeTitleLink.js
new file mode 100644
index 000000000..39efb7c8f
--- /dev/null
+++ b/frontend/src/Album/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 'Album/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 = {
+ showArtistButton: false
+};
+
+export default EpisodeTitleLink;
diff --git a/frontend/src/Album/History/AlbumHistory.js b/frontend/src/Album/History/AlbumHistory.js
new file mode 100644
index 000000000..11dfac91b
--- /dev/null
+++ b/frontend/src/Album/History/AlbumHistory.js
@@ -0,0 +1,112 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import AlbumHistoryRow from './AlbumHistoryRow';
+
+const columns = [
+ {
+ name: 'eventType',
+ isVisible: true
+ },
+ {
+ name: 'sourceTitle',
+ label: 'Source Title',
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isVisible: true
+ },
+ {
+ name: 'date',
+ label: 'Date',
+ isVisible: true
+ },
+ {
+ name: 'details',
+ label: 'Details',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ label: 'Actions',
+ isVisible: true
+ }
+];
+
+class AlbumHistory 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 album history.
+ );
+ }
+
+ if (isPopulated && !hasItems && !error) {
+ return (
+ No album history.
+ );
+ }
+
+ if (isPopulated && hasItems && !error) {
+ return (
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ );
+ }
+
+ return null;
+ }
+}
+
+AlbumHistory.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired
+};
+
+AlbumHistory.defaultProps = {
+ selectedTab: 'details'
+};
+
+export default AlbumHistory;
diff --git a/frontend/src/Album/History/AlbumHistoryConnector.js b/frontend/src/Album/History/AlbumHistoryConnector.js
new file mode 100644
index 000000000..0218e72db
--- /dev/null
+++ b/frontend/src/Album/History/AlbumHistoryConnector.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 { fetchAlbumHistory, clearAlbumHistory, albumHistoryMarkAsFailed } from 'Store/Actions/albumHistoryActions';
+import AlbumHistory from './AlbumHistory';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.albumHistory,
+ (albumHistory) => {
+ return albumHistory;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchAlbumHistory,
+ clearAlbumHistory,
+ albumHistoryMarkAsFailed
+};
+
+class AlbumHistoryConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchAlbumHistory({ albumId: this.props.albumId });
+ }
+
+ componentWillUnmount() {
+ this.props.clearAlbumHistory();
+ }
+
+ //
+ // Listeners
+
+ onMarkAsFailedPress = (historyId) => {
+ this.props.albumHistoryMarkAsFailed({ historyId, albumId: this.props.albumId });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AlbumHistoryConnector.propTypes = {
+ albumId: PropTypes.number.isRequired,
+ fetchAlbumHistory: PropTypes.func.isRequired,
+ clearAlbumHistory: PropTypes.func.isRequired,
+ albumHistoryMarkAsFailed: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AlbumHistoryConnector);
diff --git a/frontend/src/Album/History/AlbumHistoryRow.css b/frontend/src/Album/History/AlbumHistoryRow.css
new file mode 100644
index 000000000..8c3fb8272
--- /dev/null
+++ b/frontend/src/Album/History/AlbumHistoryRow.css
@@ -0,0 +1,6 @@
+.details,
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 65px;
+}
diff --git a/frontend/src/Album/History/AlbumHistoryRow.js b/frontend/src/Album/History/AlbumHistoryRow.js
new file mode 100644
index 000000000..5b7c3c4ff
--- /dev/null
+++ b/frontend/src/Album/History/AlbumHistoryRow.js
@@ -0,0 +1,139 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import Popover from 'Components/Tooltip/Popover';
+import EpisodeQuality from 'Album/EpisodeQuality';
+import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector';
+import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
+import styles from './AlbumHistoryRow.css';
+
+class AlbumHistoryRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMarkAsFailedModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onMarkAsFailedPress = () => {
+ this.setState({ isMarkAsFailedModalOpen: true });
+ }
+
+ onConfirmMarkAsFailed = () => {
+ this.props.onMarkAsFailedPress(this.props.id);
+ this.setState({ isMarkAsFailedModalOpen: false });
+ }
+
+ onMarkAsFailedModalClose = () => {
+ this.setState({ isMarkAsFailedModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ eventType,
+ sourceTitle,
+ quality,
+ qualityCutoffNotMet,
+ date,
+ data
+ } = this.props;
+
+ const {
+ isMarkAsFailedModalOpen
+ } = this.state;
+
+ return (
+
+
+
+
+ {sourceTitle}
+
+
+
+
+
+
+
+
+
+
+ }
+ title={titleCase(eventType)}
+ body={
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+
+
+
+ {
+ eventType === 'grabbed' &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+AlbumHistoryRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ eventType: PropTypes.string.isRequired,
+ sourceTitle: PropTypes.string.isRequired,
+ quality: PropTypes.object.isRequired,
+ qualityCutoffNotMet: PropTypes.bool.isRequired,
+ date: PropTypes.string.isRequired,
+ data: PropTypes.object.isRequired,
+ onMarkAsFailedPress: PropTypes.func.isRequired
+};
+
+export default AlbumHistoryRow;
diff --git a/frontend/src/Album/SceneInfo.css b/frontend/src/Album/SceneInfo.css
new file mode 100644
index 000000000..af4908d4d
--- /dev/null
+++ b/frontend/src/Album/SceneInfo.css
@@ -0,0 +1,11 @@
+.title {
+ composes: title from 'Components/DescriptionList/DescriptionListItemTitle.css';
+
+ width: 80px;
+}
+
+.description {
+ composes: title from 'Components/DescriptionList/DescriptionListItemDescription.css';
+
+ margin-left: 100px;
+}
diff --git a/frontend/src/Album/SceneInfo.js b/frontend/src/Album/SceneInfo.js
new file mode 100644
index 000000000..0c9ffa8cd
--- /dev/null
+++ b/frontend/src/Album/SceneInfo.js
@@ -0,0 +1,83 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import styles from './SceneInfo.css';
+
+function SceneInfo(props) {
+ const {
+ sceneSeasonNumber,
+ sceneEpisodeNumber,
+ sceneAbsoluteEpisodeNumber,
+ alternateTitles,
+ artistType
+ } = props;
+
+ return (
+
+ {
+ sceneSeasonNumber !== undefined &&
+
+ }
+
+ {
+ sceneEpisodeNumber !== undefined &&
+
+ }
+
+ {
+ artistType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined &&
+
+ }
+
+ {
+ !!alternateTitles.length &&
+
+ {
+ alternateTitles.map((alternateTitle) => {
+ return (
+
+ {alternateTitle.title}
+
+ );
+ })
+ }
+
+ }
+ />
+ }
+
+ );
+}
+
+SceneInfo.propTypes = {
+ sceneSeasonNumber: PropTypes.number,
+ sceneEpisodeNumber: PropTypes.number,
+ sceneAbsoluteEpisodeNumber: PropTypes.number,
+ alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ artistType: PropTypes.string
+};
+
+export default SceneInfo;
diff --git a/frontend/src/Album/Search/EpisodeSearch.css b/frontend/src/Album/Search/EpisodeSearch.css
new file mode 100644
index 000000000..2f7ddfd19
--- /dev/null
+++ b/frontend/src/Album/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/Album/Search/EpisodeSearch.js b/frontend/src/Album/Search/EpisodeSearch.js
new file mode 100644
index 000000000..f3ab8fdec
--- /dev/null
+++ b/frontend/src/Album/Search/EpisodeSearch.js
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import styles from './EpisodeSearch.css';
+
+function EpisodeSearch(props) {
+ const {
+ onQuickSearchPress,
+ onInteractiveSearchPress
+ } = props;
+
+ return (
+
+
+
+
+
+ Quick Search
+
+
+
+
+
+
+
+ Interactive Search
+
+
+
+ );
+}
+
+EpisodeSearch.propTypes = {
+ onQuickSearchPress: PropTypes.func.isRequired,
+ onInteractiveSearchPress: PropTypes.func.isRequired
+};
+
+export default EpisodeSearch;
diff --git a/frontend/src/Album/Search/EpisodeSearchConnector.js b/frontend/src/Album/Search/EpisodeSearchConnector.js
new file mode 100644
index 000000000..0759ec1f0
--- /dev/null
+++ b/frontend/src/Album/Search/EpisodeSearchConnector.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import EpisodeSearch from './EpisodeSearch';
+import InteractiveEpisodeSearchConnector from './InteractiveEpisodeSearchConnector';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.releases,
+ (releases) => {
+ return {
+ isPopulated: releases.isPopulated
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand
+};
+
+class EpisodeSearchConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isInteractiveSearchOpen: props.startInteractiveSearch
+ };
+ }
+
+ componentDidMount() {
+ if (this.props.isPopulated) {
+ this.setState({ isInteractiveSearchOpen: true });
+ }
+ }
+
+ //
+ // Listeners
+
+ onQuickSearchPress = () => {
+ this.props.executeCommand({
+ name: commandNames.ALBUM_SEARCH,
+ albumIds: [this.props.albumId]
+ });
+
+ this.props.onModalClose();
+ }
+
+ onInteractiveSearchPress = () => {
+ this.setState({ isInteractiveSearchOpen: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ if (this.state.isInteractiveSearchOpen) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+EpisodeSearchConnector.propTypes = {
+ albumId: 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/Album/Search/InteractiveEpisodeSearch.js b/frontend/src/Album/Search/InteractiveEpisodeSearch.js
new file mode 100644
index 000000000..eb8f8493f
--- /dev/null
+++ b/frontend/src/Album/Search/InteractiveEpisodeSearch.js
@@ -0,0 +1,130 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, sortDirections } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Icon from 'Components/Icon';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import InteractiveEpisodeSearchRow from './InteractiveEpisodeSearchRow';
+
+const columns = [
+ {
+ name: 'protocol',
+ label: 'Source',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'age',
+ label: 'Age',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'title',
+ label: 'Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'indexer',
+ label: 'Indexer',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'size',
+ label: 'Size',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'peers',
+ label: 'Peers',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'qualityWeight',
+ label: 'Quality',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'rejections',
+ label: React.createElement(Icon, { name: icons.DANGER }),
+ isSortable: true,
+ fixedSortDirection: sortDirections.ASCENDING,
+ isVisible: true
+ },
+ {
+ name: 'releaseWeight',
+ label: React.createElement(Icon, { name: icons.DOWNLOAD }),
+ isSortable: true,
+ fixedSortDirection: sortDirections.ASCENDING,
+ isVisible: true
+ }
+];
+
+function InteractiveEpisodeSearch(props) {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ sortKey,
+ sortDirection,
+ longDateFormat,
+ timeFormat,
+ onSortPress,
+ onGrabPress
+ } = props;
+
+ if (isFetching) {
+ return ;
+ } else if (!isFetching && !!error) {
+ return Unable to load results for this episode search. Try again later.
;
+ } else if (isPopulated && !items.length) {
+ return No results found.
;
+ }
+
+ return (
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ );
+}
+
+InteractiveEpisodeSearch.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.string,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onGrabPress: PropTypes.func.isRequired
+};
+
+export default InteractiveEpisodeSearch;
diff --git a/frontend/src/Album/Search/InteractiveEpisodeSearchConnector.js b/frontend/src/Album/Search/InteractiveEpisodeSearchConnector.js
new file mode 100644
index 000000000..a52755a79
--- /dev/null
+++ b/frontend/src/Album/Search/InteractiveEpisodeSearchConnector.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import { fetchReleases, setReleasesSort, grabRelease } from 'Store/Actions/releaseActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import InteractiveEpisodeSearch from './InteractiveEpisodeSearch';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector(),
+ createUISettingsSelector(),
+ (releases, uiSettings) => {
+ return {
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ ...releases
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchReleases,
+ setReleasesSort,
+ grabRelease
+};
+
+class InteractiveEpisodeSearchConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ albumId,
+ isPopulated
+ } = this.props;
+
+ // If search results are not yet isPopulated fetch them,
+ // otherwise re-show the existing props.
+
+ if (!isPopulated) {
+ this.props.fetchReleases({
+ albumId
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey, sortDirection) => {
+ this.props.setReleasesSort({ sortKey, sortDirection });
+ }
+
+ onGrabPress = (guid, indexerId) => {
+ this.props.grabRelease({ guid, indexerId });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+InteractiveEpisodeSearchConnector.propTypes = {
+ albumId: PropTypes.number.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ fetchReleases: PropTypes.func.isRequired,
+ setReleasesSort: PropTypes.func.isRequired,
+ grabRelease: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'releases' }
+)(InteractiveEpisodeSearchConnector);
diff --git a/frontend/src/Album/Search/InteractiveEpisodeSearchRow.css b/frontend/src/Album/Search/InteractiveEpisodeSearchRow.css
new file mode 100644
index 000000000..c77b73e7d
--- /dev/null
+++ b/frontend/src/Album/Search/InteractiveEpisodeSearchRow.css
@@ -0,0 +1,25 @@
+.title {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ word-break: break-all;
+}
+
+.quality {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ text-align: center;
+}
+
+.rejected,
+.download {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 50px;
+}
+
+.age,
+.size {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ white-space: nowrap;
+}
diff --git a/frontend/src/Album/Search/InteractiveEpisodeSearchRow.js b/frontend/src/Album/Search/InteractiveEpisodeSearchRow.js
new file mode 100644
index 000000000..a755b329e
--- /dev/null
+++ b/frontend/src/Album/Search/InteractiveEpisodeSearchRow.js
@@ -0,0 +1,204 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import formatAge from 'Utilities/Number/formatAge';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons, kinds, tooltipPositions } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import Link from 'Components/Link/Link';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import Popover from 'Components/Tooltip/Popover';
+import EpisodeQuality from 'Album/EpisodeQuality';
+import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
+import Peers from './Peers';
+import styles from './InteractiveEpisodeSearchRow.css';
+
+function getDownloadIcon(isGrabbing, isGrabbed, grabError) {
+ if (isGrabbing) {
+ return icons.SPINNER;
+ } else if (isGrabbed) {
+ return icons.DOWNLOADING;
+ } else if (grabError) {
+ return icons.DOWNLOADING;
+ }
+
+ return icons.DOWNLOAD;
+}
+
+function getDownloadTooltip(isGrabbing, isGrabbed, grabError) {
+ if (isGrabbing) {
+ return '';
+ } else if (isGrabbed) {
+ return 'Added to downloaded queue';
+ } else if (grabError) {
+ return grabError;
+ }
+
+ return 'Add to downloaded queue';
+}
+
+class InteractiveEpisodeSearchRow extends Component {
+
+ //
+ // Listeners
+
+ onGrabPress = () => {
+ const {
+ guid,
+ indexerId,
+ onGrabPress
+ }= this.props;
+
+ onGrabPress(guid, indexerId);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ protocol,
+ age,
+ ageHours,
+ ageMinutes,
+ publishDate,
+ title,
+ infoUrl,
+ indexer,
+ size,
+ seeders,
+ leechers,
+ quality,
+ rejections,
+ downloadAllowed,
+ isGrabbing,
+ isGrabbed,
+ longDateFormat,
+ timeFormat,
+ grabError
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+ {formatAge(age, ageHours, ageMinutes)}
+
+
+
+
+ {title}
+
+
+
+
+ {indexer}
+
+
+
+ {formatBytes(size)}
+
+
+
+ {
+ protocol === 'torrent' &&
+
+ }
+
+
+
+
+
+
+
+ {
+ !!rejections.length &&
+
+ }
+ title="Release Rejected"
+ body={
+
+ {
+ rejections.map((rejection, index) => {
+ return (
+
+ {rejection}
+
+ );
+ })
+ }
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+ }
+
+
+
+ {
+ downloadAllowed &&
+
+ }
+
+
+ );
+ }
+}
+
+InteractiveEpisodeSearchRow.propTypes = {
+ guid: PropTypes.string.isRequired,
+ protocol: PropTypes.string.isRequired,
+ age: PropTypes.number.isRequired,
+ ageHours: PropTypes.number.isRequired,
+ ageMinutes: PropTypes.number.isRequired,
+ publishDate: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ infoUrl: PropTypes.string.isRequired,
+ indexer: PropTypes.string.isRequired,
+ indexerId: PropTypes.number.isRequired,
+ size: PropTypes.number.isRequired,
+ seeders: PropTypes.number,
+ leechers: PropTypes.number,
+ quality: PropTypes.object.isRequired,
+ rejections: PropTypes.arrayOf(PropTypes.string).isRequired,
+ downloadAllowed: PropTypes.bool.isRequired,
+ isGrabbing: PropTypes.bool.isRequired,
+ isGrabbed: PropTypes.bool.isRequired,
+ grabError: PropTypes.string,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onGrabPress: PropTypes.func.isRequired
+};
+
+InteractiveEpisodeSearchRow.defaultProps = {
+ isGrabbing: false,
+ isGrabbed: false
+};
+
+export default InteractiveEpisodeSearchRow;
diff --git a/frontend/src/Album/Search/Peers.js b/frontend/src/Album/Search/Peers.js
new file mode 100644
index 000000000..66f7cc9f5
--- /dev/null
+++ b/frontend/src/Album/Search/Peers.js
@@ -0,0 +1,57 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function getKind(seeders) {
+ if (seeders > 50) {
+ return kinds.PRIMARY;
+ }
+
+ if (seeders > 10) {
+ return kinds.INFO;
+ }
+
+ if (seeders > 0) {
+ return kinds.WARNING;
+ }
+
+ return kinds.DANGER;
+}
+
+function getPeersTooltipPart(peers, peersUnit) {
+ if (peers == null) {
+ return `unknown ${peersUnit}s`;
+ }
+
+ if (peers === 1) {
+ return `1 ${peersUnit}`;
+ }
+
+ return `${peers} ${peersUnit}s`;
+}
+
+function Peers(props) {
+ const {
+ seeders,
+ leechers
+ } = props;
+
+ const kind = getKind(seeders);
+
+ return (
+
+ {seeders == null ? '-' : seeders} / {leechers == null ? '-' : leechers}
+
+ );
+}
+
+Peers.propTypes = {
+ seeders: PropTypes.number,
+ leechers: PropTypes.number
+};
+
+export default Peers;
diff --git a/frontend/src/Album/SeasonEpisodeNumber.css b/frontend/src/Album/SeasonEpisodeNumber.css
new file mode 100644
index 000000000..f86e1de6b
--- /dev/null
+++ b/frontend/src/Album/SeasonEpisodeNumber.css
@@ -0,0 +1,3 @@
+.absoluteEpisodeNumber {
+ margin-left: 5px;
+}
diff --git a/frontend/src/Album/SeasonEpisodeNumber.js b/frontend/src/Album/SeasonEpisodeNumber.js
new file mode 100644
index 000000000..5055e1a26
--- /dev/null
+++ b/frontend/src/Album/SeasonEpisodeNumber.js
@@ -0,0 +1,51 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import padNumber from 'Utilities/Number/padNumber';
+import styles from './SeasonEpisodeNumber.css';
+
+function SeasonEpisodeNumber(props) {
+ const {
+ seasonNumber,
+ episodeNumber,
+ absoluteEpisodeNumber,
+ airDate,
+ artistType
+ } = props;
+
+ if (artistType === 'daily' && airDate) {
+ return (
+ {airDate}
+ );
+ }
+
+ if (artistType === 'anime') {
+ return (
+
+ {seasonNumber}x{padNumber(episodeNumber, 2)}
+
+ {
+ absoluteEpisodeNumber &&
+
+ ({absoluteEpisodeNumber})
+
+ }
+
+ );
+ }
+
+ return (
+
+ {seasonNumber}x{padNumber(episodeNumber, 2)}
+
+ );
+}
+
+SeasonEpisodeNumber.propTypes = {
+ seasonNumber: PropTypes.number.isRequired,
+ episodeNumber: PropTypes.number.isRequired,
+ absoluteEpisodeNumber: PropTypes.number,
+ airDate: PropTypes.string,
+ artistType: PropTypes.string
+};
+
+export default SeasonEpisodeNumber;
diff --git a/frontend/src/Album/Summary/EpisodeAiring.js b/frontend/src/Album/Summary/EpisodeAiring.js
new file mode 100644
index 000000000..4d3e10137
--- /dev/null
+++ b/frontend/src/Album/Summary/EpisodeAiring.js
@@ -0,0 +1,84 @@
+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 {
+ releaseDate,
+ albumLabel,
+ shortDateFormat,
+ showRelativeDates,
+ timeFormat
+ } = props;
+
+ const networkLabel = (
+
+ {albumLabel}
+
+ );
+
+ if (!releaseDate) {
+ return (
+
+ TBA on {networkLabel}
+
+ );
+ }
+
+ if (!showRelativeDates) {
+ return (
+
+ {moment(releaseDate).format(shortDateFormat)} on {networkLabel}
+
+ );
+ }
+
+ if (isToday(releaseDate)) {
+ return (
+
+ Today on {networkLabel}
+
+ );
+ }
+
+ if (isTomorrow(releaseDate)) {
+ return (
+
+ Tomorrow on {networkLabel}
+
+ );
+ }
+
+ if (isInNextWeek(releaseDate)) {
+ return (
+
+ {moment(releaseDate).format('dddd')} on {networkLabel}
+
+ );
+ }
+
+ return (
+
+ {moment(releaseDate).format(shortDateFormat)} on {networkLabel}
+
+ );
+}
+
+EpisodeAiring.propTypes = {
+ releaseDate: PropTypes.string.isRequired,
+ albumLabel: PropTypes.arrayOf(PropTypes.string).isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default EpisodeAiring;
diff --git a/frontend/src/Album/Summary/EpisodeAiringConnector.js b/frontend/src/Album/Summary/EpisodeAiringConnector.js
new file mode 100644
index 000000000..508467efb
--- /dev/null
+++ b/frontend/src/Album/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/Album/Summary/EpisodeSummary.css b/frontend/src/Album/Summary/EpisodeSummary.css
new file mode 100644
index 000000000..c05234997
--- /dev/null
+++ b/frontend/src/Album/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 20px;
+ text-align: center;
+}
+
+@media only screen and (max-width: $breakpointMedium) {
+ .size,
+ .quality {
+ flex: 0 0 80px;
+ }
+}
diff --git a/frontend/src/Album/Summary/EpisodeSummary.js b/frontend/src/Album/Summary/EpisodeSummary.js
new file mode 100644
index 000000000..4b82ec4af
--- /dev/null
+++ b/frontend/src/Album/Summary/EpisodeSummary.js
@@ -0,0 +1,155 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import Label from 'Components/Label';
+import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
+import EpisodeQuality from 'Album/EpisodeQuality';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import EpisodeAiringConnector from './EpisodeAiringConnector';
+import TrackDetailRow from './TrackDetailRow';
+import styles from './EpisodeSummary.css';
+
+class EpisodeSummary extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isRemoveTrackFileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onRemoveTrackFilePress = () => {
+ this.setState({ isRemoveTrackFileModalOpen: true });
+ }
+
+ onConfirmRemoveTrackFile = () => {
+ this.props.onDeleteTrackFile();
+ this.setState({ isRemoveTrackFileModalOpen: false });
+ }
+
+ onRemoveTrackFileModalClose = () => {
+ this.setState({ isRemoveTrackFileModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ qualityProfileId,
+ overview,
+ releaseDate,
+ albumLabel,
+ path,
+ items,
+ size,
+ quality,
+ qualityCutoffNotMet,
+ columns
+ } = this.props;
+
+ const hasOverview = !!overview;
+
+ return (
+
+
+ Releases
+
+
+
+
+
+ Quality Profile
+
+
+
+
+
+
+
+ {
+ hasOverview ?
+ overview :
+ 'No album overview.'
+ }
+
+
+
+ {
+
+ {
+ items.length ?
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
:
+
+
+ No tracks in this group
+
+ }
+
+ }
+
+
+
+
+ );
+ }
+}
+
+EpisodeSummary.propTypes = {
+ qualityProfileId: PropTypes.number.isRequired,
+ overview: PropTypes.string,
+ albumLabel: PropTypes.arrayOf(PropTypes.string),
+ releaseDate: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ path: PropTypes.string,
+ size: PropTypes.number,
+ quality: PropTypes.object,
+ qualityCutoffNotMet: PropTypes.bool,
+ onDeleteTrackFile: PropTypes.func.isRequired
+};
+
+export default EpisodeSummary;
diff --git a/frontend/src/Album/Summary/EpisodeSummaryConnector.js b/frontend/src/Album/Summary/EpisodeSummaryConnector.js
new file mode 100644
index 000000000..090c0dba6
--- /dev/null
+++ b/frontend/src/Album/Summary/EpisodeSummaryConnector.js
@@ -0,0 +1,43 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { deleteTrackFile } from 'Store/Actions/trackFileActions';
+import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import EpisodeSummary from './EpisodeSummary';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.tracks,
+ createEpisodeSelector(),
+ createCommandsSelector(),
+ createDimensionsSelector(),
+ (tracks, episode, commands, dimensions) => {
+ const items = _.filter(tracks.items, { albumId: episode.id });
+
+ return {
+ network: episode.label,
+ qualityProfileId: episode.profileId,
+ releaseDate: episode.releaseDate,
+ overview: episode.overview,
+ items,
+ columns: tracks.columns
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onDeleteTrackFile() {
+ dispatch(deleteTrackFile({
+ id: props.trackFileId,
+ episodeEntity: props.episodeEntity
+ }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSummary);
diff --git a/frontend/src/Album/Summary/TrackDetailRow.css b/frontend/src/Album/Summary/TrackDetailRow.css
new file mode 100644
index 000000000..7dacb6a1e
--- /dev/null
+++ b/frontend/src/Album/Summary/TrackDetailRow.css
@@ -0,0 +1,26 @@
+.title {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ white-space: nowrap;
+}
+
+.monitored {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 42px;
+}
+
+.trackNumber {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 50px;
+}
+
+.language,
+.audio,
+.video,
+.status {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
\ No newline at end of file
diff --git a/frontend/src/Album/Summary/TrackDetailRow.js b/frontend/src/Album/Summary/TrackDetailRow.js
new file mode 100644
index 000000000..26a2ba232
--- /dev/null
+++ b/frontend/src/Album/Summary/TrackDetailRow.js
@@ -0,0 +1,123 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+import MediaInfoConnector from 'TrackFile/MediaInfoConnector';
+import * as mediaInfoTypes from 'TrackFile/mediaInfoTypes';
+import EpisodeStatusConnector from 'Album/EpisodeStatusConnector';
+
+import styles from './TrackDetailRow.css';
+
+class TrackDetailRow extends Component {
+
+ //
+ // Lifecycle
+
+ //
+ // Listeners
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ title,
+ trackNumber,
+ duration,
+ columns,
+ trackFileId
+ } = this.props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'trackNumber') {
+ return (
+
+ {trackNumber}
+
+ );
+ }
+
+ if (name === 'title') {
+ return (
+
+ {title}
+
+ );
+ }
+
+ if (name === 'duration') {
+ return (
+
+ {
+ formatTimeSpan(duration)
+ }
+
+ );
+ }
+
+ if (name === 'audioInfo') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'status') {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+ }
+}
+
+TrackDetailRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ duration: PropTypes.number.isRequired,
+ trackFileId: PropTypes.number.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ trackNumber: PropTypes.number.isRequired
+};
+
+export default TrackDetailRow;
diff --git a/frontend/src/Album/episodeEntities.js b/frontend/src/Album/episodeEntities.js
new file mode 100644
index 000000000..175b6fc54
--- /dev/null
+++ b/frontend/src/Album/episodeEntities.js
@@ -0,0 +1,13 @@
+export const CALENDAR = 'calendar';
+export const EPISODES = 'episodes';
+export const INTERACTIVE_IMPORT = 'interactiveImport.interactiveImportAlbums';
+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/AlbumStudio/AlbumStudio.js b/frontend/src/AlbumStudio/AlbumStudio.js
new file mode 100644
index 000000000..8b62a0d81
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudio.js
@@ -0,0 +1,257 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, sortDirections } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import NoArtist from 'Artist/NoArtist';
+import AlbumStudioRowConnector from './AlbumStudioRowConnector';
+import AlbumStudioFooter from './AlbumStudioFooter';
+
+const columns = [
+ {
+ name: 'status',
+ isVisible: true
+ },
+ {
+ name: 'sortName',
+ label: 'Name',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'monitored',
+ isVisible: true
+ },
+ {
+ name: 'albumCount',
+ label: 'Albums',
+ isSortable: true,
+ isVisible: true
+ }
+];
+
+class AlbumStudio extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {}
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isSaving,
+ saveError
+ } = this.props;
+
+ if (prevProps.isSaving && !isSaving && !saveError) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onUpdateSelectedPress = (changes) => {
+ this.props.onUpdateSelectedPress({
+ artistIds: this.getSelectedIds(),
+ ...changes
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ filterKey,
+ filterValue,
+ sortKey,
+ sortDirection,
+ isSaving,
+ saveError,
+ onSortPress,
+ onFilterSelect
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+ All
+
+
+
+ Monitored Only
+
+
+
+ Continuing Only
+
+
+
+ Ended Only
+
+
+
+ Missing Albums
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load the calendar
+ }
+
+ {
+ !error && isPopulated && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+ {
+ !error && isPopulated && !items.length &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+AlbumStudio.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ onSortPress: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onUpdateSelectedPress: PropTypes.func.isRequired
+};
+
+export default AlbumStudio;
diff --git a/frontend/src/AlbumStudio/AlbumStudioAlbum.css b/frontend/src/AlbumStudio/AlbumStudioAlbum.css
new file mode 100644
index 000000000..603d98ecd
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioAlbum.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/AlbumStudio/AlbumStudioAlbum.js b/frontend/src/AlbumStudio/AlbumStudioAlbum.js
new file mode 100644
index 000000000..d1c9e393d
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioAlbum.js
@@ -0,0 +1,90 @@
+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 './AlbumStudioAlbum.css';
+
+class AlbumStudioAlbum extends Component {
+
+ //
+ // Listeners
+
+ onAlbumMonitoredPress = () => {
+ const {
+ id,
+ monitored
+ } = this.props;
+
+ this.props.onAlbumMonitoredPress(id, !monitored);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ title,
+ monitored,
+ statistics,
+ isSaving
+ } = this.props;
+
+ const {
+ trackFileCount,
+ totalTrackCount,
+ percentOfTracks
+ } = statistics;
+
+ return (
+
+
+
+
+
+ {
+ `${title}`
+ }
+
+
+
+
+ {
+ totalTrackCount === 0 ? '0/0' : `${trackFileCount}/${totalTrackCount}`
+ }
+
+
+ );
+ }
+}
+
+AlbumStudioAlbum.propTypes = {
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ statistics: PropTypes.object.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onAlbumMonitoredPress: PropTypes.func.isRequired
+};
+
+AlbumStudioAlbum.defaultProps = {
+ isSaving: false,
+ statistics: {
+ trackFileCount: 0,
+ totalTrackCount: 0,
+ percentOfTracks: 0
+ }
+};
+
+export default AlbumStudioAlbum;
diff --git a/frontend/src/AlbumStudio/AlbumStudioConnector.js b/frontend/src/AlbumStudio/AlbumStudioConnector.js
new file mode 100644
index 000000000..aba720cb9
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioConnector.js
@@ -0,0 +1,97 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import { setAlbumStudioSort, setAlbumStudioFilter, saveAlbumStudio } from 'Store/Actions/albumStudioActions';
+import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions';
+import AlbumStudio from './AlbumStudio';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector(),
+ (artist) => {
+ return {
+ ...artist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchEpisodes,
+ clearEpisodes,
+ setAlbumStudioSort,
+ setAlbumStudioFilter,
+ saveAlbumStudio
+};
+
+class AlbumStudioConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.populate();
+ }
+
+ componentWillUnmount() {
+ this.unpopulate();
+ }
+
+ //
+ // Control
+
+ populate = () => {
+ this.props.fetchEpisodes();
+ }
+
+ unpopulate = () => {
+ this.props.clearEpisodes();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey) => {
+ this.props.setAlbumStudioSort({ sortKey });
+ }
+
+ onFilterSelect = (filterKey, filterValue, filterType) => {
+ this.props.setAlbumStudioFilter({ filterKey, filterValue, filterType });
+ }
+
+ onUpdateSelectedPress = (payload) => {
+ this.props.saveAlbumStudio(payload);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AlbumStudioConnector.propTypes = {
+ setAlbumStudioSort: PropTypes.func.isRequired,
+ setAlbumStudioFilter: PropTypes.func.isRequired,
+ fetchEpisodes: PropTypes.func.isRequired,
+ clearEpisodes: PropTypes.func.isRequired,
+ saveAlbumStudio: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'artist', uiSection: 'albumStudio' }
+)(AlbumStudioConnector);
diff --git a/frontend/src/AlbumStudio/AlbumStudioFooter.css b/frontend/src/AlbumStudio/AlbumStudioFooter.css
new file mode 100644
index 000000000..c18eb660f
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioFooter.css
@@ -0,0 +1,14 @@
+.inputContainer {
+ margin-right: 20px;
+}
+
+.label {
+ margin-bottom: 3px;
+ font-weight: bold;
+}
+
+.updateSelectedButton {
+ composes: button from 'Components/Link/SpinnerButton.css';
+
+ height: 35px;
+}
diff --git a/frontend/src/AlbumStudio/AlbumStudioFooter.js b/frontend/src/AlbumStudio/AlbumStudioFooter.js
new file mode 100644
index 000000000..d5eb300cd
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioFooter.js
@@ -0,0 +1,145 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import MonitorAlbumsSelectInput from 'Components/Form/MonitorAlbumsSelectInput';
+import SelectInput from 'Components/Form/SelectInput';
+import PageContentFooter from 'Components/Page/PageContentFooter';
+import styles from './AlbumStudioFooter.css';
+
+const NO_CHANGE = 'noChange';
+
+class AlbumStudioFooter extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ monitored: NO_CHANGE,
+ monitor: NO_CHANGE
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isSaving,
+ saveError
+ } = prevProps;
+
+ if (prevProps.isSaving && !isSaving && !saveError) {
+ this.setState({
+ monitored: NO_CHANGE,
+ monitor: NO_CHANGE
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+ }
+
+ onUpdateSelectedPress = () => {
+ const {
+ monitor,
+ monitored
+ } = this.state;
+
+ const changes = {};
+
+ if (monitored !== NO_CHANGE) {
+ changes.monitored = monitored === 'monitored';
+ }
+
+ if (monitor !== NO_CHANGE) {
+ changes.monitor = monitor;
+ }
+
+ this.props.onUpdateSelectedPress(changes);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ selectedCount,
+ isSaving
+ } = this.props;
+
+ const {
+ monitored,
+ monitor
+ } = this.state;
+
+ const monitoredOptions = [
+ { key: NO_CHANGE, value: 'No Change', disabled: true },
+ { key: 'monitored', value: 'Monitored' },
+ { key: 'unmonitored', value: 'Unmonitored' }
+ ];
+
+ const noChanges = monitored === NO_CHANGE && monitor === NO_CHANGE;
+
+ return (
+
+
+
+ Monitor Artist
+
+
+
+
+
+
+
+ Monitor Albums
+
+
+
+
+
+
+
+ {selectedCount} Artist(s) Selected
+
+
+
+ Update Selected
+
+
+
+ );
+ }
+}
+
+AlbumStudioFooter.propTypes = {
+ selectedCount: PropTypes.number.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ onUpdateSelectedPress: PropTypes.func.isRequired
+};
+
+export default AlbumStudioFooter;
diff --git a/frontend/src/AlbumStudio/AlbumStudioRow.css b/frontend/src/AlbumStudio/AlbumStudioRow.css
new file mode 100644
index 000000000..a053c6bef
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioRow.css
@@ -0,0 +1,20 @@
+.status,
+.monitored {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 50px;
+}
+
+.title {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 1px;
+ white-space: nowrap;
+}
+
+.seasons {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ display: flex;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/AlbumStudio/AlbumStudioRow.js b/frontend/src/AlbumStudio/AlbumStudioRow.js
new file mode 100644
index 000000000..d63dc83ee
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioRow.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import MonitorToggleButton from 'Components/MonitorToggleButton';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import AlbumStudioAlbum from './AlbumStudioAlbum';
+import styles from './AlbumStudioRow.css';
+
+class AlbumStudioRow extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistId,
+ status,
+ nameSlug,
+ artistName,
+ monitored,
+ albums,
+ isSaving,
+ isSelected,
+ onSelectedChange,
+ onArtistMonitoredPress,
+ onAlbumMonitoredPress
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ albums.map((season) => {
+ return (
+
+ );
+ })
+ }
+
+
+ );
+ }
+}
+
+AlbumStudioRow.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ status: PropTypes.string.isRequired,
+ nameSlug: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ albums: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired,
+ onArtistMonitoredPress: PropTypes.func.isRequired,
+ onAlbumMonitoredPress: PropTypes.func.isRequired
+};
+
+AlbumStudioRow.defaultProps = {
+ isSaving: false
+};
+
+export default AlbumStudioRow;
diff --git a/frontend/src/AlbumStudio/AlbumStudioRowConnector.js b/frontend/src/AlbumStudio/AlbumStudioRowConnector.js
new file mode 100644
index 000000000..fa54b9064
--- /dev/null
+++ b/frontend/src/AlbumStudio/AlbumStudioRowConnector.js
@@ -0,0 +1,85 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { toggleArtistMonitored, toggleSeasonMonitored } from 'Store/Actions/artistActions';
+import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
+import AlbumStudioRow from './AlbumStudioRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.episodes,
+ createArtistSelector(),
+ (episodes, artist) => {
+ const albumsInArtist = _.filter(episodes.items, { artistId: artist.id });
+ const sortedAlbums = _.orderBy(albumsInArtist, 'releaseDate', 'desc');
+
+ return {
+ ...artist,
+ artistId: artist.id,
+ artistName: artist.artistName,
+ nameSlug: artist.nameSlug,
+ monitored: artist.monitored,
+ status: artist.status,
+ isSaving: artist.isSaving,
+ albums: sortedAlbums
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ toggleArtistMonitored,
+ toggleSeasonMonitored,
+ toggleEpisodeMonitored
+};
+
+class AlbumStudioRowConnector extends Component {
+
+ //
+ // Listeners
+
+ onArtistMonitoredPress = () => {
+ const {
+ artistId,
+ monitored
+ } = this.props;
+
+ this.props.toggleArtistMonitored({
+ artistId,
+ monitored: !monitored
+ });
+ }
+
+ onAlbumMonitoredPress = (albumId, monitored) => {
+ this.props.toggleEpisodeMonitored({
+ albumId,
+ monitored
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AlbumStudioRowConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ toggleArtistMonitored: PropTypes.func.isRequired,
+ toggleSeasonMonitored: PropTypes.func.isRequired,
+ toggleEpisodeMonitored: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AlbumStudioRowConnector);
diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js
new file mode 100644
index 000000000..5a8d6a95c
--- /dev/null
+++ b/frontend/src/App/App.js
@@ -0,0 +1,252 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DocumentTitle from 'react-document-title';
+import { Provider } from 'react-redux';
+import { Route, Redirect } from 'react-router-dom';
+import { ConnectedRouter } from 'react-router-redux';
+import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
+import NotFound from 'Components/NotFound';
+import Switch from 'Components/Router/Switch';
+import PageConnector from 'Components/Page/PageConnector';
+import ArtistIndexConnector from 'Artist/Index/ArtistIndexConnector';
+import AddNewArtistConnector from 'AddArtist/AddNewArtist/AddNewArtistConnector';
+import ImportArtist from 'AddArtist/ImportArtist/ImportArtist';
+import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector';
+import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector';
+import ArtistDetailsPageConnector from 'Artist/Details/ArtistDetailsPageConnector';
+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 IndexerSettings from 'Settings/Indexers/IndexerSettings';
+import DownloadClientSettings from 'Settings/DownloadClients/DownloadClientSettings';
+import NotificationSettings from 'Settings/Notifications/NotificationSettings';
+import MetadataSettings from 'Settings/Metadata/MetadataSettings';
+import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
+import UISettingsConnector from 'Settings/UI/UISettingsConnector';
+import Status from 'System/Status/Status';
+import TasksConnector from 'System/Tasks/TasksConnector';
+import BackupsConnector from 'System/Backup/BackupsConnector';
+import UpdatesConnector from 'System/Updates/UpdatesConnector';
+import LogsTableConnector from 'System/Events/LogsTableConnector';
+import Logs from 'System/Logs/Logs';
+
+function App({ store, history }) {
+ return (
+
+
+
+
+
+ {/*
+ Artist
+ */}
+
+
+
+ {
+ window.Sonarr.urlBase &&
+ {
+ return (
+
+ );
+ }}
+ />
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Calendar
+ */}
+
+
+
+ {/*
+ Activity
+ */}
+
+
+
+
+
+
+
+ {/*
+ Wanted
+ */}
+
+
+
+
+
+ {/*
+ Settings
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ System
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Not Found
+ */}
+
+
+
+
+
+
+
+
+ );
+}
+
+App.propTypes = {
+ store: PropTypes.object.isRequired,
+ history: PropTypes.object.isRequired
+};
+
+export default App;
diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js
new file mode 100644
index 000000000..285b87ec8
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModal.js
@@ -0,0 +1,29 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector';
+
+function AppUpdatedModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+AppUpdatedModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AppUpdatedModal;
diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js
new file mode 100644
index 000000000..a21afbc5a
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalConnector.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux';
+import AppUpdatedModal from './AppUpdatedModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ location.reload();
+ }
+ };
+}
+
+export default connect(null, createMapDispatchToProps)(AppUpdatedModal);
diff --git a/frontend/src/App/AppUpdatedModalContent.css b/frontend/src/App/AppUpdatedModalContent.css
new file mode 100644
index 000000000..37b89c9be
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContent.css
@@ -0,0 +1,15 @@
+.version {
+ margin: 0 3px;
+ font-weight: bold;
+}
+
+.maintenance {
+ margin-top: 20px;
+}
+
+.changes {
+ margin-top: 20px;
+ padding-bottom: 5px;
+ border-bottom: 1px solid #e5e5e5;
+ font-size: 18px;
+}
diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js
new file mode 100644
index 000000000..9597d538f
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContent.js
@@ -0,0 +1,98 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Button from 'Components/Link/Button';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import UpdateChanges from 'System/Updates/UpdateChanges';
+import styles from './AppUpdatedModalContent.css';
+
+function AppUpdatedModalContent(props) {
+ const {
+ version,
+ isPopulated,
+ error,
+ items,
+ onSeeChangesPress,
+ onModalClose
+ } = props;
+
+ const update = items[0];
+
+ return (
+
+
+ Lidarr Updated
+
+
+
+
+ Version {version} of Lidarr has been installed, in order to get the latest changes you'll need to reload Lidarr.
+
+
+ {
+ isPopulated && !error && !!update &&
+
+ {
+ !update.changes &&
+
Maintenance release
+ }
+
+ {
+ !!update.changes &&
+
+
+ What's new?
+
+
+
+
+
+
+ }
+
+ }
+
+ {
+ !isPopulated && !error &&
+
+ }
+
+
+
+
+ Recent Changes
+
+
+
+ Reload
+
+
+
+ );
+}
+
+AppUpdatedModalContent.propTypes = {
+ version: PropTypes.string.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSeeChangesPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AppUpdatedModalContent;
diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js
new file mode 100644
index 000000000..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..9178d2ab8
--- /dev/null
+++ b/frontend/src/App/ConnectionLostModal.js
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './ConnectionLostModal.css';
+
+function ConnectionLostModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ Connnection Lost
+
+
+
+
+ Lidarr has lost it's connection to the backend and will need to be reloaded to restore functionality.
+
+
+
+ Lidarr will try to connect automatically, or you can click reload below.
+
+
+
+
+ Reload
+
+
+
+
+ );
+}
+
+ConnectionLostModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ConnectionLostModal;
diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js
new file mode 100644
index 000000000..8ab8e3cd0
--- /dev/null
+++ b/frontend/src/App/ConnectionLostModalConnector.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux';
+import ConnectionLostModal from './ConnectionLostModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onModalClose() {
+ location.reload();
+ }
+ };
+}
+
+export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal);
diff --git a/frontend/src/Artist/ArtistBanner.js b/frontend/src/Artist/ArtistBanner.js
new file mode 100644
index 000000000..6bc5d7fe6
--- /dev/null
+++ b/frontend/src/Artist/ArtistBanner.js
@@ -0,0 +1,168 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import LazyLoad from 'react-lazyload';
+
+const bannerPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA+gAAAC5AgMAAADG9/24AAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QkRBgAc5PUQ8QAAB7tJREFUeNrtnb1rHEsMwMdjArtrUrpf3uMg2cOl+5QpXWRvjQkXlyHVK8MVYXFl3F+/GAzrNfdSuXkQnH9ie/Oqw32aFM6bkTQfd85rHrwi0qjJJql+pxlJo9FISv0XqX8mKkmSJEmSJEmSJEmSJEmSJEmS5NeVYrh7HDqJ5DeY23kQB64/u7zW91amzgXqvRqjfGYvarnfxqncuaQlf72Zxv4gSOluudOfjRy1Hzh1L+nPj+KU3jh0MWr3Sp87dDFq98An/msmg3zPW/aFR6/vRaBPglML6Mci0H0g92MYfrjvRgJ5TrDvhuFyGIZv9NdTOev93dDry1Z59mM56/2hU8WZcefFjZgVT/Z90SlEV9VKio3HeKYZLLSGII6WPP+oBv3ZwkL3iE4JG/ZRjUYb16mAripUO/c4Hl3bd/juCF19FuHeJn6nK90VBh0s3SjBvcFW/wTrvfBa118EbHY8qmMOtmgdupoKOLTvAmOH1k059LKAX+Qra/TncExH4hcBXV3Zf/+Dv5Vb43cfod/wt3PLsN5VF6HDiudt56IbB23Ql5/oZ8A7CfZWbgF61sap62XdlOZTZ2rF3c7ltNW1+f7LuDez/uc6uDfO8dwkbPXcKF8vPS9sds527pC2utH0uCb0Jmz2t8wNvN3q2jj4ntDJna94m3g4sb7HH8Gue0RH4Je8z61g4GGr79Wz1qHjbi94m/jcH1IOccsj+lt/sOFr4m0EPz+3XyNueURvSmfn+Ebx1rctMvOxU8foqOwVa+9mfdvHDH+DdYQOxAesvZslvc/wo4vQZy6eY+vdrCVrut/AyTW9CWvO3E1ra4L6i5Fxoka7MDan45vTOmx2MPGc0QH52Tb6kTPxXNGtW5/Tnl+oGP2N/dstY8eeO2M+bqM3zvVxRT+gS8Vdl5/z6CaCzfx/c41o3pP2+030UzrAHDNGv8d4FvMVAf1I4c07V/QlnNsyG9TN23ID/ZjObnO+6CZmydS+y8oG9Dfk2Gd80WcW3Rv44e5bZOLtD8EUHbRq0V0F/DAMn8fQ2UUv2UayEMxplaFvM0G7QR/ufqBcmH/gG85Z9BM8rMO5bdiUDu4ceaLvUjqWfJveQm8hpvnKFv1jOLxUTtkoBWf0nIK5Cd6wO207dAznTlmjH+K630KvuKPbOHYffJsOexykx0iWJ7pNRf+ttEXvW1U49F7ZbJ26RHSe6ehn5NRGMPBVQAfP12EQf8QZPTMxXac30M1RxtaWXLBFn3j0eRsHNIBuCycLtqfWCQZrBcZ0W+gVhXts0RtEXyit4zDOoE/N/5yNzNF3oVB0A93I6ise7bijr1XwbYiurySg7xjfpp+g375mjj5D9HYD3fr6YvkKcxU80Q8duvVt2+gjob/ljX7yFH1a80dvDfpia8GXqp3Wr1XJXevljvFt2QZ6a6tJ2Gvd2Pa8Xuu2iNB7o++r+lUJlQas93oOl04be73Ut7y1Ts4tn/0EfaxZOzcKaXIsqNiI4QtE5x7N7Z08RZ9CKpb38cWs9V26b4sDWURnr3W9foq+gpM8a3S4V+rhKUCcqrBXTufMUxU2QWUTkFtZGls3pjgnqJ4FdB1lZAd8/qMEZGRtAlJvJqONb2syzuj2CuK+1YU5reg2CzGNyqq6fpOpku8VRI7lchX+7SIy8NdQSaKn3K8bsWJOlZGBX2Ed0bUIdBXftJpzG1h2vjetWFqgHXrhczTWtx2h8llXVTytI4FHnaes0bGMqNhC1+jUXmFMx7iCaq6qy4HqxR7dFfMUtc34LQCVDPppcM13Kie5RmTGJYNUKBraDL57xEKalS0UzTgXiiqn1X3fRxR0bBf6TLEvD4aKkslTdO5F4fQUIHseo5ugfrShewbWjvsriHxT65WAByAHVCT7u0fvoKDC+rZClZyf/eSAngUTj1pfCXjshU/8smDiPTr7J374sDPDfI1Hd4cX1g874TmvRc+30U8V9+e88Ig7U6V26Ofk21rzg1ScH3Hj033bTXIZab2iGG6PdWOaQzLx7cShQ0FFfdxqlfFu2DAhxw4vf5zWV3hYZ96m47l7u0+DMAgdu1Dxbs4St+Rx6MbAwzIveLfk8Y2Y9J5HP1tij3DmjZii9lujQy9GXO/M22/5pmvUVdWiV3RkYd50zbfaI7Vb9GmDD0C4t9qLGiy+JPTVKT4A4d5gMY866N4i+p9KuUM767aavpkqLnmDDl10S8W/mSq20O3I3n8x6AXufP4tdKlxcpkZ4HN43FZ0mT3GCmicTO2ysW2uVXmFWp/yb5cdN0kHrb/ADwFN0uPW+ICOt+0SWuPjkW2tPTr+CjcSphjiGIzWodPQGwljMGj4Se/Q0bfJGH4Sj7zx6DJG3tCgo4HQoYiukDHoKB5vZdCtb9MrIUM7DyK169Zu+mEUMtQsjLJrtT7vWl2IGWXnBxjaFwFnraQBhmFs5dDqi66SNLYyGlY6XMgaVip4RG00mHh2LWwwcRhHPVsJG0cdhpDProQNIQ+j52e30kbPa19B5T64H9Wfqn0mTelB7Y04pWMFfCQf5JDTjYOTuSClu5QUSa9EyU0gf5BF7iaP2zxdq6TJjUydgxTD3aPvm5wkSZIkSZIkSZIkSZIkSZIk+Z9kv/534U2+Uyf0XwP9H83PZBlAqkdjAAAAAElFTkSuQmCC';
+
+function findBanner(images) {
+ return _.find(images, { coverType: 'banner' });
+}
+
+function getBannerUrl(banner, size) {
+ if (banner) {
+ if (banner.url.contains('lastWrite=') || (/^https?:/).test(banner.url)) {
+ // Remove protocol
+ let url = banner.url.replace(/^https?:/, '');
+ url = url.replace('banner.jpg', `banner-${size}.jpg`);
+
+ return url;
+ }
+ }
+}
+
+class ArtistBanner extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const pixelRatio = Math.floor(window.devicePixelRatio);
+
+ const {
+ images,
+ size
+ } = props;
+
+ const banner = findBanner(images);
+
+ this.state = {
+ pixelRatio,
+ banner,
+ bannerUrl: getBannerUrl(banner, pixelRatio * size),
+ isLoaded: false,
+ hasError: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ images,
+ size
+ } = this.props;
+
+ const {
+ banner,
+ pixelRatio
+ } = this.state;
+
+ const nextBanner = findBanner(images);
+
+ if (nextBanner && (!banner || nextBanner.url !== banner.url)) {
+ this.setState({
+ banner: nextBanner,
+ bannerUrl: getBannerUrl(nextBanner, 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.
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onError = () => {
+ this.setState({ hasError: true });
+ }
+
+ onLoad = () => {
+ this.setState({
+ isLoaded: true,
+ hasError: false
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ style,
+ size,
+ lazy,
+ overflow
+ } = this.props;
+
+ const {
+ bannerUrl,
+ hasError,
+ isLoaded
+ } = this.state;
+
+ if (hasError || !bannerUrl) {
+ return (
+
+ );
+ }
+
+ if (lazy) {
+ return (
+
+ }
+ >
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+ArtistBanner.propTypes = {
+ className: PropTypes.string,
+ style: PropTypes.object,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ size: PropTypes.number.isRequired,
+ lazy: PropTypes.bool.isRequired,
+ overflow: PropTypes.bool.isRequired
+};
+
+ArtistBanner.defaultProps = {
+ size: 70,
+ lazy: true,
+ overflow: false
+};
+
+export default ArtistBanner;
diff --git a/frontend/src/Artist/ArtistLogo.js b/frontend/src/Artist/ArtistLogo.js
new file mode 100644
index 000000000..05e665186
--- /dev/null
+++ b/frontend/src/Artist/ArtistLogo.js
@@ -0,0 +1,160 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import LazyLoad from 'react-lazyload';
+
+const logoPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AgMAAAC84irAAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+EJEBIzDdm9OfoAAAbkSURBVGje7Zq9b9s4FMBZFgUkBR27C3cw0MromL1jxwyVZASB67G4qWPgoSAyBdm9CwECKCp8nbIccGj/Ce/BTUb3Lh3aI997pCjnTnyyt0JcIif5+ZHvPZLvQ0KMYxzjGMc4xjGOcYxjHOP4JUfSfP7RVPvSH3MYX/eC5aecxne1v+w95WebFs/rwVO/8+h8PnT6t3ln/DFQuJ06/SyHiX9pxa7o5/lewkuLDxLvhM8tPki8g07dU8Gnj5zGlw7P79n4pDVYi8/YuHO4n03z0z6XXDom4G3TXDdN840+LobN/W1Ty2slHD8bNvevlUgutLmTj4NmT3pf6mMGcJGth+gefaZsDCjB2Wj65wN8ZmnAGnE6eFieI1FvcEISLjIUr9hm+w7PFeHiE9t0E7dyIatE48odXTPu0j/A3BMnXf7NXDxudTxbE2VxMWVu+sfwf3i1ZMLiaQLf+iWIP4VtjtTzFhc35vfveZrb4nPt4R95ulu1cxeVh8Psw7rzbgWp8dWHyr83WJpbgjypjS5XeZnqRxmJNUd3MS1d6ue/tOn0WuayNd2CoTlaeqwnIVeOgcWHdHdMS9cSN1vCy3bxZwzFm6VL7QA14WTudVj1sFvf4ReZNSCO0IvwngXFV3hkFcriuPokrPrYbYxjVAHiZ24zLYIeP7/E4xZUgHiZWt29D9ptGemHR7mPo9B10HLGbucRfs/Ww2f2CD4L2u0+wofKwwvrd0XoqCmr38CAZa1d58LesEpvgqtN4MCR1mVj2nZWOiweVB/CAXuyi59Y1auA2eekg6Xw8Tfm013A8LFV8mYXL61ZF4Hb8Zx8d9vBtbdG7s99XvOOZlF38QVtmlkAv0ffxTOjxU/o5p8FvKbSszw2ik87+Iz23Lwf134RiWf2tG3xN2T4oh8vDO4U33z+5qnefFnR77OA2wheh2WfbJBHeI/XgtNJEaHdtJNrvPn8E8eV/kW/2xn8FDc77LemOyq4J1XvSbds7SZ3cAV+86UXP283TGaFUk4ZwmNyugne8FaqxdHtFkH8GNewg2cc3PjsM7CbbNdMwQJ47aL3mP5H308ar5XOn2nUwpx+4hrx/z+qn5DBNqD4rMUpWACnPwnhkfa9SnZwvX1MnHLVi08cPle+0wBuAsykd8dO0KkS9L0dPCO37MVLxJc6nPHdTeNT/ZeLDQN/DEFpBzc33Bfckhx8K1q7IS5vuPgjbTf5AL97zcALxFUHN76QrF7heTHru54RN3bbxTeEn4Xx04f4NOfhSuPLncmnQk3z1yLlSE8fabtFHVyZyIQlXes8zrdSJR5ea7k3+asUooXg2mO4oDprT/XdHpROhouL/8A3edBw5DYxBhYdn08Q53jd0elDfApHbHjL6Hk/pvvNd1rEWdLl9iG+hpMgiMMdVEM64B8X5nq6ZBwX5rCSeK/4uInJROiwetLi0jtpG0yJBPOkTVQXryEPKqMQbq6JeyUTvUOkilq/EVGmo5NIpP3XRIzhXIafrjzF30JUIqecKxIjOpF6il9jbHTLxjs3rN5voPH+GxbDA1m7GrM9a4zdTigdCUUXD2MSSEAXQRxDo2QHl2iwV+h7gchqLrLrhmKxH/Z6nqLUQD5AYSHWAEwk+Z1Ck1vEAmEhBaVtufDtj8Zmv6U+PQNBqbDf/szVR5XNvQteSAzRyeQhzgnIKR2Invq43gQb4+oRaJCTTcRd6RkzGXlJQe3vDq8gsDB2S0QaSoViwKNW9Sh9zUzEMA2MWtU7nJUGYhIa4bnjcLthgkkopMAGj3dxXgoMCbg+laTFL8luSn9pFkrAMf031cmVJz0jXzsKFm6OSfVqYnEILPKZDjeicPFhQoaHbMhKX+NmZ5Q+ntr8n5obhGPVKlx48cs+FteKP3MlswWv6CSPHK4Dmntm0ckreW0snmxKbsnLFdyo4mrwjLYJo+Dmyn0k3uDTEpMRTrnPKza+IHy9wGSEU2yMvSrvHeJ/Qt2UV+p0hVacvsah0psKXqEVy7y2tPu3xhM1oMxLReY00tAlJG9JFZktzCwyU4lbuqQ7U22VN1zi9gvsIP05PjAL7H55H/C6rREzyvu41bbS4VXb1OV0FLG1YVsa1J1gtzaosVJbHO3Gb6z4bR2H89s61FRqCIcgL+E3lfyWlsaN3eR6QDP0pSdeKqOEZjOgoda285SUl5W+Jga181wz0WQFF2poM7FtZTZKXlXZ0Fam10htroY3Ug9s43pN5OJ2jyZy28Iu1nu0sNsGenGzRwO9bd8Xd/u0793LA8Vmn5cHnPhiH+Gt+HIv4Ye+tnHoSyMHvrJy6Aszh76uc+DLQuLQV5XGMY5xjGMc4xjHOMYxjnH80uNfW99BeoyzJCoAAAAASUVORK5CYII=';
+
+function findLogo(images) {
+ return _.find(images, { coverType: 'logo' });
+}
+
+function getLogoUrl(logo, size) {
+ if (logo) {
+ // Remove protocol
+ let url = logo.url.replace(/^https?:/, '');
+ url = url.replace('logo.jpg', `logo-${size}.jpg`);
+
+ return url;
+ }
+}
+
+class ArtistLogo extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const pixelRatio = Math.floor(window.devicePixelRatio);
+
+ const {
+ images,
+ size
+ } = props;
+
+ const logo = findLogo(images);
+
+ this.state = {
+ pixelRatio,
+ logo,
+ logoUrl: getLogoUrl(logo, pixelRatio * size),
+ hasError: false,
+ isLoaded: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ images,
+ size
+ } = this.props;
+
+ const {
+ pixelRatio
+ } = this.state;
+
+ const logo = findLogo(images);
+
+ if (logo && logo.url !== this.state.logo.url) {
+ this.setState({
+ logo,
+ logoUrl: getLogoUrl(logo, pixelRatio * size),
+ hasError: false,
+ isLoaded: false
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onError = () => {
+ this.setState({ hasError: true });
+ }
+
+ onLoad = () => {
+ this.setState({ isLoaded: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ style,
+ size,
+ lazy,
+ overflow
+ } = this.props;
+
+ const {
+ logoUrl,
+ hasError,
+ isLoaded
+ } = this.state;
+
+ if (hasError || !logoUrl) {
+ return (
+
+ );
+ }
+
+ if (lazy) {
+ return (
+
+ }
+ >
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+ArtistLogo.propTypes = {
+ className: PropTypes.string,
+ style: PropTypes.object,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ size: PropTypes.number.isRequired,
+ lazy: PropTypes.bool.isRequired,
+ overflow: PropTypes.bool.isRequired
+};
+
+ArtistLogo.defaultProps = {
+ size: 250,
+ lazy: true,
+ overflow: false
+};
+
+export default ArtistLogo;
diff --git a/frontend/src/Artist/ArtistNameLink.js b/frontend/src/Artist/ArtistNameLink.js
new file mode 100644
index 000000000..aafc97912
--- /dev/null
+++ b/frontend/src/Artist/ArtistNameLink.js
@@ -0,0 +1,20 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Link from 'Components/Link/Link';
+
+function ArtistNameLink({ nameSlug, artistName }) {
+ const link = `/artist/${nameSlug}`;
+
+ return (
+
+ {artistName}
+
+ );
+}
+
+ArtistNameLink.propTypes = {
+ nameSlug: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired
+};
+
+export default ArtistNameLink;
diff --git a/frontend/src/Artist/ArtistPoster.js b/frontend/src/Artist/ArtistPoster.js
new file mode 100644
index 000000000..25b744c80
--- /dev/null
+++ b/frontend/src/Artist/ArtistPoster.js
@@ -0,0 +1,168 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import LazyLoad from 'react-lazyload';
+
+const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AgMAAAC84irAAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+EJEBIzDdm9OfoAAAbkSURBVGje7Zq9b9s4FMBZFgUkBR27C3cw0MromL1jxwyVZASB67G4qWPgoSAyBdm9CwECKCp8nbIccGj/Ce/BTUb3Lh3aI997pCjnTnyyt0JcIif5+ZHvPZLvQ0KMYxzjGMc4xjGOcYxjHOP4JUfSfP7RVPvSH3MYX/eC5aecxne1v+w95WebFs/rwVO/8+h8PnT6t3ln/DFQuJ06/SyHiX9pxa7o5/lewkuLDxLvhM8tPki8g07dU8Gnj5zGlw7P79n4pDVYi8/YuHO4n03z0z6XXDom4G3TXDdN840+LobN/W1Ty2slHD8bNvevlUgutLmTj4NmT3pf6mMGcJGth+gefaZsDCjB2Wj65wN8ZmnAGnE6eFieI1FvcEISLjIUr9hm+w7PFeHiE9t0E7dyIatE48odXTPu0j/A3BMnXf7NXDxudTxbE2VxMWVu+sfwf3i1ZMLiaQLf+iWIP4VtjtTzFhc35vfveZrb4nPt4R95ulu1cxeVh8Psw7rzbgWp8dWHyr83WJpbgjypjS5XeZnqRxmJNUd3MS1d6ue/tOn0WuayNd2CoTlaeqwnIVeOgcWHdHdMS9cSN1vCy3bxZwzFm6VL7QA14WTudVj1sFvf4ReZNSCO0IvwngXFV3hkFcriuPokrPrYbYxjVAHiZ24zLYIeP7/E4xZUgHiZWt29D9ptGemHR7mPo9B10HLGbucRfs/Ww2f2CD4L2u0+wofKwwvrd0XoqCmr38CAZa1d58LesEpvgqtN4MCR1mVj2nZWOiweVB/CAXuyi59Y1auA2eekg6Xw8Tfm013A8LFV8mYXL61ZF4Hb8Zx8d9vBtbdG7s99XvOOZlF38QVtmlkAv0ffxTOjxU/o5p8FvKbSszw2ik87+Iz23Lwf134RiWf2tG3xN2T4oh8vDO4U33z+5qnefFnR77OA2wheh2WfbJBHeI/XgtNJEaHdtJNrvPn8E8eV/kW/2xn8FDc77LemOyq4J1XvSbds7SZ3cAV+86UXP283TGaFUk4ZwmNyugne8FaqxdHtFkH8GNewg2cc3PjsM7CbbNdMwQJ47aL3mP5H308ar5XOn2nUwpx+4hrx/z+qn5DBNqD4rMUpWACnPwnhkfa9SnZwvX1MnHLVi08cPle+0wBuAsykd8dO0KkS9L0dPCO37MVLxJc6nPHdTeNT/ZeLDQN/DEFpBzc33Bfckhx8K1q7IS5vuPgjbTf5AL97zcALxFUHN76QrF7heTHru54RN3bbxTeEn4Xx04f4NOfhSuPLncmnQk3z1yLlSE8fabtFHVyZyIQlXes8zrdSJR5ea7k3+asUooXg2mO4oDprT/XdHpROhouL/8A3edBw5DYxBhYdn08Q53jd0elDfApHbHjL6Hk/pvvNd1rEWdLl9iG+hpMgiMMdVEM64B8X5nq6ZBwX5rCSeK/4uInJROiwetLi0jtpG0yJBPOkTVQXryEPKqMQbq6JeyUTvUOkilq/EVGmo5NIpP3XRIzhXIafrjzF30JUIqecKxIjOpF6il9jbHTLxjs3rN5voPH+GxbDA1m7GrM9a4zdTigdCUUXD2MSSEAXQRxDo2QHl2iwV+h7gchqLrLrhmKxH/Z6nqLUQD5AYSHWAEwk+Z1Ck1vEAmEhBaVtufDtj8Zmv6U+PQNBqbDf/szVR5XNvQteSAzRyeQhzgnIKR2Invq43gQb4+oRaJCTTcRd6RkzGXlJQe3vDq8gsDB2S0QaSoViwKNW9Sh9zUzEMA2MWtU7nJUGYhIa4bnjcLthgkkopMAGj3dxXgoMCbg+laTFL8luSn9pFkrAMf031cmVJz0jXzsKFm6OSfVqYnEILPKZDjeicPFhQoaHbMhKX+NmZ5Q+ntr8n5obhGPVKlx48cs+FteKP3MlswWv6CSPHK4Dmntm0ckreW0snmxKbsnLFdyo4mrwjLYJo+Dmyn0k3uDTEpMRTrnPKza+IHy9wGSEU2yMvSrvHeJ/Qt2UV+p0hVacvsah0psKXqEVy7y2tPu3xhM1oMxLReY00tAlJG9JFZktzCwyU4lbuqQ7U22VN1zi9gvsIP05PjAL7H55H/C6rREzyvu41bbS4VXb1OV0FLG1YVsa1J1gtzaosVJbHO3Gb6z4bR2H89s61FRqCIcgL+E3lfyWlsaN3eR6QDP0pSdeKqOEZjOgoda285SUl5W+Jga181wz0WQFF2poM7FtZTZKXlXZ0Fam10htroY3Ug9s43pN5OJ2jyZy28Iu1nu0sNsGenGzRwO9bd8Xd/u0793LA8Vmn5cHnPhiH+Gt+HIv4Ye+tnHoSyMHvrJy6Aszh76uc+DLQuLQV5XGMY5xjGMc4xjHOMYxjnH80uNfW99BeoyzJCoAAAAASUVORK5CYII=';
+
+function findPoster(images) {
+ return _.find(images, { coverType: 'poster' });
+}
+
+function getPosterUrl(poster, size) {
+ if (poster) {
+ if (poster.url.contains('lastWrite=') || (/^https?:/).test(poster.url)) {
+ // Remove protocol
+ let url = poster.url.replace(/^https?:/, '');
+ url = url.replace('poster.jpg', `poster-${size}.jpg`);
+
+ return url;
+ }
+ }
+}
+
+class ArtistPoster extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const pixelRatio = Math.floor(window.devicePixelRatio);
+
+ const {
+ images,
+ size
+ } = props;
+
+ const poster = findPoster(images);
+
+ this.state = {
+ pixelRatio,
+ poster,
+ posterUrl: getPosterUrl(poster, pixelRatio * size),
+ isLoaded: false,
+ hasError: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ images,
+ size
+ } = this.props;
+
+ const {
+ poster,
+ pixelRatio
+ } = this.state;
+
+ const nextPoster = findPoster(images);
+
+ if (nextPoster && (!poster || nextPoster.url !== poster.url)) {
+ this.setState({
+ poster: nextPoster,
+ posterUrl: getPosterUrl(nextPoster, pixelRatio * size),
+ hasError: false
+ // Don't reset isLoaded, as we want to immediately try to
+ // show the new image, whether an image was shown previously
+ // or the placeholder was shown.
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onError = () => {
+ this.setState({ hasError: true });
+ }
+
+ onLoad = () => {
+ this.setState({
+ isLoaded: true,
+ hasError: false
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ style,
+ size,
+ lazy,
+ overflow
+ } = this.props;
+
+ const {
+ posterUrl,
+ hasError,
+ isLoaded
+ } = this.state;
+
+ if (hasError || !posterUrl) {
+ return (
+
+ );
+ }
+
+ if (lazy) {
+ return (
+
+ }
+ >
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+ArtistPoster.propTypes = {
+ className: PropTypes.string,
+ style: PropTypes.object,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ size: PropTypes.number.isRequired,
+ lazy: PropTypes.bool.isRequired,
+ overflow: PropTypes.bool.isRequired
+};
+
+ArtistPoster.defaultProps = {
+ size: 250,
+ lazy: true,
+ overflow: false
+};
+
+export default ArtistPoster;
diff --git a/frontend/src/Artist/Delete/DeleteArtist.less b/frontend/src/Artist/Delete/DeleteArtist.less
new file mode 100644
index 000000000..4cf99e1b2
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtist.less
@@ -0,0 +1,39 @@
+@import "Content/icons";
+
+.delete-artist-modal {
+
+ i {
+ margin-right : 5px;
+ //.fa-icon-color(white);
+
+ }
+
+ .path {
+ white-space : nowrap;
+ font-size : 16px;
+ padding-bottom : 20px;
+ }
+
+ .delete-files-info,
+ .delete-label {
+ color : @brand-danger-dark;
+ }
+
+ .delete-files-info {
+ display : none;
+ }
+
+ .checkbox {
+ display : inline-block;
+ }
+
+ .c-checkbox:hover .check {
+ border-color : @brand-danger-dark;
+ }
+
+ input[type=checkbox]:checked + span {
+ background-color : @brand-danger-dark;
+ border-color : @brand-danger-dark;
+ }
+
+}
\ No newline at end of file
diff --git a/frontend/src/Artist/Delete/DeleteArtistModal.js b/frontend/src/Artist/Delete/DeleteArtistModal.js
new file mode 100644
index 000000000..5b6490c66
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtistModal.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import DeleteArtistModalContentConnector from './DeleteArtistModalContentConnector';
+
+function DeleteArtistModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+DeleteArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default DeleteArtistModal;
diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContent.css b/frontend/src/Artist/Delete/DeleteArtistModalContent.css
new file mode 100644
index 000000000..dbfef0871
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtistModalContent.css
@@ -0,0 +1,12 @@
+.pathContainer {
+ margin-bottom: 20px;
+}
+
+.pathIcon {
+ margin-right: 8px;
+}
+
+.deleteFilesMessage {
+ margin-top: 20px;
+ color: $dangerColor;
+}
diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Delete/DeleteArtistModalContent.js
new file mode 100644
index 000000000..444b75bc8
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtistModalContent.js
@@ -0,0 +1,139 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons, inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './DeleteArtistModalContent.css';
+
+class DeleteArtistModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ deleteFiles: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDeleteFilesChange = ({ value }) => {
+ this.setState({ deleteFiles: value });
+ }
+
+ onDeleteArtistConfirmed = () => {
+ const deleteFiles = this.state.deleteFiles;
+
+ this.setState({ deleteFiles: false });
+ this.props.onDeletePress(deleteFiles);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistName,
+ path,
+ trackFileCount,
+ sizeOnDisk,
+ onModalClose
+ } = this.props;
+
+ const deleteFiles = this.state.deleteFiles;
+ let deleteFilesLabel = `Delete ${trackFileCount} Track Files`;
+ let deleteFilesHelpText = 'Delete the track files and artist folder';
+
+ if (trackFileCount === 0) {
+ deleteFilesLabel = 'Delete Artist Folder';
+ deleteFilesHelpText = 'Delete the artist folder and it\'s contents';
+ }
+
+ return (
+
+
+ Delete - {artistName}
+
+
+
+
+
+
+ {path}
+
+
+
+ {deleteFilesLabel}
+
+
+
+
+ {
+ deleteFiles &&
+
+
The artist folder {path} and all it's content will be deleted.
+
+ {
+ !!trackFileCount &&
+
{trackFileCount} track files totaling {formatBytes(sizeOnDisk)}
+ }
+
+ }
+
+
+
+
+
+ Close
+
+
+
+ Delete
+
+
+
+ );
+ }
+}
+
+DeleteArtistModalContent.propTypes = {
+ artistName: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ trackFileCount: PropTypes.number.isRequired,
+ sizeOnDisk: PropTypes.number.isRequired,
+ onDeletePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+DeleteArtistModalContent.defaultProps = {
+ trackFileCount: 0
+};
+
+export default DeleteArtistModalContent;
diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js b/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js
new file mode 100644
index 000000000..938ac5a96
--- /dev/null
+++ b/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js
@@ -0,0 +1,55 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { deleteArtist } from 'Store/Actions/artistActions';
+import DeleteArtistModalContent from './DeleteArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (artist) => {
+ return artist;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ deleteArtist
+};
+
+class DeleteArtistModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onDeletePress = (deleteFiles) => {
+ this.props.deleteArtist({
+ id: this.props.artistId,
+ deleteFiles
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DeleteArtistModalContentConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ deleteArtist: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DeleteArtistModalContentConnector);
diff --git a/frontend/src/Artist/Details/AlbumRow.css b/frontend/src/Artist/Details/AlbumRow.css
new file mode 100644
index 000000000..255ee76c9
--- /dev/null
+++ b/frontend/src/Artist/Details/AlbumRow.css
@@ -0,0 +1,17 @@
+.title {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ white-space: nowrap;
+}
+
+.monitored {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 42px;
+}
+
+.status {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Artist/Details/AlbumRow.js b/frontend/src/Artist/Details/AlbumRow.js
new file mode 100644
index 000000000..0ae589625
--- /dev/null
+++ b/frontend/src/Artist/Details/AlbumRow.js
@@ -0,0 +1,226 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MonitorToggleButton from 'Components/MonitorToggleButton';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import { kinds, sizes } from 'Helpers/Props';
+import TableRow from 'Components/Table/TableRow';
+import Label from 'Components/Label';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+import EpisodeSearchCellConnector from 'Album/EpisodeSearchCellConnector';
+import EpisodeTitleLink from 'Album/EpisodeTitleLink';
+
+import styles from './AlbumRow.css';
+
+function getEpisodeCountKind(monitored, trackFileCount, episodeCount) {
+ if (trackFileCount === episodeCount && episodeCount > 0) {
+ return kinds.SUCCESS;
+ }
+
+ if (!monitored) {
+ return kinds.WARNING;
+ }
+
+ return kinds.DANGER;
+}
+
+class AlbumRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onManualSearchPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onDetailsModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ onMonitorAlbumPress = (monitored, options) => {
+ this.props.onMonitorAlbumPress(this.props.id, monitored, options);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ artistId,
+ monitored,
+ statistics,
+ duration,
+ releaseDate,
+ title,
+ isSaving,
+ artistMonitored,
+ path,
+ columns
+ } = this.props;
+
+ const {
+ trackCount,
+ trackFileCount,
+ totalTrackCount
+ } = statistics;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'monitored') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'title') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'path') {
+ return (
+
+ {
+ path
+ }
+
+ );
+ }
+
+ if (name === 'trackCount') {
+ return (
+
+ {
+ statistics.totalTrackCount
+ }
+
+ );
+ }
+
+ if (name === 'duration') {
+ return (
+
+ {
+ formatTimeSpan(duration)
+ }
+
+ );
+ }
+
+ if (name === 'releaseDate') {
+ return (
+
+ );
+ }
+
+ if (name === 'status') {
+ return (
+
+
+ {
+ {trackFileCount} / {trackCount}
+ }
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+ }
+}
+
+AlbumRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ artistId: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ duration: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ isSaving: PropTypes.bool,
+ unverifiedSceneNumbering: PropTypes.bool,
+ artistMonitored: PropTypes.bool.isRequired,
+ statistics: PropTypes.object.isRequired,
+ path: PropTypes.string,
+ mediaInfo: PropTypes.object,
+ alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onMonitorAlbumPress: PropTypes.func.isRequired
+};
+
+AlbumRow.defaultProps = {
+ statistics: {
+ trackCount: 0,
+ trackFileCount: 0
+ }
+};
+
+export default AlbumRow;
diff --git a/frontend/src/Artist/Details/AlbumRowConnector.js b/frontend/src/Artist/Details/AlbumRowConnector.js
new file mode 100644
index 000000000..cff75e50f
--- /dev/null
+++ b/frontend/src/Artist/Details/AlbumRowConnector.js
@@ -0,0 +1,28 @@
+/* eslint max-params: 0 */
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import AlbumRow from './AlbumRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state, { sceneSeasonNumber }) => sceneSeasonNumber,
+ createArtistSelector(),
+ createTrackFileSelector(),
+ createCommandsSelector(),
+ (id, sceneSeasonNumber, artist, trackFile, commands) => {
+ const alternateTitles = sceneSeasonNumber ? _.filter(artist.alternateTitles, { sceneSeasonNumber }) : [];
+
+ return {
+ artistMonitored: artist.monitored,
+ trackFilePath: trackFile ? trackFile.path : null,
+ trackFileRelativePath: trackFile ? trackFile.relativePath : null,
+ alternateTitles
+ };
+ }
+ );
+}
+export default connect(createMapStateToProps)(AlbumRow);
diff --git a/frontend/src/Artist/Details/ArtistAlternateTitles.css b/frontend/src/Artist/Details/ArtistAlternateTitles.css
new file mode 100644
index 000000000..1af1ae68b
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistAlternateTitles.css
@@ -0,0 +1,3 @@
+.alternateTitle {
+ white-space: nowrap;
+}
diff --git a/frontend/src/Artist/Details/ArtistAlternateTitles.js b/frontend/src/Artist/Details/ArtistAlternateTitles.js
new file mode 100644
index 000000000..e1fde52e6
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistAlternateTitles.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './ArtistAlternateTitles.css';
+
+function ArtistAlternateTitles({ alternateTitles }) {
+ return (
+
+ {
+ alternateTitles.map((alternateTitle) => {
+ return (
+
+ {alternateTitle}
+
+ );
+ })
+ }
+
+ );
+}
+
+ArtistAlternateTitles.propTypes = {
+ alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired
+};
+
+export default ArtistAlternateTitles;
diff --git a/frontend/src/Artist/Details/ArtistDetails.css b/frontend/src/Artist/Details/ArtistDetails.css
new file mode 100644
index 000000000..1fd2f3940
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetails.css
@@ -0,0 +1,132 @@
+.innerContentBody {
+ padding: 0;
+}
+
+.header {
+ position: relative;
+ width: 100%;
+ height: 375px;
+}
+
+.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;
+}
+
+.logo {
+ flex-shrink: 0;
+ margin-right: 35px;
+ width: 250px;
+ height: 97px;
+}
+
+.poster {
+ flex-shrink: 0;
+ margin-right: 35px;
+ width: 250px;
+ height: 250px;
+}
+
+.info {
+ flex-grow: 1;
+ overflow: hidden;
+}
+
+.titleContainer {
+ display: flex;
+ justify-content: space-between;
+}
+
+.title {
+ margin-bottom: 5px;
+ font-weight: 300;
+ font-size: 50px;
+ line-height: 50px;
+}
+
+.alternateTitlesIconContainer {
+ margin-left: 20px;
+ line-height: 50px;
+}
+
+.artistNavigationButtons {
+ white-space: no-wrap;
+}
+
+.artistNavigationButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ margin-left: 5px;
+ color: #e1e2e3;
+ white-space: nowrap;
+}
+
+.details {
+ font-weight: 300;
+ font-size: 20px;
+}
+
+.runtime {
+ margin-right: 15px;
+}
+
+.detailsLabel {
+ composes: label from 'Components/Label.css';
+
+ margin: 5px 10px 5px 0;
+}
+
+.path,
+.sizeOnDisk,
+.qualityProfileName,
+.links,
+.tags {
+ margin-left: 8px;
+ font-weight: 300;
+ font-size: 17px;
+}
+
+.path {
+ vertical-align: text-top;
+ font-size: $defaultFontSize;
+ font-family: $monoSpaceFontFamily;
+}
+
+.contentContainer {
+ padding: 20px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .contentContainer {
+ padding: 20px 0;
+ }
+
+ .headerContent {
+ padding: 15px;
+ }
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .poster {
+ display: none;
+ }
+}
diff --git a/frontend/src/Artist/Details/ArtistDetails.js b/frontend/src/Artist/Details/ArtistDetails.js
new file mode 100644
index 000000000..74af7b438
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetails.js
@@ -0,0 +1,591 @@
+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 HeartRating from 'Components/HeartRating';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Label from 'Components/Label';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import Popover from 'Components/Tooltip/Popover';
+import Tooltip from 'Components/Tooltip/Tooltip';
+import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
+import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
+import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
+import ArtistPoster from 'Artist/ArtistPoster';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+import ArtistAlternateTitles from './ArtistAlternateTitles';
+import ArtistDetailsSeasonConnector from './ArtistDetailsSeasonConnector';
+import ArtistTagsConnector from './ArtistTagsConnector';
+import ArtistDetailsLinks from './ArtistDetailsLinks';
+import styles from './ArtistDetails.css';
+
+const albumTypes = [
+ {
+ name: 'album',
+ label: 'Album',
+ isVisible: true
+ },
+ {
+ name: 'ep',
+ label: 'EP',
+ isVisible: true
+ },
+ {
+ name: 'single',
+ label: 'Single',
+ isVisible: true
+ },
+ {
+ name: 'broadcast',
+ label: 'Broadcast',
+ isVisible: true
+ },
+ {
+ name: 'other',
+ label: 'Other',
+ isVisible: true
+ }
+];
+
+function getFanartUrl(images) {
+ const fanartImage = _.find(images, { coverType: 'fanart' });
+ if (fanartImage) {
+ // Remove protocol
+ return fanartImage.url.replace(/^https?:/, '');
+ }
+}
+
+function getExpandedState(newState) {
+ return {
+ allExpanded: newState.allSelected,
+ allCollapsed: newState.allUnselected,
+ expandedState: newState.selectedState
+ };
+}
+
+class ArtistDetails extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOrganizeModalOpen: false,
+ isManageEpisodesOpen: false,
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false,
+ allExpanded: false,
+ allCollapsed: false,
+ expandedState: {}
+ };
+ }
+
+ //
+ // Listeners
+
+ onOrganizePress = () => {
+ this.setState({ isOrganizeModalOpen: true });
+ }
+
+ onOrganizeModalClose = () => {
+ this.setState({ isOrganizeModalOpen: false });
+ }
+
+ onManageEpisodesPress = () => {
+ this.setState({ isManageEpisodesOpen: true });
+ }
+
+ onManageEpisodesModalClose = () => {
+ this.setState({ isManageEpisodesOpen: false });
+ }
+
+ onEditArtistPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditArtistModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteArtistPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ onExpandAllPress = () => {
+ const {
+ allExpanded,
+ expandedState
+ } = this.state;
+
+ this.setState(getExpandedState(selectAll(expandedState, !allExpanded)));
+ }
+
+ onExpandPress = (albumId, isExpanded) => {
+ this.setState((state) => {
+ const convertedState = {
+ allSelected: state.allExpanded,
+ allUnselected: state.allCollapsed,
+ selectedState: state.expandedState
+ };
+
+ const newState = toggleSelected(convertedState, [], albumId, isExpanded, false);
+
+ return getExpandedState(newState);
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ foreignArtistId,
+ artistName,
+ ratings,
+ path,
+ sizeOnDisk,
+ trackFileCount,
+ qualityProfileId,
+ monitored,
+ status,
+ overview,
+ links,
+ images,
+ albums,
+ alternateTitles,
+ tags,
+ isRefreshing,
+ isSearching,
+ isFetching,
+ isPopulated,
+ episodesError,
+ trackFilesError,
+ previousArtist,
+ nextArtist,
+ onRefreshPress,
+ onSearchPress
+ } = this.props;
+
+ const {
+ isOrganizeModalOpen,
+ isManageEpisodesOpen,
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen,
+ allExpanded,
+ allCollapsed,
+ expandedState
+ } = this.state;
+
+ const continuing = status === 'continuing';
+
+ let trackFilesCountMessage = 'No track files';
+
+ if (trackFileCount === 1) {
+ trackFilesCountMessage = '1 track file';
+ } else if (trackFileCount > 1) {
+ trackFilesCountMessage = `${trackFileCount} track files`;
+ }
+
+ let expandIcon = icons.EXPAND_INDETERMINATE;
+
+ if (allExpanded) {
+ expandIcon = icons.COLLAPSE;
+ } else if (allCollapsed) {
+ expandIcon = icons.EXPAND;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {artistName}
+
+ {
+ !!alternateTitles.length &&
+
+
+ }
+ title="Alternate Titles"
+ body={ }
+ position={tooltipPositions.BOTTOM}
+ />
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {path}
+
+
+
+
+
+
+
+ {
+ formatBytes(sizeOnDisk)
+ }
+
+
+
+
+
+
+
+ {
+
+ }
+
+
+
+
+
+
+
+ {monitored ? 'Monitored' : 'Unmonitored'}
+
+
+
+
+
+
+
+ {continuing ? 'Continuing' : 'Ended'}
+
+
+
+
+
+
+
+ Links
+
+
+ }
+ tooltip={
+
+ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.BOTTOM}
+ />
+
+ {
+ !!tags.length &&
+
+
+
+
+ Tags
+
+
+ }
+ tooltip={ }
+ kind={kinds.INVERSE}
+ position={tooltipPositions.BOTTOM}
+ />
+
+ }
+
+
+
+
+
+
+
+
+
+
+ {
+ !isPopulated && !episodesError && !trackFilesError &&
+
+ }
+
+ {
+ !isFetching && episodesError &&
+
Loading episodes failed
+ }
+
+ {
+ !isFetching && trackFilesError &&
+
Loading episode files failed
+ }
+
+ {
+ isPopulated && !!albumTypes.length &&
+
+ {
+ albumTypes.slice(0).map((season) => {
+ return (
+
+ );
+ })
+ }
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistDetails.propTypes = {
+ id: PropTypes.number.isRequired,
+ foreignArtistId: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ ratings: PropTypes.object.isRequired,
+ path: PropTypes.string.isRequired,
+ sizeOnDisk: PropTypes.number.isRequired,
+ trackFileCount: PropTypes.number,
+ qualityProfileId: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ overview: PropTypes.string.isRequired,
+ links: PropTypes.arrayOf(PropTypes.object).isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ albums: PropTypes.arrayOf(PropTypes.object).isRequired,
+ alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ isRefreshing: PropTypes.bool.isRequired,
+ isSearching: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ episodesError: PropTypes.object,
+ trackFilesError: PropTypes.object,
+ previousArtist: PropTypes.object.isRequired,
+ nextArtist: PropTypes.object.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+ArtistDetails.defaultProps = {
+ isSaving: false
+};
+
+export default ArtistDetails;
diff --git a/frontend/src/Artist/Details/ArtistDetailsConnector.js b/frontend/src/Artist/Details/ArtistDetailsConnector.js
new file mode 100644
index 000000000..29efad73d
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsConnector.js
@@ -0,0 +1,193 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { findCommand } from 'Utilities/Command';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { fetchEpisodes, clearEpisodes } from 'Store/Actions/episodeActions';
+import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import ArtistDetails from './ArtistDetails';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { nameSlug }) => nameSlug,
+ (state) => state.episodes,
+ (state) => state.trackFiles,
+ createAllArtistSelector(),
+ createCommandsSelector(),
+ (nameSlug, episodes, trackFiles, allArtists, commands) => {
+ const sortedArtist = _.orderBy(allArtists, 'sortName');
+ const artistIndex = _.findIndex(sortedArtist, { nameSlug });
+ const artist = sortedArtist[artistIndex];
+
+ if (!artist) {
+ return {};
+ }
+
+ const previousArtist = sortedArtist[artistIndex - 1] || _.last(sortedArtist);
+ const nextArtist = sortedArtist[artistIndex + 1] || _.first(sortedArtist);
+ const isArtistRefreshing = !!findCommand(commands, { name: commandNames.REFRESH_ARTIST, artistId: artist.id });
+ const allArtistRefreshing = _.some(commands, (command) => command.name === commandNames.REFRESH_ARTIST && !command.body.artistId);
+ const isRefreshing = isArtistRefreshing || allArtistRefreshing;
+ const isSearching = !!findCommand(commands, { name: commandNames.ARTIST_SEARCH, artistId: artist.id });
+ const isRenamingFiles = !!findCommand(commands, { name: commandNames.RENAME_FILES, artistId: artist.id });
+ const isRenamingArtistCommand = findCommand(commands, { name: commandNames.RENAME_ARTIST });
+ const isRenamingArtist = !!(isRenamingArtistCommand && isRenamingArtistCommand.body.artistId.indexOf(artist.id) > -1);
+
+ const isFetching = episodes.isFetching || trackFiles.isFetching;
+ const isPopulated = episodes.isPopulated && trackFiles.isPopulated;
+ const episodesError = episodes.error;
+ const trackFilesError = trackFiles.error;
+ const alternateTitles = _.reduce(artist.alternateTitles, (acc, alternateTitle) => {
+ if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) &&
+ (alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) {
+ acc.push(alternateTitle.title);
+ }
+
+ return acc;
+ }, []);
+
+ return {
+ ...artist,
+ alternateTitles,
+ isArtistRefreshing,
+ allArtistRefreshing,
+ isRefreshing,
+ isSearching,
+ isRenamingFiles,
+ isRenamingArtist,
+ isFetching,
+ isPopulated,
+ episodesError,
+ trackFilesError,
+ previousArtist,
+ nextArtist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchEpisodes,
+ clearEpisodes,
+ fetchTrackFiles,
+ clearTrackFiles,
+ fetchQueueDetails,
+ clearQueueDetails,
+ executeCommand
+};
+
+class ArtistDetailsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ registerPagePopulator(this.populate);
+ this.populate();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ id,
+ isArtistRefreshing,
+ allArtistRefreshing,
+ isRenamingFiles,
+ isRenamingArtist
+ } = this.props;
+
+ if (
+ (prevProps.isArtistRefreshing && !isArtistRefreshing) ||
+ (prevProps.allArtistRefreshing && !allArtistRefreshing) ||
+ (prevProps.isRenamingFiles && !isRenamingFiles) ||
+ (prevProps.isRenamingArtist && !isRenamingArtist)
+ ) {
+ this.populate();
+ }
+
+ // If the id has changed we need to clear the 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 artistId = this.props.id;
+
+ this.props.fetchEpisodes({ artistId });
+ this.props.fetchTrackFiles({ artistId });
+ this.props.fetchQueueDetails({ artistId });
+ }
+
+ unpopulate = () => {
+ this.props.clearEpisodes();
+ this.props.clearTrackFiles();
+ this.props.clearQueueDetails();
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ this.props.executeCommand({
+ name: commandNames.REFRESH_ARTIST,
+ artistId: this.props.id
+ });
+ }
+
+ onSearchPress = () => {
+ this.props.executeCommand({
+ name: commandNames.ARTIST_SEARCH,
+ artistId: this.props.id
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ArtistDetailsConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ nameSlug: PropTypes.string.isRequired,
+ isArtistRefreshing: PropTypes.bool.isRequired,
+ allArtistRefreshing: PropTypes.bool.isRequired,
+ isRefreshing: PropTypes.bool.isRequired,
+ isRenamingFiles: PropTypes.bool.isRequired,
+ isRenamingArtist: PropTypes.bool.isRequired,
+ fetchEpisodes: PropTypes.func.isRequired,
+ clearEpisodes: PropTypes.func.isRequired,
+ fetchTrackFiles: PropTypes.func.isRequired,
+ clearTrackFiles: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ArtistDetailsConnector);
diff --git a/frontend/src/Artist/Details/ArtistDetailsLinks.css b/frontend/src/Artist/Details/ArtistDetailsLinks.css
new file mode 100644
index 000000000..0f65b9154
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsLinks.css
@@ -0,0 +1,13 @@
+.links {
+ margin: 0;
+}
+
+.link {
+ white-space: nowrap;
+}
+
+.linkLabel {
+ composes: label from 'Components/Label.css';
+
+ cursor: pointer;
+}
diff --git a/frontend/src/Artist/Details/ArtistDetailsLinks.js b/frontend/src/Artist/Details/ArtistDetailsLinks.js
new file mode 100644
index 000000000..655b6b294
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsLinks.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import styles from './ArtistDetailsLinks.css';
+
+function ArtistDetailsLinks(props) {
+ const {
+ foreignArtistId,
+ links
+ } = props;
+
+ return (
+
+
+
+
+ Musicbrainz
+
+
+
+ {links.map((link, index) => {
+ return (
+
+
+
+ {link.name}
+
+
+ {(index > 0 && index % 5 === 0) &&
+
+ }
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+ArtistDetailsLinks.propTypes = {
+ foreignArtistId: PropTypes.string.isRequired,
+ links: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default ArtistDetailsLinks;
diff --git a/frontend/src/Artist/Details/ArtistDetailsPageConnector.js b/frontend/src/Artist/Details/ArtistDetailsPageConnector.js
new file mode 100644
index 000000000..3219267bf
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsPageConnector.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 createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import NotFound from 'Components/NotFound';
+import ArtistDetailsConnector from './ArtistDetailsConnector';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { match }) => match,
+ createAllArtistSelector(),
+ (match, allArtists) => {
+ const nameSlug = match.params.nameSlug;
+ const artistIndex = _.findIndex(allArtists, { nameSlug });
+
+ if (artistIndex > -1) {
+ return {
+ nameSlug
+ };
+ }
+
+ return {};
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ push
+};
+
+class ArtistDetailsPageConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ if (!this.props.nameSlug) {
+ this.props.push(`${window.Sonarr.urlBase}/`);
+ return;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ nameSlug
+ } = this.props;
+
+ if (!nameSlug) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+ArtistDetailsPageConnector.propTypes = {
+ nameSlug: PropTypes.string,
+ match: PropTypes.shape({ params: PropTypes.shape({ nameSlug: PropTypes.string.isRequired }).isRequired }).isRequired,
+ push: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ArtistDetailsPageConnector);
diff --git a/frontend/src/Artist/Details/ArtistDetailsSeason.css b/frontend/src/Artist/Details/ArtistDetailsSeason.css
new file mode 100644
index 000000000..4cb0065e9
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsSeason.css
@@ -0,0 +1,119 @@
+.albumType {
+ margin-bottom: 20px;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+
+ &:last-of-type {
+ margin-bottom: 0;
+ }
+}
+
+.header {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ font-size: 24px;
+}
+
+.albumTypeLabel {
+ margin-right: 5px;
+ margin-left: 5px;
+}
+
+.albumCount {
+ font-size: 18px;
+ font-style: italic;
+ color: #8895aa;
+}
+
+.episodeCountContainer {
+ margin-left: 10px;
+ vertical-align: text-bottom;
+}
+
+.expandButton {
+ composes: link from 'Components/Link/Link.css';
+
+ flex-grow: 1;
+ margin: 0 20px;
+ text-align: center;
+}
+
+.left {
+ display: flex;
+ align-items: center;
+ flex: 0 1 300px;
+}
+
+.left,
+.actions {
+ padding: 15px 10px;
+}
+
+.actionsMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ flex: 0 0 45px;
+}
+
+.actionsMenuContent {
+ composes: menuContent from 'Components/Menu/MenuContent.css';
+
+ white-space: nowrap;
+ font-size: 14px;
+}
+
+.actionMenuIcon {
+ margin-right: 8px;
+}
+
+.actionButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ width: 30px;
+}
+
+.episodes {
+ padding-top: 15px;
+ border-top: 1px solid $borderColor;
+}
+
+.collapseButtonContainer {
+ padding: 10px 15px;
+ width: 100%;
+ border-top: 1px solid $borderColor;
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+ background-color: #fafafa;
+ text-align: center;
+}
+
+.expandButtonIcon {
+ composes: actionButton;
+
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin-top: -12px;
+ margin-left: -15px;
+}
+
+.noEpisodes {
+ margin-bottom: 15px;
+ text-align: center;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .season {
+ border-right: 0;
+ border-left: 0;
+ border-radius: 0;
+ }
+
+ .expandButtonIcon {
+ position: static;
+ margin: 0;
+ }
+}
diff --git a/frontend/src/Artist/Details/ArtistDetailsSeason.js b/frontend/src/Artist/Details/ArtistDetailsSeason.js
new file mode 100644
index 000000000..ccffc29fe
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsSeason.js
@@ -0,0 +1,318 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import isAfter from 'Utilities/Date/isAfter';
+import isBefore from 'Utilities/Date/isBefore';
+import getToggledRange from 'Utilities/Table/getToggledRange';
+import { align, icons, kinds, sizes } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+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 TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
+import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
+import AlbumRowConnector from './AlbumRowConnector';
+import styles from './ArtistDetailsSeason.css';
+
+class ArtistDetailsSeason extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOrganizeModalOpen: false,
+ isManageEpisodesOpen: false,
+ lastToggledEpisode: null
+ };
+ }
+
+ componentDidMount() {
+ this._expandByDefault();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.artistId !== this.props.artistId) {
+ this._expandByDefault();
+ }
+ }
+
+ //
+ // Control
+
+ _expandByDefault() {
+ const {
+ name,
+ onExpandPress,
+ items
+ } = this.props;
+
+ const expand = _.some(items, (item) => {
+ return isAfter(item.releaseDate) ||
+ isAfter(item.releaseDate, { days: -30 });
+ });
+
+ onExpandPress(name, expand && name > 0);
+ }
+
+ //
+ // Listeners
+
+ onOrganizePress = () => {
+ this.setState({ isOrganizeModalOpen: true });
+ }
+
+ onOrganizeModalClose = () => {
+ this.setState({ isOrganizeModalOpen: false });
+ }
+
+ onManageEpisodesPress = () => {
+ this.setState({ isManageEpisodesOpen: true });
+ }
+
+ onManageEpisodesModalClose = () => {
+ this.setState({ isManageEpisodesOpen: false });
+ }
+
+ onExpandPress = () => {
+ const {
+ name,
+ isExpanded
+ } = this.props;
+
+ this.props.onExpandPress(name, !isExpanded);
+ }
+
+ onMonitorAlbumPress = (albumId, monitored, { shiftKey }) => {
+ const lastToggled = this.state.lastToggledEpisode;
+ const albumIds = [albumId];
+
+ if (shiftKey && lastToggled) {
+ const { lower, upper } = getToggledRange(this.props.items, albumId, lastToggled);
+ const items = this.props.items;
+
+ for (let i = lower; i < upper; i++) {
+ albumIds.push(items[i].id);
+ }
+ }
+
+ this.setState({ lastToggledEpisode: albumId });
+
+ this.props.onMonitorAlbumPress(_.uniq(albumIds), monitored);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistId,
+ label,
+ items,
+ columns,
+ isSaving,
+ isExpanded,
+ isSearching,
+ artistMonitored,
+ isSmallScreen,
+ onTableOptionChange,
+ onMonitorSeasonPress,
+ onSearchPress
+ } = this.props;
+
+ const {
+ isOrganizeModalOpen,
+ isManageEpisodesOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+
+
+ {label}
+
+
+
+ ({items.length} Releases)
+
+
+ }
+
+
+
+
+
+
+
+ {
+ !isSmallScreen &&
+
+ }
+
+
+ {
+ isSmallScreen ?
+
+
+
+
+
+
+
+
+
+ Search
+
+
+
+
+
+ Preview Rename
+
+
+
+
+
+ Manage Tracks
+
+
+ :
+
+
+
+
+
+ }
+
+
+
+
+ {
+ isExpanded &&
+
+ {
+ items.length ?
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
:
+
+
+ No albums in this group
+
+ }
+
+
+
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistDetailsSeason.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool,
+ isExpanded: PropTypes.bool,
+ isSearching: PropTypes.bool.isRequired,
+ artistMonitored: PropTypes.bool.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onTableOptionChange: PropTypes.func.isRequired,
+ onMonitorSeasonPress: PropTypes.func.isRequired,
+ onExpandPress: PropTypes.func.isRequired,
+ onMonitorAlbumPress: PropTypes.func.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+export default ArtistDetailsSeason;
diff --git a/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js b/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.js
new file mode 100644
index 000000000..49dfb78e4
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistDetailsSeasonConnector.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 { findCommand } from 'Utilities/Command';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { toggleSeasonMonitored } from 'Store/Actions/artistActions';
+import { toggleEpisodesMonitored, setEpisodesTableOption } from 'Store/Actions/episodeActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import ArtistDetailsSeason from './ArtistDetailsSeason';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { label }) => label,
+ (state) => state.episodes,
+ createArtistSelector(),
+ createCommandsSelector(),
+ createDimensionsSelector(),
+ (label, episodes, artist, commands, dimensions) => {
+ const isSearching = !!findCommand(commands, {
+ name: commandNames.SEASON_SEARCH,
+ artistId: artist.id,
+ label
+ });
+
+ const episodesInSeason = _.filter(episodes.items, { albumType: label });
+ const sortedEpisodes = _.orderBy(episodesInSeason, 'releaseDate', 'desc');
+
+ return {
+ items: sortedEpisodes,
+ columns: episodes.columns,
+ isSearching,
+ artistMonitored: artist.monitored,
+ isSmallScreen: dimensions.isSmallScreen
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ toggleSeasonMonitored,
+ toggleEpisodesMonitored,
+ setEpisodesTableOption,
+ executeCommand
+};
+
+class ArtistDetailsSeasonConnector extends Component {
+
+ //
+ // Listeners
+
+ onTableOptionChange = (payload) => {
+ this.props.setEpisodesTableOption(payload);
+ }
+
+ onMonitorSeasonPress = (monitored) => {
+ const {
+ artistId
+ } = this.props;
+
+ this.props.toggleSeasonMonitored({
+ artistId,
+ monitored
+ });
+ }
+
+ onSearchPress = () => {
+ const {
+ artistId
+ } = this.props;
+
+ this.props.executeCommand({
+ name: commandNames.SEASON_SEARCH,
+ artistId
+ });
+ }
+
+ onMonitorAlbumPress = (albumIds, monitored) => {
+ this.props.toggleEpisodesMonitored({
+ albumIds,
+ monitored
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ArtistDetailsSeasonConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ toggleSeasonMonitored: PropTypes.func.isRequired,
+ toggleEpisodesMonitored: PropTypes.func.isRequired,
+ setEpisodesTableOption: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ArtistDetailsSeasonConnector);
diff --git a/frontend/src/Artist/Details/ArtistTags.css b/frontend/src/Artist/Details/ArtistTags.css
new file mode 100644
index 000000000..ec340a041
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistTags.css
@@ -0,0 +1,8 @@
+.tags {
+ margin: 0;
+ padding-left: 20px;
+}
+
+.tag {
+ white-space: nowrap;
+}
diff --git a/frontend/src/Artist/Details/ArtistTags.js b/frontend/src/Artist/Details/ArtistTags.js
new file mode 100644
index 000000000..7ea841a36
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistTags.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function ArtistTags({ tags }) {
+ return (
+
+ {
+ tags.map((tag) => {
+ return (
+
+ {tag}
+
+ );
+ })
+ }
+
+ );
+}
+
+ArtistTags.propTypes = {
+ tags: PropTypes.arrayOf(PropTypes.string).isRequired
+};
+
+export default ArtistTags;
diff --git a/frontend/src/Artist/Details/ArtistTagsConnector.js b/frontend/src/Artist/Details/ArtistTagsConnector.js
new file mode 100644
index 000000000..1ecde26cd
--- /dev/null
+++ b/frontend/src/Artist/Details/ArtistTagsConnector.js
@@ -0,0 +1,30 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import ArtistTags from './ArtistTags';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createTagsSelector(),
+ (artist, tagList) => {
+ const tags = _.reduce(artist.tags, (acc, tag) => {
+ const matchingTag = _.find(tagList, { id: tag });
+
+ if (matchingTag) {
+ acc.push(matchingTag.label);
+ }
+
+ return acc;
+ }, []);
+
+ return {
+ tags
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(ArtistTags);
diff --git a/frontend/src/Artist/Edit/EditArtistModal.js b/frontend/src/Artist/Edit/EditArtistModal.js
new file mode 100644
index 000000000..6e99a2f53
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditArtistModalContentConnector from './EditArtistModalContentConnector';
+
+function EditArtistModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditArtistModal;
diff --git a/frontend/src/Artist/Edit/EditArtistModalConnector.js b/frontend/src/Artist/Edit/EditArtistModalConnector.js
new file mode 100644
index 000000000..9e62a4780
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModalConnector.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditArtistModal from './EditArtistModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditArtistModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'artist' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditArtistModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(undefined, mapDispatchToProps)(EditArtistModalConnector);
diff --git a/frontend/src/Artist/Edit/EditArtistModalContent.css b/frontend/src/Artist/Edit/EditArtistModalContent.css
new file mode 100644
index 000000000..a3c7f464c
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Artist/Edit/EditArtistModalContent.js b/frontend/src/Artist/Edit/EditArtistModalContent.js
new file mode 100644
index 000000000..ae45f6332
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModalContent.js
@@ -0,0 +1,164 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import styles from './EditArtistModalContent.css';
+
+class EditArtistModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistName,
+ item,
+ isSaving,
+ showLanguageProfile,
+ onInputChange,
+ onSavePress,
+ onModalClose,
+ onDeleteArtistPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ monitored,
+ albumFolder,
+ qualityProfileId,
+ languageProfileId,
+ path,
+ tags
+ } = item;
+
+ return (
+
+
+ Edit - {artistName}
+
+
+
+
+
+
+
+ Delete
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+ }
+}
+
+EditArtistModalContent.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ item: PropTypes.object.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteArtistPress: PropTypes.func.isRequired
+};
+
+export default EditArtistModalContent;
diff --git a/frontend/src/Artist/Edit/EditArtistModalContentConnector.js b/frontend/src/Artist/Edit/EditArtistModalContentConnector.js
new file mode 100644
index 000000000..8676584a3
--- /dev/null
+++ b/frontend/src/Artist/Edit/EditArtistModalContentConnector.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 selectSettings from 'Store/Selectors/selectSettings';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { setArtistValue, saveArtist } from 'Store/Actions/artistActions';
+import EditArtistModalContent from './EditArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artist,
+ (state) => state.settings.languageProfiles,
+ createArtistSelector(),
+ (artistState, languageProfiles, artist) => {
+ const {
+ isSaving,
+ saveError,
+ pendingChanges
+ } = artistState;
+
+ const artistSettings = _.pick(artist, [
+ 'monitored',
+ 'albumFolder',
+ 'qualityProfileId',
+ 'languageProfileId',
+ 'path',
+ 'tags'
+ ]);
+
+ const settings = selectSettings(artistSettings, pendingChanges, saveError);
+
+ return {
+ artistName: artist.artistName,
+ isSaving,
+ saveError,
+ pendingChanges,
+ item: settings.settings,
+ showLanguageProfile: languageProfiles.items.length > 1,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setArtistValue,
+ saveArtist
+};
+
+class EditArtistModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setArtistValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveArtist({ id: this.props.artistId });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditArtistModalContentConnector.propTypes = {
+ artistId: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ setArtistValue: PropTypes.func.isRequired,
+ saveArtist: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditArtistModalContentConnector);
diff --git a/frontend/src/Artist/Editor/ArtistEditor.js b/frontend/src/Artist/Editor/ArtistEditor.js
new file mode 100644
index 000000000..17b0fa91a
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditor.js
@@ -0,0 +1,322 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, sortDirections } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import NoArtist from 'Artist/NoArtist';
+import ArtistEditorRowConnector from './ArtistEditorRowConnector';
+import ArtistEditorFooter from './ArtistEditorFooter';
+import OrganizeArtistModal from './Organize/OrganizeArtistModal';
+
+function getColumns(showLanguageProfile) {
+ return [
+ {
+ name: 'status',
+ isVisible: true
+ },
+ {
+ name: 'sortName',
+ label: 'Name',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'languageProfileId',
+ label: 'Language Profile',
+ isSortable: true,
+ isVisible: showLanguageProfile
+ },
+ {
+ name: 'albumFolder',
+ label: 'Album Folder',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ isSortable: false,
+ isVisible: true
+ }
+ ];
+}
+
+class ArtistEditor extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isOrganizingArtistModalOpen: false,
+ columns: getColumns(props.showLanguageProfile)
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isDeleting,
+ deleteError
+ } = this.props;
+
+ const hasFinishedDeleting = prevProps.isDeleting &&
+ !isDeleting &&
+ !deleteError;
+
+ if (hasFinishedDeleting) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onSaveSelected = (changes) => {
+ this.props.onSaveSelected({
+ artistIds: this.getSelectedIds(),
+ ...changes
+ });
+ }
+
+ onOrganizeArtistPress = () => {
+ this.setState({ isOrganizingArtistModalOpen: true });
+ }
+
+ onOrganizeArtistModalClose = (organized) => {
+ this.setState({ isOrganizingArtistModalOpen: false });
+
+ if (organized === true) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ filterKey,
+ filterValue,
+ sortKey,
+ sortDirection,
+ isSaving,
+ saveError,
+ isDeleting,
+ deleteError,
+ isOrganizingArtist,
+ showLanguageProfile,
+ onSortPress,
+ onFilterSelect
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ columns
+ } = this.state;
+
+ const selectedArtistIds = this.getSelectedIds();
+
+ return (
+
+
+
+
+
+
+
+ All
+
+
+
+ Monitored Only
+
+
+
+ Continuing Only
+
+
+
+ Ended Only
+
+
+
+ Missing Albums
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load the calendar
+ }
+
+ {
+ !error && isPopulated && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+ {
+ !error && isPopulated && !items.length &&
+
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistEditor.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ isDeleting: PropTypes.bool.isRequired,
+ deleteError: PropTypes.object,
+ isOrganizingArtist: PropTypes.bool.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onSaveSelected: PropTypes.func.isRequired
+};
+
+export default ArtistEditor;
diff --git a/frontend/src/Artist/Editor/ArtistEditorConnector.js b/frontend/src/Artist/Editor/ArtistEditorConnector.js
new file mode 100644
index 000000000..e99e09861
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorConnector.js
@@ -0,0 +1,86 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createCommandSelector from 'Store/Selectors/createCommandSelector';
+import { setArtistEditorSort, setArtistEditorFilter, saveArtistEditor } from 'Store/Actions/artistEditorActions';
+import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import * as commandNames from 'Commands/commandNames';
+import ArtistEditor from './ArtistEditor';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.languageProfiles,
+ createClientSideCollectionSelector(),
+ createCommandSelector(commandNames.RENAME_ARTIST),
+ (languageProfiles, artist, isOrganizingArtist) => {
+ return {
+ isOrganizingArtist,
+ showLanguageProfile: languageProfiles.items.length > 1,
+ ...artist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setArtistEditorSort,
+ setArtistEditorFilter,
+ saveArtistEditor,
+ fetchRootFolders
+};
+
+class ArtistEditorConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchRootFolders();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey) => {
+ this.props.setArtistEditorSort({ sortKey });
+ }
+
+ onFilterSelect = (filterKey, filterValue, filterType) => {
+ this.props.setArtistEditorFilter({ filterKey, filterValue, filterType });
+ }
+
+ onSaveSelected = (payload) => {
+ this.props.saveArtistEditor(payload);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ArtistEditorConnector.propTypes = {
+ setArtistEditorSort: PropTypes.func.isRequired,
+ setArtistEditorFilter: PropTypes.func.isRequired,
+ saveArtistEditor: PropTypes.func.isRequired,
+ fetchRootFolders: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'artist', uiSection: 'artistEditor' }
+)(ArtistEditorConnector);
diff --git a/frontend/src/Artist/Editor/ArtistEditorFooter.css b/frontend/src/Artist/Editor/ArtistEditorFooter.css
new file mode 100644
index 000000000..cc01c33ea
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorFooter.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;
+ }
+
+ .selectedArtistLabel {
+ text-align: left;
+ }
+}
diff --git a/frontend/src/Artist/Editor/ArtistEditorFooter.js b/frontend/src/Artist/Editor/ArtistEditorFooter.js
new file mode 100644
index 000000000..382c5d092
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorFooter.js
@@ -0,0 +1,295 @@
+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 SpinnerButton from 'Components/Link/SpinnerButton';
+import PageContentFooter from 'Components/Page/PageContentFooter';
+import TagsModal from './Tags/TagsModal';
+import DeleteArtistModal from './Delete/DeleteArtistModal';
+import ArtistEditorFooterLabel from './ArtistEditorFooterLabel';
+import styles from './ArtistEditorFooter.css';
+
+const NO_CHANGE = 'noChange';
+
+class ArtistEditorFooter extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ monitored: NO_CHANGE,
+ qualityProfileId: NO_CHANGE,
+ languageProfileId: NO_CHANGE,
+ albumFolder: NO_CHANGE,
+ rootFolderPath: NO_CHANGE,
+ savingTags: false,
+ isDeleteArtistModalOpen: false,
+ isTagsModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isSaving,
+ saveError
+ } = this.props;
+
+ if (prevProps.isSaving && !isSaving && !saveError) {
+ this.setState({
+ monitored: NO_CHANGE,
+ qualityProfileId: NO_CHANGE,
+ languageProfileId: NO_CHANGE,
+ albumFolder: NO_CHANGE,
+ rootFolderPath: NO_CHANGE,
+ savingTags: false
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+
+ if (value === NO_CHANGE) {
+ return;
+ }
+
+ switch (name) {
+ case 'monitored':
+ this.props.onSaveSelected({ [name]: value === 'monitored' });
+ break;
+ case 'albumFolder':
+ this.props.onSaveSelected({ [name]: value === 'yes' });
+ break;
+ default:
+ this.props.onSaveSelected({ [name]: value });
+ }
+ }
+
+ onApplyTagsPress = (tags, applyTags) => {
+ this.setState({
+ savingTags: true,
+ isTagsModalOpen: false
+ });
+
+ this.props.onSaveSelected({
+ tags,
+ applyTags
+ });
+ }
+
+ onDeleteSelectedPress = () => {
+ this.setState({ isDeleteArtistModalOpen: true });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ onTagsPress = () => {
+ this.setState({ isTagsModalOpen: true });
+ }
+
+ onTagsModalClose = () => {
+ this.setState({ isTagsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistIds,
+ selectedCount,
+ isSaving,
+ isDeleting,
+ isOrganizingArtist,
+ showLanguageProfile,
+ onOrganizeArtistPress
+ } = this.props;
+
+ const {
+ monitored,
+ qualityProfileId,
+ languageProfileId,
+ albumFolder,
+ rootFolderPath,
+ savingTags,
+ isTagsModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ const monitoredOptions = [
+ { key: NO_CHANGE, value: 'No Change', disabled: true },
+ { key: 'monitored', value: 'Monitored', disabled: true },
+ { key: 'unmonitored', value: 'Unmonitored' }
+ ];
+
+ const albumFolderOptions = [
+ { key: NO_CHANGE, value: 'No Change', disabled: true },
+ { key: 'yes', value: 'Yes' },
+ { key: 'no', value: 'No' }
+ ];
+
+ return (
+
+
+
+
+
+ {
+ showLanguageProfile &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ Rename Files
+
+
+
+ Set Tags
+
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistEditorFooter.propTypes = {
+ artistIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ selectedCount: PropTypes.number.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ isDeleting: PropTypes.bool.isRequired,
+ deleteError: PropTypes.object,
+ isOrganizingArtist: PropTypes.bool.isRequired,
+ showLanguageProfile: PropTypes.bool.isRequired,
+ onSaveSelected: PropTypes.func.isRequired,
+ onOrganizeArtistPress: PropTypes.func.isRequired
+};
+
+export default ArtistEditorFooter;
diff --git a/frontend/src/Artist/Editor/ArtistEditorFooterLabel.css b/frontend/src/Artist/Editor/ArtistEditorFooterLabel.css
new file mode 100644
index 000000000..9b4b40be6
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorFooterLabel.css
@@ -0,0 +1,8 @@
+.label {
+ margin-bottom: 3px;
+ font-weight: bold;
+}
+
+.savingIcon {
+ margin-left: 8px;
+}
diff --git a/frontend/src/Artist/Editor/ArtistEditorFooterLabel.js b/frontend/src/Artist/Editor/ArtistEditorFooterLabel.js
new file mode 100644
index 000000000..1c6be745d
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorFooterLabel.js
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import SpinnerIcon from 'Components/SpinnerIcon';
+import styles from './ArtistEditorFooterLabel.css';
+
+function ArtistEditorFooterLabel(props) {
+ const {
+ className,
+ label,
+ isSaving
+ } = props;
+
+ return (
+
+ {label}
+
+ {
+ isSaving &&
+
+ }
+
+ );
+}
+
+ArtistEditorFooterLabel.propTypes = {
+ className: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ isSaving: PropTypes.bool.isRequired
+};
+
+ArtistEditorFooterLabel.defaultProps = {
+ className: styles.label
+};
+
+export default ArtistEditorFooterLabel;
diff --git a/frontend/src/Artist/Editor/ArtistEditorRow.css b/frontend/src/Artist/Editor/ArtistEditorRow.css
new file mode 100644
index 000000000..17711c50c
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorRow.css
@@ -0,0 +1,5 @@
+.albumFolder {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
diff --git a/frontend/src/Artist/Editor/ArtistEditorRow.js b/frontend/src/Artist/Editor/ArtistEditorRow.js
new file mode 100644
index 000000000..ddfd8a893
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorRow.js
@@ -0,0 +1,114 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+import TagListConnector from 'Components/TagListConnector';
+import CheckInput from 'Components/Form/CheckInput';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import ArtistStatusCell from 'Artist/Index/Table/ArtistStatusCell';
+import styles from './ArtistEditorRow.css';
+
+class ArtistEditorRow extends Component {
+
+ //
+ // Listeners
+
+ onAlbumFolderChange = () => {
+ // Mock handler to satisfy `onChange` being required for `CheckInput`.
+ //
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ status,
+ nameSlug,
+ artistName,
+ monitored,
+ languageProfile,
+ qualityProfile,
+ albumFolder,
+ path,
+ tags,
+ columns,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {qualityProfile.name}
+
+
+ {
+ _.find(columns, { name: 'languageProfileId' }).isVisible &&
+
+ {languageProfile.name}
+
+ }
+
+
+
+
+
+
+ {path}
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistEditorRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ status: PropTypes.string.isRequired,
+ nameSlug: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ languageProfile: PropTypes.object.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ albumFolder: PropTypes.bool.isRequired,
+ path: PropTypes.string.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default ArtistEditorRow;
diff --git a/frontend/src/Artist/Editor/ArtistEditorRowConnector.js b/frontend/src/Artist/Editor/ArtistEditorRowConnector.js
new file mode 100644
index 000000000..4ed496a01
--- /dev/null
+++ b/frontend/src/Artist/Editor/ArtistEditorRowConnector.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector';
+import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
+import ArtistEditorRow from './ArtistEditorRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createLanguageProfileSelector(),
+ createQualityProfileSelector(),
+ (languageProfile, qualityProfile) => {
+ return {
+ languageProfile,
+ qualityProfile
+ };
+ }
+ );
+}
+
+function ArtistEditorRowConnector(props) {
+ return (
+
+ );
+}
+
+ArtistEditorRowConnector.propTypes = {
+ qualityProfileId: PropTypes.number.isRequired
+};
+
+export default connect(createMapStateToProps)(ArtistEditorRowConnector);
diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js
new file mode 100644
index 000000000..11fd79d5d
--- /dev/null
+++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import DeleteArtistModalContentConnector from './DeleteArtistModalContentConnector';
+
+function DeleteArtistModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+DeleteArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default DeleteArtistModal;
diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css
new file mode 100644
index 000000000..950fdc27d
--- /dev/null
+++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css
@@ -0,0 +1,13 @@
+.message {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
+
+.pathContainer {
+ margin-left: 5px;
+}
+
+.path {
+ margin-left: 5px;
+ color: $dangerColor;
+}
diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.js
new file mode 100644
index 000000000..87088b472
--- /dev/null
+++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.js
@@ -0,0 +1,123 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import Button from 'Components/Link/Button';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './DeleteArtistModalContent.css';
+
+class DeleteArtistModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ deleteFiles: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onDeleteFilesChange = ({ value }) => {
+ this.setState({ deleteFiles: value });
+ }
+
+ onDeleteArtistConfirmed = () => {
+ const deleteFiles = this.state.deleteFiles;
+
+ this.setState({ deleteFiles: false });
+ this.props.onDeleteSelectedPress(deleteFiles);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artist,
+ onModalClose
+ } = this.props;
+ const deleteFiles = this.state.deleteFiles;
+
+ return (
+
+
+ Delete Selected Artist
+
+
+
+
+
+ {`Delete Artist Folder${artist.length > 1 ? 's' : ''}`}
+
+ 1 ? 's' : ''} and all contents`}
+ kind={kinds.DANGER}
+ onChange={this.onDeleteFilesChange}
+ />
+
+
+
+
+ {`Are you sure you want to delete ${artist.length} selected artist${artist.length > 1 ? 's' : ''}${deleteFiles ? ' and all contents' : ''}?`}
+
+
+
+ {
+ artist.map((s) => {
+ return (
+
+ {s.artistName}
+
+ {
+ deleteFiles &&
+
+ -
+
+ {s.path}
+
+
+ }
+
+ );
+ })
+ }
+
+
+
+
+
+ Cancel
+
+
+
+ Delete
+
+
+
+ );
+ }
+}
+
+DeleteArtistModalContent.propTypes = {
+ artist: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteSelectedPress: PropTypes.func.isRequired
+};
+
+export default DeleteArtistModalContent;
diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js
new file mode 100644
index 000000000..8c61976e8
--- /dev/null
+++ b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js
@@ -0,0 +1,45 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import { bulkDeleteArtist } from 'Store/Actions/artistEditorActions';
+import DeleteArtistModalContent from './DeleteArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { artistIds }) => artistIds,
+ createAllArtistSelector(),
+ (artistIds, allArtists) => {
+ const selectedArtist = _.intersectionWith(allArtists, artistIds, (s, id) => {
+ return s.id === id;
+ });
+
+ const sortedArtist = _.orderBy(selectedArtist, 'sortName');
+ const artist = _.map(sortedArtist, (s) => {
+ return {
+ artistName: s.artistName,
+ path: s.path
+ };
+ });
+
+ return {
+ artist
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onDeleteSelectedPress(deleteFiles) {
+ dispatch(bulkDeleteArtist({
+ artistIds: props.artistIds,
+ deleteFiles
+ }));
+
+ props.onModalClose();
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteArtistModalContent);
diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModal.js b/frontend/src/Artist/Editor/Organize/OrganizeArtistModal.js
new file mode 100644
index 000000000..412396355
--- /dev/null
+++ b/frontend/src/Artist/Editor/Organize/OrganizeArtistModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import OrganizeArtistModalContentConnector from './OrganizeArtistModalContentConnector';
+
+function OrganizeArtistModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+OrganizeArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default OrganizeArtistModal;
diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.css b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.css
new file mode 100644
index 000000000..0b896f4ef
--- /dev/null
+++ b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.css
@@ -0,0 +1,8 @@
+.renameIcon {
+ margin-left: 5px;
+}
+
+.message {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js
new file mode 100644
index 000000000..5f90eca90
--- /dev/null
+++ b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js
@@ -0,0 +1,74 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './OrganizeArtistModalContent.css';
+
+function OrganizeArtistModalContent(props) {
+ const {
+ artistNames,
+ onModalClose,
+ onOrganizeArtistPress
+ } = props;
+
+ return (
+
+
+ Organize Selected Artist
+
+
+
+
+ Tip: To preview a rename... select "Cancel" then click any artist name and use the
+
+
+
+
+ Are you sure you want to organize all files in the {artistNames.length} selected artist?
+
+
+
+ {
+ artistNames.map((artistName) => {
+ return (
+
+ {artistName}
+
+ );
+ })
+ }
+
+
+
+
+
+ Cancel
+
+
+
+ Organize
+
+
+
+ );
+}
+
+OrganizeArtistModalContent.propTypes = {
+ artistNames: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onOrganizeArtistPress: PropTypes.func.isRequired
+};
+
+export default OrganizeArtistModalContent;
diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContentConnector.js b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContentConnector.js
new file mode 100644
index 000000000..6be1eb961
--- /dev/null
+++ b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContentConnector.js
@@ -0,0 +1,67 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import OrganizeArtistModalContent from './OrganizeArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { artistIds }) => artistIds,
+ createAllArtistSelector(),
+ (artistIds, allArtists) => {
+ const artist = _.intersectionWith(allArtists, artistIds, (s, id) => {
+ return s.id === id;
+ });
+
+ const sortedArtist = _.orderBy(artist, 'sortName');
+ const artistNames = _.map(sortedArtist, 'artistName');
+
+ return {
+ artistNames
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand
+};
+
+class OrganizeArtistModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onOrganizeArtistPress = () => {
+ this.props.executeCommand({
+ name: commandNames.RENAME_ARTIST,
+ artistIds: this.props.artistIds
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render(props) {
+ return (
+
+ );
+ }
+}
+
+OrganizeArtistModalContentConnector.propTypes = {
+ artistIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(OrganizeArtistModalContentConnector);
diff --git a/frontend/src/Artist/Editor/Tags/TagsModal.js b/frontend/src/Artist/Editor/Tags/TagsModal.js
new file mode 100644
index 000000000..0f6c2d7ec
--- /dev/null
+++ b/frontend/src/Artist/Editor/Tags/TagsModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import TagsModalContentConnector from './TagsModalContentConnector';
+
+function TagsModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+TagsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default TagsModal;
diff --git a/frontend/src/Artist/Editor/Tags/TagsModalContent.css b/frontend/src/Artist/Editor/Tags/TagsModalContent.css
new file mode 100644
index 000000000..63be9aadd
--- /dev/null
+++ b/frontend/src/Artist/Editor/Tags/TagsModalContent.css
@@ -0,0 +1,12 @@
+.renameIcon {
+ margin-left: 5px;
+}
+
+.message {
+ margin-top: 20px;
+ margin-bottom: 10px;
+}
+
+.result {
+ padding-top: 4px;
+}
diff --git a/frontend/src/Artist/Editor/Tags/TagsModalContent.js b/frontend/src/Artist/Editor/Tags/TagsModalContent.js
new file mode 100644
index 000000000..b982fee0e
--- /dev/null
+++ b/frontend/src/Artist/Editor/Tags/TagsModalContent.js
@@ -0,0 +1,187 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds, sizes } from 'Helpers/Props';
+import Label from 'Components/Label';
+import Button from 'Components/Link/Button';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import styles from './TagsModalContent.css';
+
+class TagsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ tags: [],
+ applyTags: 'add'
+ };
+ }
+
+ //
+ // Lifecycle
+
+ onInputChange = ({ name, value }) => {
+ this.setState({ [name]: value });
+ }
+
+ onApplyTagsPress = () => {
+ const {
+ tags,
+ applyTags
+ } = this.state;
+
+ this.props.onApplyTagsPress(tags, applyTags);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ artistTags,
+ tagList,
+ onModalClose
+ } = this.props;
+
+ const {
+ tags,
+ applyTags
+ } = this.state;
+
+ const applyTagsOptions = [
+ { key: 'add', value: 'Add' },
+ { key: 'remove', value: 'Remove' },
+ { key: 'replace', value: 'Replace' }
+ ];
+
+ return (
+
+
+ Tags
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ Apply
+
+
+
+ );
+ }
+}
+
+TagsModalContent.propTypes = {
+ artistTags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onApplyTagsPress: PropTypes.func.isRequired
+};
+
+export default TagsModalContent;
diff --git a/frontend/src/Artist/Editor/Tags/TagsModalContentConnector.js b/frontend/src/Artist/Editor/Tags/TagsModalContentConnector.js
new file mode 100644
index 000000000..6741e8b5c
--- /dev/null
+++ b/frontend/src/Artist/Editor/Tags/TagsModalContentConnector.js
@@ -0,0 +1,36 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import TagsModalContent from './TagsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { artistIds }) => artistIds,
+ createAllArtistSelector(),
+ createTagsSelector(),
+ (artistIds, allArtists, tagList) => {
+ const artist = _.intersectionWith(allArtists, artistIds, (s, id) => {
+ return s.id === id;
+ });
+
+ const artistTags = _.uniq(_.concat(..._.map(artist, 'tags')));
+
+ return {
+ artistTags,
+ tagList
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onAction() {
+ // Do something
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(TagsModalContent);
diff --git a/frontend/src/Artist/Index/ArtistIndex.css b/frontend/src/Artist/Index/ArtistIndex.css
new file mode 100644
index 000000000..9e67ef538
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndex.css
@@ -0,0 +1,66 @@
+.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);
+}
+
+.bannersInnerContentBody {
+ composes: innerContentBody from 'Components/Page/PageContentBody.css';
+
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+
+ /* 5px less padding than normal to handle poster's 5px margin */
+ padding: calc($pageContentBodyPadding - 5px);
+}
+
+.tableInnerContentBody {
+ composes: innerContentBody from 'Components/Page/PageContentBody.css';
+
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+.contentBodyContainer {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .pageContentBodyWrapper {
+ flex-basis: auto;
+ }
+
+ .contentBody {
+ flex-basis: 1px;
+ }
+
+ .postersInnerContentBody {
+ padding: calc($pageContentBodyPaddingSmallScreen - 5px);
+ }
+
+ .bannersInnerContentBody {
+ padding: calc($pageContentBodyPaddingSmallScreen - 5px);
+ }
+}
diff --git a/frontend/src/Artist/Index/ArtistIndex.js b/frontend/src/Artist/Index/ArtistIndex.js
new file mode 100644
index 000000000..827fe2499
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndex.js
@@ -0,0 +1,389 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import { align, icons, sortDirections } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageJumpBar from 'Components/Page/PageJumpBar';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import NoArtist from 'Artist/NoArtist';
+import ArtistIndexTableConnector from './Table/ArtistIndexTableConnector';
+import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal';
+import ArtistIndexPostersConnector from './Posters/ArtistIndexPostersConnector';
+import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal';
+import ArtistIndexBannersConnector from './Banners/ArtistIndexBannersConnector';
+import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal';
+import ArtistIndexOverviewsConnector from './Overview/ArtistIndexOverviewsConnector';
+import ArtistIndexFooter from './ArtistIndexFooter';
+import ArtistIndexFilterMenu from './Menus/ArtistIndexFilterMenu';
+import ArtistIndexSortMenu from './Menus/ArtistIndexSortMenu';
+import ArtistIndexViewMenu from './Menus/ArtistIndexViewMenu';
+import styles from './ArtistIndex.css';
+
+function getViewComponent(view) {
+ if (view === 'posters') {
+ return ArtistIndexPostersConnector;
+ }
+
+ if (view === 'banners') {
+ return ArtistIndexBannersConnector;
+ }
+
+ if (view === 'overview') {
+ return ArtistIndexOverviewsConnector;
+ }
+
+ return ArtistIndexTableConnector;
+}
+
+class ArtistIndex extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._viewComponent = null;
+
+ this.state = {
+ contentBody: null,
+ jumpBarItems: [],
+ isPosterOptionsModalOpen: false,
+ isBannerOptionsModalOpen: false,
+ isOverviewOptionsModalOpen: false,
+ isRendered: false
+ };
+ }
+
+ componentDidMount() {
+ this.setJumpBarItems();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ sortKey,
+ sortDirection
+ } = this.props;
+
+ if (
+ hasDifferentItems(prevProps.items, items) ||
+ sortKey !== prevProps.sortKey ||
+ sortDirection !== prevProps.sortDirection
+ ) {
+ this.setJumpBarItems();
+ }
+ }
+
+ //
+ // Control
+
+ setContentBodyRef = (ref) => {
+ this.setState({ contentBody: ref });
+ }
+
+ setViewComponentRef = (ref) => {
+ this._viewComponent = ref;
+ }
+
+ setJumpBarItems() {
+ const {
+ items,
+ sortKey,
+ sortDirection
+ } = this.props;
+
+ // Reset if not sorting by sortName
+ if (sortKey !== 'sortName') {
+ this.setState({ jumpBarItems: [] });
+ return;
+ }
+
+ const characters = _.reduce(items, (acc, item) => {
+ const firstCharacter = item.sortName.charAt(0);
+
+ if (isNaN(firstCharacter)) {
+ acc.push(firstCharacter);
+ } else {
+ acc.push('#');
+ }
+
+ return acc;
+ }, []).sort();
+
+ // Reverse if sorting descending
+ if (sortDirection === sortDirections.DESCENDING) {
+ characters.reverse();
+ }
+
+ this.setState({ jumpBarItems: _.sortedUniq(characters) });
+ }
+
+ //
+ // Listeners
+
+ onPosterOptionsPress = () => {
+ this.setState({ isPosterOptionsModalOpen: true });
+ }
+
+ onPosterOptionsModalClose = () => {
+ this.setState({ isPosterOptionsModalOpen: false });
+ }
+
+ onBannerOptionsPress = () => {
+ this.setState({ isBannerOptionsModalOpen: true });
+ }
+
+ onBannerOptionsModalClose = () => {
+ this.setState({ isBannerOptionsModalOpen: false });
+ }
+
+ onOverviewOptionsPress = () => {
+ this.setState({ isOverviewOptionsModalOpen: true });
+ }
+
+ onOverviewOptionsModalClose = () => {
+ this.setState({ isOverviewOptionsModalOpen: false });
+ }
+
+ onJumpBarItemPress = (item) => {
+ const viewComponent = this._viewComponent.getWrappedInstance();
+ viewComponent.scrollToFirstCharacter(item);
+ }
+
+ onRender = () => {
+ this.setState({ isRendered: true }, () => {
+ const {
+ scrollTop,
+ isSmallScreen
+ } = this.props;
+
+ if (isSmallScreen) {
+ // Seems to result in the view being off by 125px (distance to the top of the page)
+ // document.documentElement.scrollTop = document.body.scrollTop = scrollTop;
+
+ // This works, but then jumps another 1px after scrolling
+ document.documentElement.scrollTop = scrollTop;
+ }
+ });
+ }
+
+ onScroll = ({ scrollTop }) => {
+ this.props.onScroll({ scrollTop });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ filterKey,
+ filterValue,
+ sortKey,
+ sortDirection,
+ view,
+ isRefreshingArtist,
+ isRssSyncExecuting,
+ scrollTop,
+ onSortSelect,
+ onFilterSelect,
+ onViewSelect,
+ onRefreshArtistPress,
+ onRssSyncPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ contentBody,
+ jumpBarItems,
+ isPosterOptionsModalOpen,
+ isBannerOptionsModalOpen,
+ isOverviewOptionsModalOpen,
+ isRendered
+ } = this.state;
+
+ const ViewComponent = getViewComponent(view);
+ const isLoaded = !error && isPopulated && !!items.length && contentBody;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {
+ view === 'posters' &&
+
+ }
+
+ {
+ view === 'banners' &&
+
+ }
+
+ {
+ view === 'overview' &&
+
+ }
+
+ {
+ (view === 'posters' || view === 'banners' || view === 'overview') &&
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load artist
+ }
+
+ {
+ isLoaded &&
+
+ }
+
+ {
+ !error && isPopulated && !items.length &&
+
+ }
+
+
+ {
+ isLoaded && !!jumpBarItems.length &&
+
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndex.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ view: PropTypes.string.isRequired,
+ isRefreshingArtist: PropTypes.bool.isRequired,
+ isRssSyncExecuting: PropTypes.bool.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onSortSelect: PropTypes.func.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onViewSelect: PropTypes.func.isRequired,
+ onRefreshArtistPress: PropTypes.func.isRequired,
+ onRssSyncPress: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default ArtistIndex;
diff --git a/frontend/src/Artist/Index/ArtistIndexConnector.js b/frontend/src/Artist/Index/ArtistIndexConnector.js
new file mode 100644
index 000000000..688852942
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexConnector.js
@@ -0,0 +1,166 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import dimensions from 'Styles/Variables/dimensions';
+import createCommandSelector from 'Store/Selectors/createCommandSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import { fetchArtist } from 'Store/Actions/artistActions';
+import scrollPositions from 'Store/scrollPositions';
+import { setArtistSort, setArtistFilter, setArtistView } from 'Store/Actions/artistIndexActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import withScrollPosition from 'Components/withScrollPosition';
+import ArtistIndex from './ArtistIndex';
+
+const POSTERS_PADDING = 15;
+const POSTERS_PADDING_SMALL_SCREEN = 5;
+const BANNERS_PADDING = 15;
+const BANNERS_PADDING_SMALL_SCREEN = 5;
+const TABLE_PADDING = parseInt(dimensions.pageContentBodyPadding);
+const TABLE_PADDING_SMALL_SCREEN = parseInt(dimensions.pageContentBodyPaddingSmallScreen);
+
+// If the scrollTop is greater than zero it needs to be offset
+// by the padding so when it is set initially so it is correct
+// after React Virtualized takes the padding into account.
+
+function getScrollTop(view, scrollTop, isSmallScreen) {
+ if (scrollTop === 0) {
+ return 0;
+ }
+
+ let padding = isSmallScreen ? TABLE_PADDING_SMALL_SCREEN : TABLE_PADDING;
+
+ if (view === 'posters') {
+ padding = isSmallScreen ? POSTERS_PADDING_SMALL_SCREEN : POSTERS_PADDING;
+ }
+
+ if (view === 'banners') {
+ padding = isSmallScreen ? BANNERS_PADDING_SMALL_SCREEN : BANNERS_PADDING;
+ }
+
+ return scrollTop + padding;
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artist,
+ (state) => state.artistIndex,
+ createCommandSelector(commandNames.REFRESH_ARTIST),
+ createCommandSelector(commandNames.RSS_SYNC),
+ createDimensionsSelector(),
+ (artist, artistIndex, isRefreshingArtist, isRssSyncExecuting, dimensionsState) => {
+ return {
+ isRefreshingArtist,
+ isRssSyncExecuting,
+ isSmallScreen: dimensionsState.isSmallScreen,
+ ...artist,
+ ...artistIndex
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchArtist,
+ setArtistSort,
+ setArtistFilter,
+ setArtistView,
+ executeCommand
+};
+
+class ArtistIndexConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ view,
+ scrollTop,
+ isSmallScreen
+ } = props;
+
+ this.state = {
+ scrollTop: getScrollTop(view, scrollTop, isSmallScreen)
+ };
+ }
+
+ componentDidMount() {
+ this.props.fetchArtist();
+ }
+
+ //
+ // Listeners
+
+ onSortSelect = (sortKey) => {
+ this.props.setArtistSort({ sortKey });
+ }
+
+ onFilterSelect = (filterKey, filterValue, filterType) => {
+ this.props.setArtistFilter({ filterKey, filterValue, filterType });
+ }
+
+ onViewSelect = (view) => {
+ // Reset the scroll position before changing the view
+ this.setState({ scrollTop: 0 }, () => {
+ this.props.setArtistView({ view });
+ });
+ }
+
+ onScroll = ({ scrollTop }) => {
+ this.setState({
+ scrollTop
+ }, () => {
+ scrollPositions.artistIndex = scrollTop;
+ });
+ }
+
+ onRefreshArtistPress = () => {
+ this.props.executeCommand({
+ name: commandNames.REFRESH_ARTIST
+ });
+ }
+
+ onRssSyncPress = () => {
+ this.props.executeCommand({
+ name: commandNames.RSS_SYNC
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ArtistIndexConnector.propTypes = {
+ isSmallScreen: PropTypes.bool.isRequired,
+ view: PropTypes.string.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ fetchArtist: PropTypes.func.isRequired,
+ setArtistSort: PropTypes.func.isRequired,
+ setArtistFilter: PropTypes.func.isRequired,
+ setArtistView: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default withScrollPosition(
+ connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexConnector),
+ 'artistIndex'
+);
diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.css b/frontend/src/Artist/Index/ArtistIndexFooter.css
new file mode 100644
index 000000000..3aa369576
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexFooter.css
@@ -0,0 +1,66 @@
+.footer {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 20px;
+ font-size: $smallFontSize;
+}
+
+.legendItem {
+ display: flex;
+ margin-bottom: 4px;
+ line-height: 16px;
+}
+
+.legendItemColor {
+ margin-right: 8px;
+ width: 30px;
+ height: 16px;
+ border-radius: 4px;
+}
+
+.continuing {
+ composes: legendItemColor;
+
+ background-color: $primaryColor;
+}
+
+.ended {
+ composes: legendItemColor;
+
+ background-color: $successColor;
+}
+
+.missingMonitored {
+ composes: legendItemColor;
+
+ background-color: $dangerColor;
+}
+
+.missingUnmonitored {
+ composes: legendItemColor;
+
+ background-color: $warningColor;
+}
+
+.statistics {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+}
+
+@media (max-width: $breakpointLarge) {
+ .statistics {
+ display: block;
+ }
+}
+
+@media (max-width: $breakpointSmall) {
+ .footer {
+ display: block;
+ }
+
+ .statistics {
+ display: flex;
+ margin-top: 20px;
+ }
+}
diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.js b/frontend/src/Artist/Index/ArtistIndexFooter.js
new file mode 100644
index 000000000..c7610a079
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexFooter.js
@@ -0,0 +1,104 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import styles from './ArtistIndexFooter.css';
+
+function ArtistIndexFooter({ artist }) {
+ const count = artist.length;
+ let tracks = 0;
+ let trackFiles = 0;
+ let ended = 0;
+ let continuing = 0;
+ let monitored = 0;
+
+ artist.forEach((s) => {
+ tracks += s.trackCount || 0;
+ trackFiles += s.trackFileCount || 0;
+
+ if (s.status === 'ended') {
+ ended++;
+ } else {
+ continuing++;
+ }
+
+ if (s.monitored) {
+ monitored++;
+ }
+ });
+
+ return (
+
+
+
+
+
Continuing (All tracks downloaded)
+
+
+
+
+
Ended (All tracks downloaded)
+
+
+
+
+
Missing Tracks (Artist monitored)
+
+
+
+
+
Missing Tracks (Artist not monitored)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+ArtistIndexFooter.propTypes = {
+ artist: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default ArtistIndexFooter;
diff --git a/frontend/src/Artist/Index/ArtistIndexItemConnector.js b/frontend/src/Artist/Index/ArtistIndexItemConnector.js
new file mode 100644
index 000000000..216c3fbbe
--- /dev/null
+++ b/frontend/src/Artist/Index/ArtistIndexItemConnector.js
@@ -0,0 +1,77 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
+import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state, { albums }) => albums,
+ createQualityProfileSelector(),
+ createLanguageProfileSelector(),
+ createCommandsSelector(),
+ (artistId, albums, qualityProfile, languageProfile, commands) => {
+ const isRefreshingArtist = _.some(commands, (command) => {
+ return command.name === commandNames.REFRESH_ARTIST &&
+ command.body.artistId === artistId;
+ });
+
+ const latestAlbum = _.first(_.orderBy(albums, 'releaseDate', 'desc'));
+
+ return {
+ qualityProfile,
+ languageProfile,
+ latestAlbum,
+ isRefreshingArtist
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand
+};
+
+class ArtistIndexItemConnector extends Component {
+
+ //
+ // Listeners
+
+ onRefreshArtistPress = () => {
+ this.props.executeCommand({
+ name: commandNames.REFRESH_ARTIST,
+ artistId: this.props.id
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ component: ItemComponent,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+ArtistIndexItemConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ component: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexItemConnector);
diff --git a/frontend/src/Artist/Index/ArtistIndexPage.js b/frontend/src/Artist/Index/ArtistIndexPage.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css
new file mode 100644
index 000000000..a80a4c2f0
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css
@@ -0,0 +1,85 @@
+$hoverScale: 1.05;
+
+.container {
+ padding: 10px;
+}
+
+.content {
+ transition: all 200ms ease-in;
+
+ &:hover {
+ z-index: 2;
+ box-shadow: 0 0 12px $black;
+ transition: all 200ms ease-in;
+
+ .controls {
+ opacity: 0.9;
+ transition: opacity 200ms linear 150ms;
+ }
+ }
+}
+
+.bannerContainer {
+ position: relative;
+}
+
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ display: block;
+ background-color: $defaultColor;
+}
+
+.nextAiring {
+ background-color: $defaultColor;
+ color: $white;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.title {
+ @add-mixin truncate;
+
+ background-color: $defaultColor;
+ color: $white;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.ended {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 0;
+ height: 0;
+ border-width: 0 25px 25px 0;
+ border-style: solid;
+ border-color: transparent $dangerColor transparent transparent;
+ color: $white;
+}
+
+.controls {
+ position: absolute;
+ bottom: 10px;
+ left: 10px;
+ border-radius: 4px;
+ background-color: #216044;
+ color: $white;
+ font-size: $smallFontSize;
+ opacity: 0;
+ transition: opacity 0;
+}
+
+.action {
+ composes: button from 'Components/Link/IconButton.css';
+
+ &:hover {
+ color: #ccc;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .container {
+ padding: 5px;
+ }
+}
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js
new file mode 100644
index 000000000..45858ba59
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js
@@ -0,0 +1,231 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import ArtistBanner from 'Artist/ArtistBanner';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
+import ArtistIndexBannerInfo from './ArtistIndexBannerInfo';
+import styles from './ArtistIndexBanner.css';
+
+class ArtistIndexBanner extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditArtistPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditArtistModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteArtistPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ artistName,
+ monitored,
+ status,
+ nameSlug,
+ nextAiring,
+ trackCount,
+ trackFileCount,
+ images,
+ bannerWidth,
+ bannerHeight,
+ detailedProgressBar,
+ showTitle,
+ showQualityProfile,
+ qualityProfile,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat,
+ isRefreshingArtist,
+ onRefreshArtistPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ const link = `/artist/${nameSlug}`;
+
+ const elementStyle = {
+ width: `${bannerWidth}px`,
+ height: `${bannerHeight}px`
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ status === 'ended' &&
+
+ }
+
+
+
+
+
+
+
+
+ {
+ showTitle &&
+
+ {artistName}
+
+ }
+
+ {
+ showQualityProfile &&
+
+ {qualityProfile.name}
+
+ }
+
+
+ {
+ getRelativeDate(
+ nextAiring,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ }
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexBanner.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ nameSlug: PropTypes.string.isRequired,
+ nextAiring: PropTypes.string,
+ trackCount: PropTypes.number,
+ trackFileCount: PropTypes.number,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ bannerWidth: PropTypes.number.isRequired,
+ bannerHeight: PropTypes.number.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ showTitle: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ isRefreshingArtist: PropTypes.bool.isRequired,
+ onRefreshArtistPress: PropTypes.func.isRequired
+};
+
+ArtistIndexBanner.defaultProps = {
+ trackCount: 0,
+ trackFileCount: 0
+};
+
+export default ArtistIndexBanner;
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.css b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.css
new file mode 100644
index 000000000..cab3dec61
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.css
@@ -0,0 +1,6 @@
+.info {
+ background-color: $defaultColor;
+ color: $white;
+ text-align: center;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js
new file mode 100644
index 000000000..f641de0e1
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import styles from './ArtistIndexBannerInfo.css';
+
+function ArtistIndexBannerInfo(props) {
+ const {
+ qualityProfile,
+ showQualityProfile,
+ previousAiring,
+ added,
+ albumCount,
+ path,
+ sizeOnDisk,
+ sortKey,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ if (sortKey === 'qualityProfileId' && !showQualityProfile) {
+ return (
+
+ {qualityProfile.name}
+
+ );
+ }
+
+ if (sortKey === 'previousAiring' && previousAiring) {
+ return (
+
+ {
+ getRelativeDate(
+ previousAiring,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ }
+
+ );
+ }
+
+ if (sortKey === 'added' && added) {
+ const addedDate = getRelativeDate(
+ added,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: false
+ }
+ );
+
+ return (
+
+ {`Added ${addedDate}`}
+
+ );
+ }
+
+ if (sortKey === 'albumCount') {
+ let albums = '1 album';
+
+ if (albumCount === 0) {
+ albums = 'No albums';
+ } else if (albumCount > 1) {
+ albums = `${albumCount} albums`;
+ }
+
+ return (
+
+ {albums}
+
+ );
+ }
+
+ if (sortKey === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (sortKey === 'sizeOnDisk') {
+ return (
+
+ {formatBytes(sizeOnDisk)}
+
+ );
+ }
+
+ return null;
+}
+
+ArtistIndexBannerInfo.propTypes = {
+ qualityProfile: PropTypes.object.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ previousAiring: PropTypes.string,
+ added: PropTypes.string,
+ albumCount: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ sizeOnDisk: PropTypes.number,
+ sortKey: PropTypes.string.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default ArtistIndexBannerInfo;
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.css b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.css
new file mode 100644
index 000000000..9c6520fb5
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.css
@@ -0,0 +1,3 @@
+.grid {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js
new file mode 100644
index 000000000..986632ab4
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js
@@ -0,0 +1,327 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import Measure from 'react-measure';
+import { Grid, WindowScroller } from 'react-virtualized';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import dimensions from 'Styles/Variables/dimensions';
+import { sortDirections } from 'Helpers/Props';
+import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
+import ArtistIndexBanner from './ArtistIndexBanner';
+import styles from './ArtistIndexBanners.css';
+
+// container dimensions
+const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
+const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
+const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
+
+const additionalColumnCount = {
+ small: 3,
+ medium: 2,
+ large: 1
+};
+
+function calculateColumnWidth(width, bannerSize, isSmallScreen) {
+ const maxiumColumnWidth = isSmallScreen ? 344 : 364;
+ const columns = Math.floor(width / maxiumColumnWidth);
+ const remainder = width % maxiumColumnWidth;
+
+ if (remainder === 0 && bannerSize === 'large') {
+ return maxiumColumnWidth;
+ }
+
+ return Math.floor(width / (columns + additionalColumnCount[bannerSize]));
+}
+
+function calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions) {
+ const {
+ detailedProgressBar,
+ showTitle,
+ showQualityProfile
+ } = bannerOptions;
+
+ const nextAiringHeight = 19;
+
+ const heights = [
+ bannerHeight,
+ detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
+ nextAiringHeight,
+ isSmallScreen ? columnPaddingSmallScreen : columnPadding
+ ];
+
+ if (showTitle) {
+ heights.push(19);
+ }
+
+ if (showQualityProfile) {
+ heights.push(19);
+ }
+
+ switch (sortKey) {
+ case 'seasons':
+ case 'previousAiring':
+ case 'added':
+ case 'path':
+ case 'sizeOnDisk':
+ heights.push(19);
+ break;
+ case 'qualityProfileId':
+ if (!showQualityProfile) {
+ heights.push(19);
+ }
+ break;
+ default:
+ // No need to add a height of 0
+ }
+
+ return heights.reduce((acc, height) => acc + height, 0);
+}
+
+function calculateHeight(bannerWidth) {
+ return Math.ceil((88/476) * bannerWidth);
+}
+
+class ArtistIndexBanners extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ width: 0,
+ columnWidth: 364,
+ columnCount: 1,
+ bannerWidth: 476,
+ bannerHeight: 88,
+ rowHeight: calculateRowHeight(88, null, props.isSmallScreen, {})
+ };
+
+ this._isInitialized = false;
+ this._grid = null;
+ }
+
+ componentDidMount() {
+ this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ filterKey,
+ filterValue,
+ sortKey,
+ sortDirection,
+ bannerOptions
+ } = this.props;
+
+ const itemsChanged = hasDifferentItems(prevProps.items, items);
+
+ if (
+ prevProps.sortKey !== sortKey ||
+ prevProps.bannerOptions !== bannerOptions ||
+ itemsChanged
+ ) {
+ this.calculateGrid();
+ }
+
+ if (
+ prevProps.filterKey !== filterKey ||
+ prevProps.filterValue !== filterValue ||
+ prevProps.sortKey !== sortKey ||
+ prevProps.sortDirection !== sortDirection ||
+ itemsChanged
+ ) {
+ this._grid.recomputeGridSize();
+ }
+ }
+
+ //
+ // Control
+
+ scrollToFirstCharacter(character) {
+ const items = this.props.items;
+ const {
+ columnCount,
+ rowHeight
+ } = this.state;
+
+ const index = _.findIndex(items, (item) => {
+ const firstCharacter = item.sortName.charAt(0);
+
+ if (character === '#') {
+ return !isNaN(firstCharacter);
+ }
+
+ return firstCharacter === character;
+ });
+
+ if (index != null) {
+ const row = Math.floor(index / columnCount);
+ const scrollTop = rowHeight * row;
+
+ this.props.onScroll({ scrollTop });
+ }
+ }
+
+ setGridRef = (ref) => {
+ this._grid = ref;
+ }
+
+ calculateGrid = (width = this.state.width, isSmallScreen) => {
+ const {
+ sortKey,
+ bannerOptions
+ } = this.props;
+
+ const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
+ const columnWidth = calculateColumnWidth(width, bannerOptions.size, isSmallScreen);
+ const columnCount = Math.max(Math.floor(width / columnWidth), 1);
+ const bannerWidth = columnWidth - padding;
+ const bannerHeight = calculateHeight(bannerWidth);
+ const rowHeight = calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions);
+
+ this.setState({
+ width,
+ columnWidth,
+ columnCount,
+ bannerWidth,
+ bannerHeight,
+ rowHeight
+ });
+ }
+
+ cellRenderer = ({ key, rowIndex, columnIndex, style }) => {
+ const {
+ items,
+ sortKey,
+ bannerOptions,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = this.props;
+
+ const {
+ bannerWidth,
+ bannerHeight,
+ columnCount
+ } = this.state;
+
+ const {
+ detailedProgressBar,
+ showTitle,
+ showQualityProfile
+ } = bannerOptions;
+
+ const artist = items[rowIndex * columnCount + columnIndex];
+
+ if (!artist) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.calculateGrid(width, this.props.isSmallScreen);
+ }
+
+ onSectionRendered = () => {
+ if (!this._isInitialized && this._contentBodyNode) {
+ this.props.onRender();
+ this._isInitialized = true;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ scrollTop,
+ isSmallScreen,
+ onScroll
+ } = this.props;
+
+ const {
+ width,
+ columnWidth,
+ columnCount,
+ rowHeight
+ } = this.state;
+
+ const rowCount = Math.ceil(items.length / columnCount);
+
+ return (
+
+
+ {({ height, isScrolling }) => {
+ return (
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+ArtistIndexBanners.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ bannerOptions: PropTypes.object.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ contentBody: PropTypes.object.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default ArtistIndexBanners;
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js
new file mode 100644
index 000000000..21384039e
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js
@@ -0,0 +1,33 @@
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import ArtistIndexBanners from './ArtistIndexBanners';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistIndex.bannerOptions,
+ createClientSideCollectionSelector(),
+ createUISettingsSelector(),
+ createDimensionsSelector(),
+ (bannerOptions, artist, uiSettings, dimensions) => {
+ return {
+ bannerOptions,
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ isSmallScreen: dimensions.isSmallScreen,
+ ...artist
+ };
+ }
+ );
+}
+
+export default connectSection(
+ createMapStateToProps,
+ undefined,
+ undefined,
+ { withRef: true },
+ { section: 'artist', uiSection: 'artistIndex' }
+)(ArtistIndexBanners);
diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js
new file mode 100644
index 000000000..34c8abfcf
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import ArtistIndexBannerOptionsModalContentConnector from './ArtistIndexBannerOptionsModalContentConnector';
+
+function ArtistIndexBannerOptionsModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+ArtistIndexBannerOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistIndexBannerOptionsModal;
diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js
new file mode 100644
index 000000000..d320acea5
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js
@@ -0,0 +1,173 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+const bannerSizeOptions = [
+ { key: 'small', value: 'Small' },
+ { key: 'medium', value: 'Medium' },
+ { key: 'large', value: 'Large' }
+];
+
+class ArtistIndexBannerOptionsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ detailedProgressBar: props.detailedProgressBar,
+ size: props.size,
+ showTitle: props.showTitle,
+ showQualityProfile: props.showQualityProfile
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ detailedProgressBar,
+ size,
+ showTitle,
+ showQualityProfile
+ } = this.props;
+
+ const state = {};
+
+ if (detailedProgressBar !== prevProps.detailedProgressBar) {
+ state.detailedProgressBar = detailedProgressBar;
+ }
+
+ if (size !== prevProps.size) {
+ state.size = size;
+ }
+
+ if (showTitle !== prevProps.showTitle) {
+ state.showTitle = showTitle;
+ }
+
+ if (showQualityProfile !== prevProps.showQualityProfile) {
+ state.showQualityProfile = showQualityProfile;
+ }
+
+ if (!_.isEmpty(state)) {
+ this.setState(state);
+ }
+ }
+
+ //
+ // Listeners
+
+ onChangeOption = ({ name, value }) => {
+ this.setState({
+ [name]: value
+ }, () => {
+ this.props.onChangeOption({ [name]: value });
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ onModalClose
+ } = this.props;
+
+ const {
+ detailedProgressBar,
+ size,
+ showTitle,
+ showQualityProfile
+ } = this.state;
+
+ return (
+
+
+ Options
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+ArtistIndexBannerOptionsModalContent.propTypes = {
+ size: PropTypes.string.isRequired,
+ showTitle: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ onChangeOption: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistIndexBannerOptionsModalContent;
diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js
new file mode 100644
index 000000000..0ea742781
--- /dev/null
+++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setArtistBannerOption } from 'Store/Actions/artistIndexActions';
+import ArtistIndexBannerOptionsModalContent from './ArtistIndexBannerOptionsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistIndex,
+ (artistIndex) => {
+ return artistIndex.bannerOptions;
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onChangeOption(payload) {
+ dispatch(setArtistBannerOption(payload));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexBannerOptionsModalContent);
diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js
new file mode 100644
index 000000000..5b78ce44d
--- /dev/null
+++ b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align } from 'Helpers/Props';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+
+function ArtistIndexFilterMenu(props) {
+ const {
+ filterKey,
+ filterValue,
+ onFilterSelect
+ } = props;
+
+ return (
+
+
+
+ All
+
+
+
+ Monitored Only
+
+
+
+ Continuing Only
+
+
+
+ Ended Only
+
+
+
+ Missing Albums
+
+
+
+ );
+}
+
+ArtistIndexFilterMenu.propTypes = {
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ onFilterSelect: PropTypes.func.isRequired
+};
+
+export default ArtistIndexFilterMenu;
diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js
new file mode 100644
index 000000000..c4c70e0d2
--- /dev/null
+++ b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js
@@ -0,0 +1,145 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align, sortDirections } from 'Helpers/Props';
+import SortMenu from 'Components/Menu/SortMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import SortMenuItem from 'Components/Menu/SortMenuItem';
+
+function ArtistIndexSortMenu(props) {
+ const {
+ sortKey,
+ sortDirection,
+ onSortSelect
+ } = props;
+
+ return (
+
+
+
+ Name
+
+
+
+ Type
+
+
+
+ Quality Profile
+
+
+
+ Language Profile
+
+
+
+ Next Airing
+
+
+
+ Previous Airing
+
+
+
+ Added
+
+
+
+ Albums
+
+
+
+ Tracks
+
+
+
+ Track Count
+
+
+
+ Latest Album
+
+
+
+ Path
+
+
+
+ Size on Disk
+
+
+
+ );
+}
+
+ArtistIndexSortMenu.propTypes = {
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ onSortSelect: PropTypes.func.isRequired
+};
+
+export default ArtistIndexSortMenu;
diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js
new file mode 100644
index 000000000..b227a96b6
--- /dev/null
+++ b/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align } from 'Helpers/Props';
+import ViewMenu from 'Components/Menu/ViewMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import ViewMenuItem from 'Components/Menu/ViewMenuItem';
+
+function ArtistIndexViewMenu(props) {
+ const {
+ view,
+ onViewSelect
+ } = props;
+
+ return (
+
+
+
+ Table
+
+
+
+ Posters
+
+
+
+ Banners
+
+
+
+ Overview
+
+
+
+ );
+}
+
+ArtistIndexViewMenu.propTypes = {
+ view: PropTypes.string.isRequired,
+ onViewSelect: PropTypes.func.isRequired
+};
+
+export default ArtistIndexViewMenu;
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css
new file mode 100644
index 000000000..cb1b3c0fb
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css
@@ -0,0 +1,91 @@
+$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 {
+ flex: 1 0 1px;
+ overflow: hidden;
+ padding-left: 10px;
+}
+
+.titleRow {
+ display: flex;
+ justify-content: space-between;
+ 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;
+}
+
+.overview {
+ composes: link;
+
+ flex: 0 1 1000px;
+ overflow: hidden;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .overview {
+ display: none;
+ }
+}
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js
new file mode 100644
index 000000000..b1567fcff
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js
@@ -0,0 +1,252 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Truncate from 'react-truncate';
+import { icons } from 'Helpers/Props';
+import dimensions from 'Styles/Variables/dimensions';
+import fonts from 'Styles/Variables/fonts';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import ArtistPoster from 'Artist/ArtistPoster';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
+import ArtistIndexOverviewInfo from './ArtistIndexOverviewInfo';
+import styles from './ArtistIndexOverview.css';
+
+const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
+const defaultFontSize = parseInt(fonts.defaultFontSize);
+const lineHeight = parseFloat(fonts.lineHeight);
+
+function calculateHeight(rowHeight, isSmallScreen) {
+ let height = rowHeight - 45;
+
+ if (isSmallScreen) {
+ height -= columnPaddingSmallScreen;
+ } else {
+ height -= columnPadding;
+ }
+
+ return height;
+}
+
+class ArtistIndexOverview extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditArtistPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditArtistModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteArtistPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ artistName,
+ overview,
+ monitored,
+ status,
+ nameSlug,
+ nextAiring,
+ trackCount,
+ trackFileCount,
+ images,
+ posterWidth,
+ posterHeight,
+ qualityProfile,
+ overviewOptions,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat,
+ rowHeight,
+ isSmallScreen,
+ isRefreshingArtist,
+ onRefreshArtistPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ const link = `/artist/${nameSlug}`;
+
+ const elementStyle = {
+ width: `${posterWidth}px`,
+ height: `${posterHeight}px`
+ };
+
+ const height = calculateHeight(rowHeight, isSmallScreen);
+
+ return (
+
+
+
+
+ {
+ status === 'ended' &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ {artistName}
+
+
+
+
+
+
+
+
+
+
+
+
+ {overview}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexOverview.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ overview: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ nameSlug: PropTypes.string.isRequired,
+ nextAiring: PropTypes.string,
+ trackCount: PropTypes.number,
+ trackFileCount: PropTypes.number,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ posterHeight: PropTypes.number.isRequired,
+ rowHeight: PropTypes.number.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ overviewOptions: PropTypes.object.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ isRefreshingArtist: PropTypes.bool.isRequired,
+ onRefreshArtistPress: PropTypes.func.isRequired
+};
+
+ArtistIndexOverview.defaultProps = {
+ trackCount: 0,
+ trackFileCount: 0
+};
+
+export default ArtistIndexOverview;
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.css
new file mode 100644
index 000000000..aa25a676c
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.css
@@ -0,0 +1,23 @@
+.infos {
+ display: flex;
+ flex: 0 0 250px;
+ flex-direction: column;
+ margin-left: 10px;
+}
+
+.info {
+ flex: 0 0 $artistIndexOverviewInfoRowHeight;
+ margin: 2px 0;
+}
+
+.icon {
+ margin-right: 5px;
+ width: 25px;
+ text-align: center;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .infos {
+ margin-left: 0;
+ }
+}
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js
new file mode 100644
index 000000000..31a114e70
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js
@@ -0,0 +1,193 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons } from 'Helpers/Props';
+import dimensions from 'Styles/Variables/dimensions';
+import Icon from 'Components/Icon';
+import styles from './ArtistIndexOverviewInfo.css';
+
+const infoRowHeight = parseInt(dimensions.artistIndexOverviewInfoRowHeight);
+
+function isVisible(name, show, value, sortKey, index) {
+ if (value == null) {
+ return false;
+ }
+
+ return show || sortKey === name;
+}
+
+function ArtistIndexOverviewInfo(props) {
+ const {
+ height,
+ showQualityProfile,
+ showAdded,
+ showAlbumCount,
+ showPath,
+ showSizeOnDisk,
+ nextAiring,
+ qualityProfile,
+ added,
+ albumCount,
+ path,
+ sizeOnDisk,
+ sortKey,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ let albums = '1 album';
+
+ if (albumCount === 0) {
+ albums = 'No albums';
+ } else if (albumCount > 1) {
+ albums = `${albumCount} albums`;
+ }
+
+ const maxRows = Math.floor(height / (infoRowHeight + 4));
+
+ return (
+
+ {
+ !!nextAiring &&
+
+
+
+ {
+ getRelativeDate(
+ nextAiring,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ }
+
+ }
+
+ {
+ isVisible('qualityProfileId', showQualityProfile, qualityProfile, sortKey) && maxRows > 1 &&
+
+
+
+ {qualityProfile.name}
+
+ }
+
+ {
+ isVisible('added', showAdded, added, sortKey) && maxRows > 2 &&
+
+
+
+ {
+ getRelativeDate(
+ added,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ }
+
+ }
+
+ {
+ isVisible('albumCount', showAlbumCount, albumCount, sortKey) && maxRows > 3 &&
+
+
+
+ {albums}
+
+ }
+
+ {
+ isVisible('path', showPath, path, sortKey) && maxRows > 4 &&
+
+
+
+ {path}
+
+ }
+
+ {
+ isVisible('sizeOnDisk', showSizeOnDisk, sizeOnDisk, sortKey) && maxRows > 5 &&
+
+
+
+ {formatBytes(sizeOnDisk)}
+
+ }
+
+
+ );
+}
+
+ArtistIndexOverviewInfo.propTypes = {
+ height: PropTypes.number.isRequired,
+ showNetwork: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ showAdded: PropTypes.bool.isRequired,
+ showAlbumCount: PropTypes.bool.isRequired,
+ showPath: PropTypes.bool.isRequired,
+ showSizeOnDisk: PropTypes.bool.isRequired,
+ nextAiring: PropTypes.string,
+ qualityProfile: PropTypes.object.isRequired,
+ previousAiring: PropTypes.string,
+ added: PropTypes.string,
+ albumCount: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ sizeOnDisk: PropTypes.number,
+ sortKey: PropTypes.string.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default ArtistIndexOverviewInfo;
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.css
new file mode 100644
index 000000000..9c6520fb5
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.css
@@ -0,0 +1,3 @@
+.grid {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js
new file mode 100644
index 000000000..801a9d35b
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js
@@ -0,0 +1,278 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import Measure from 'react-measure';
+import { Grid, WindowScroller } from 'react-virtualized';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import dimensions from 'Styles/Variables/dimensions';
+import { sortDirections } from 'Helpers/Props';
+import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
+import ArtistIndexOverview from './ArtistIndexOverview';
+import styles from './ArtistIndexOverviews.css';
+
+// Poster container dimensions
+const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
+const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
+const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
+
+function calculatePosterWidth(posterSize, isSmallScreen) {
+ const maxiumPosterWidth = isSmallScreen ? 192 : 202;
+
+ if (posterSize === 'large') {
+ return maxiumPosterWidth;
+ }
+
+ if (posterSize === 'medium') {
+ return Math.floor(maxiumPosterWidth * 0.75);
+ }
+
+ return Math.floor(maxiumPosterWidth * 0.5);
+}
+
+function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) {
+ const {
+ detailedProgressBar
+ } = overviewOptions;
+
+ const heights = [
+ posterHeight,
+ detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
+ isSmallScreen ? columnPaddingSmallScreen : columnPadding
+ ];
+
+ return heights.reduce((acc, height) => acc + height, 0);
+}
+
+function calculatePosterHeight(posterWidth) {
+ return posterWidth;
+}
+
+class ArtistIndexOverviews extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ width: 0,
+ columnCount: 1,
+ posterWidth: 238,
+ posterHeight: 238,
+ rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {})
+ };
+
+ this._isInitialized = false;
+ this._grid = null;
+ }
+
+ componentDidMount() {
+ this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ filterKey,
+ filterValue,
+ sortKey,
+ sortDirection,
+ overviewOptions
+ } = 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.filterKey !== filterKey ||
+ prevProps.filterValue !== filterValue ||
+ prevProps.sortKey !== sortKey ||
+ prevProps.sortDirection !== sortDirection ||
+ itemsChanged ||
+ overviewOptionsChanged
+ ) {
+ this._grid.recomputeGridSize();
+ }
+ }
+
+ //
+ // Control
+
+ scrollToFirstCharacter(character) {
+ const items = this.props.items;
+ const {
+ rowHeight
+ } = this.state;
+
+ const index = _.findIndex(items, (item) => {
+ const firstCharacter = item.sortTitle.charAt(0);
+
+ if (character === '#') {
+ return !isNaN(firstCharacter);
+ }
+
+ return firstCharacter === character;
+ });
+
+ if (index != null) {
+ const 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,
+ timeFormat,
+ isSmallScreen
+ } = this.props;
+
+ const {
+ posterWidth,
+ posterHeight,
+ rowHeight
+ } = this.state;
+
+ const artist = items[rowIndex];
+
+ if (!artist) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.calculateGrid(width, this.props.isSmallScreen);
+ }
+
+ onSectionRendered = () => {
+ if (!this._isInitialized && this._contentBodyNode) {
+ this.props.onRender();
+ this._isInitialized = true;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ scrollTop,
+ isSmallScreen,
+ onScroll
+ } = this.props;
+
+ const {
+ width,
+ rowHeight
+ } = this.state;
+
+ return (
+
+
+ {({ height, isScrolling }) => {
+ return (
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+ArtistIndexOverviews.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ overviewOptions: PropTypes.object.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ contentBody: PropTypes.object.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default ArtistIndexOverviews;
diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js
new file mode 100644
index 000000000..a2075416f
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js
@@ -0,0 +1,33 @@
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import ArtistIndexOverviews from './ArtistIndexOverviews';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistIndex.overviewOptions,
+ createClientSideCollectionSelector(),
+ createUISettingsSelector(),
+ createDimensionsSelector(),
+ (overviewOptions, artist, uiSettings, dimensions) => {
+ return {
+ overviewOptions,
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ isSmallScreen: dimensions.isSmallScreen,
+ ...artist
+ };
+ }
+ );
+}
+
+export default connectSection(
+ createMapStateToProps,
+ undefined,
+ undefined,
+ { withRef: true },
+ { section: 'artist', uiSection: 'artistIndex' }
+)(ArtistIndexOverviews);
diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js
new file mode 100644
index 000000000..9ca575185
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import ArtistIndexOverviewOptionsModalContentConnector from './ArtistIndexOverviewOptionsModalContentConnector';
+
+function ArtistIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+ArtistIndexOverviewOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistIndexOverviewOptionsModal;
diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js
new file mode 100644
index 000000000..b5bf02b45
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js
@@ -0,0 +1,247 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+const posterSizeOptions = [
+ { key: 'small', value: 'Small' },
+ { key: 'medium', value: 'Medium' },
+ { key: 'large', value: 'Large' }
+];
+
+class ArtistIndexOverviewOptionsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ detailedProgressBar: props.detailedProgressBar,
+ size: props.size,
+ showQualityProfile: props.showQualityProfile,
+ showPreviousAiring: props.showPreviousAiring,
+ showAdded: props.showAdded,
+ showAlbumCount: props.showAlbumCount,
+ showPath: props.showPath,
+ showSizeOnDisk: props.showSizeOnDisk
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ detailedProgressBar,
+ size,
+ showQualityProfile,
+ showPreviousAiring,
+ showAdded,
+ showAlbumCount,
+ showPath,
+ showSizeOnDisk
+ } = this.props;
+
+ const state = {};
+
+ if (detailedProgressBar !== prevProps.detailedProgressBar) {
+ state.detailedProgressBar = detailedProgressBar;
+ }
+
+ if (size !== prevProps.size) {
+ state.size = size;
+ }
+
+ if (showQualityProfile !== prevProps.showQualityProfile) {
+ state.showQualityProfile = showQualityProfile;
+ }
+
+ if (showPreviousAiring !== prevProps.showPreviousAiring) {
+ state.showPreviousAiring = showPreviousAiring;
+ }
+
+ if (showAdded !== prevProps.showAdded) {
+ state.showAdded = showAdded;
+ }
+
+ if (showAlbumCount !== prevProps.showAlbumCount) {
+ state.showAlbumCount = showAlbumCount;
+ }
+
+ if (showPath !== prevProps.showPath) {
+ state.showPath = showPath;
+ }
+
+ if (showSizeOnDisk !== prevProps.showSizeOnDisk) {
+ state.showSizeOnDisk = showSizeOnDisk;
+ }
+
+ if (!_.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,
+ showQualityProfile,
+ showPreviousAiring,
+ showAdded,
+ showAlbumCount,
+ showPath,
+ showSizeOnDisk
+ } = this.state;
+
+ return (
+
+
+ Overview Options
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+ArtistIndexOverviewOptionsModalContent.propTypes = {
+ size: PropTypes.string.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ showPreviousAiring: PropTypes.bool.isRequired,
+ showAdded: PropTypes.bool.isRequired,
+ showAlbumCount: PropTypes.bool.isRequired,
+ showPath: PropTypes.bool.isRequired,
+ showSizeOnDisk: PropTypes.bool.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ onChangeOverviewOption: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistIndexOverviewOptionsModalContent;
diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js
new file mode 100644
index 000000000..70c30dba6
--- /dev/null
+++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setArtistOverviewOption } from 'Store/Actions/artistIndexActions';
+import ArtistIndexOverviewOptionsModalContent from './ArtistIndexOverviewOptionsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistIndex,
+ (artistIndex) => {
+ return artistIndex.overviewOptions;
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onChangeOverviewOption(payload) {
+ dispatch(setArtistOverviewOption(payload));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexOverviewOptionsModalContent);
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css
new file mode 100644
index 000000000..c687874d4
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.css
@@ -0,0 +1,85 @@
+$hoverScale: 1.05;
+
+.container {
+ padding: 10px;
+}
+
+.content {
+ transition: all 200ms ease-in;
+
+ &:hover {
+ z-index: 2;
+ box-shadow: 0 0 12px $black;
+ transition: all 200ms ease-in;
+
+ .controls {
+ opacity: 0.9;
+ transition: opacity 200ms linear 150ms;
+ }
+ }
+}
+
+.posterContainer {
+ position: relative;
+}
+
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ display: block;
+ background-color: $defaultColor;
+}
+
+.nextAiring {
+ background-color: $defaultColor;
+ color: $white;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.title {
+ @add-mixin truncate;
+
+ background-color: $defaultColor;
+ color: $white;
+ text-align: center;
+ font-size: $smallFontSize;
+}
+
+.ended {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 0;
+ height: 0;
+ border-width: 0 25px 25px 0;
+ border-style: solid;
+ border-color: transparent $dangerColor transparent transparent;
+ color: $white;
+}
+
+.controls {
+ position: absolute;
+ bottom: 10px;
+ left: 10px;
+ border-radius: 4px;
+ background-color: #216044;
+ color: $white;
+ font-size: $smallFontSize;
+ opacity: 0;
+ transition: opacity 0;
+}
+
+.action {
+ composes: button from 'Components/Link/IconButton.css';
+
+ &:hover {
+ color: #ccc;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .container {
+ padding: 5px;
+ }
+}
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js
new file mode 100644
index 000000000..6ec411eac
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js
@@ -0,0 +1,231 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import Label from 'Components/Label';
+import Link from 'Components/Link/Link';
+import ArtistPoster from 'Artist/ArtistPoster';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar';
+import ArtistIndexPosterInfo from './ArtistIndexPosterInfo';
+import styles from './ArtistIndexPoster.css';
+
+class ArtistIndexPoster extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditArtistPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditArtistModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteArtistPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ artistName,
+ monitored,
+ status,
+ nameSlug,
+ nextAiring,
+ trackCount,
+ trackFileCount,
+ images,
+ posterWidth,
+ posterHeight,
+ detailedProgressBar,
+ showTitle,
+ showQualityProfile,
+ qualityProfile,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat,
+ isRefreshingArtist,
+ onRefreshArtistPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ const link = `/artist/${nameSlug}`;
+
+ const elementStyle = {
+ width: `${posterWidth}px`,
+ height: `${posterHeight}px`
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ status === 'ended' &&
+
+ }
+
+
+
+
+
+
+
+
+ {
+ showTitle &&
+
+ {artistName}
+
+ }
+
+ {
+ showQualityProfile &&
+
+ {qualityProfile.name}
+
+ }
+
+
+ {
+ getRelativeDate(
+ nextAiring,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ }
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexPoster.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ nameSlug: PropTypes.string.isRequired,
+ nextAiring: PropTypes.string,
+ trackCount: PropTypes.number,
+ trackFileCount: PropTypes.number,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ posterHeight: PropTypes.number.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ showTitle: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ qualityProfile: PropTypes.object.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ isRefreshingArtist: PropTypes.bool.isRequired,
+ onRefreshArtistPress: PropTypes.func.isRequired
+};
+
+ArtistIndexPoster.defaultProps = {
+ trackCount: 0,
+ trackFileCount: 0
+};
+
+export default ArtistIndexPoster;
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css
new file mode 100644
index 000000000..cab3dec61
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.css
@@ -0,0 +1,6 @@
+.info {
+ background-color: $defaultColor;
+ color: $white;
+ text-align: center;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js
new file mode 100644
index 000000000..591961605
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import formatBytes from 'Utilities/Number/formatBytes';
+import styles from './ArtistIndexPosterInfo.css';
+
+function ArtistIndexPosterInfo(props) {
+ const {
+ qualityProfile,
+ showQualityProfile,
+ previousAiring,
+ added,
+ albumCount,
+ path,
+ sizeOnDisk,
+ sortKey,
+ showRelativeDates,
+ shortDateFormat,
+ timeFormat
+ } = props;
+
+ if (sortKey === 'qualityProfileId' && !showQualityProfile) {
+ return (
+
+ {qualityProfile.name}
+
+ );
+ }
+
+ if (sortKey === 'previousAiring' && previousAiring) {
+ return (
+
+ {
+ getRelativeDate(
+ previousAiring,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: true
+ }
+ )
+ }
+
+ );
+ }
+
+ if (sortKey === 'added' && added) {
+ const addedDate = getRelativeDate(
+ added,
+ shortDateFormat,
+ showRelativeDates,
+ {
+ timeFormat,
+ timeForToday: false
+ }
+ );
+
+ return (
+
+ {`Added ${addedDate}`}
+
+ );
+ }
+
+ if (sortKey === 'albumCount') {
+ let albums = '1 album';
+
+ if (albumCount === 0) {
+ albums = 'No albums';
+ } else if (albumCount > 1) {
+ albums = `${albumCount} albums`;
+ }
+
+ return (
+
+ {albums}
+
+ );
+ }
+
+ if (sortKey === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (sortKey === 'sizeOnDisk') {
+ return (
+
+ {formatBytes(sizeOnDisk)}
+
+ );
+ }
+
+ return null;
+}
+
+ArtistIndexPosterInfo.propTypes = {
+ qualityProfile: PropTypes.object.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ previousAiring: PropTypes.string,
+ added: PropTypes.string,
+ albumCount: PropTypes.number.isRequired,
+ path: PropTypes.string.isRequired,
+ sizeOnDisk: PropTypes.number,
+ sortKey: PropTypes.string.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired
+};
+
+export default ArtistIndexPosterInfo;
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.css b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.css
new file mode 100644
index 000000000..9c6520fb5
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.css
@@ -0,0 +1,3 @@
+.grid {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js
new file mode 100644
index 000000000..9f870a55b
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js
@@ -0,0 +1,327 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import Measure from 'react-measure';
+import { Grid, WindowScroller } from 'react-virtualized';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import dimensions from 'Styles/Variables/dimensions';
+import { sortDirections } from 'Helpers/Props';
+import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
+import ArtistIndexPoster from './ArtistIndexPoster';
+import styles from './ArtistIndexPosters.css';
+
+// Poster container dimensions
+const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
+const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
+const progressBarHeight = parseInt(dimensions.progressBarSmallHeight);
+const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight);
+
+const additionalColumnCount = {
+ small: 3,
+ medium: 2,
+ large: 1
+};
+
+function calculateColumnWidth(width, posterSize, isSmallScreen) {
+ const maxiumColumnWidth = isSmallScreen ? 172 : 182;
+ const columns = Math.floor(width / maxiumColumnWidth);
+ const remainder = width % maxiumColumnWidth;
+
+ if (remainder === 0 && posterSize === 'large') {
+ return maxiumColumnWidth;
+ }
+
+ return Math.floor(width / (columns + additionalColumnCount[posterSize]));
+}
+
+function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions) {
+ const {
+ detailedProgressBar,
+ showTitle,
+ showQualityProfile
+ } = posterOptions;
+
+ const nextAiringHeight = 19;
+
+ const heights = [
+ posterHeight,
+ detailedProgressBar ? detailedProgressBarHeight : progressBarHeight,
+ nextAiringHeight,
+ isSmallScreen ? columnPaddingSmallScreen : columnPadding
+ ];
+
+ if (showTitle) {
+ heights.push(19);
+ }
+
+ if (showQualityProfile) {
+ heights.push(19);
+ }
+
+ switch (sortKey) {
+ case 'seasons':
+ case 'previousAiring':
+ case 'added':
+ case 'path':
+ case 'sizeOnDisk':
+ heights.push(19);
+ break;
+ case 'qualityProfileId':
+ if (!showQualityProfile) {
+ heights.push(19);
+ }
+ break;
+ default:
+ // No need to add a height of 0
+ }
+
+ return heights.reduce((acc, height) => acc + height, 0);
+}
+
+function calculatePosterHeight(posterWidth) {
+ return Math.ceil(posterWidth);
+}
+
+class ArtistIndexPosters extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ width: 0,
+ columnWidth: 182,
+ columnCount: 1,
+ posterWidth: 238,
+ posterHeight: 238,
+ rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {})
+ };
+
+ this._isInitialized = false;
+ this._grid = null;
+ }
+
+ componentDidMount() {
+ this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ filterKey,
+ filterValue,
+ sortKey,
+ sortDirection,
+ posterOptions
+ } = this.props;
+
+ const itemsChanged = hasDifferentItems(prevProps.items, items);
+
+ if (
+ prevProps.sortKey !== sortKey ||
+ prevProps.posterOptions !== posterOptions ||
+ itemsChanged
+ ) {
+ this.calculateGrid();
+ }
+
+ if (
+ prevProps.filterKey !== filterKey ||
+ prevProps.filterValue !== filterValue ||
+ prevProps.sortKey !== sortKey ||
+ prevProps.sortDirection !== sortDirection ||
+ itemsChanged
+ ) {
+ this._grid.recomputeGridSize();
+ }
+ }
+
+ //
+ // Control
+
+ scrollToFirstCharacter(character) {
+ const items = this.props.items;
+ const {
+ columnCount,
+ rowHeight
+ } = this.state;
+
+ const index = _.findIndex(items, (item) => {
+ const firstCharacter = item.sortName.charAt(0);
+
+ if (character === '#') {
+ return !isNaN(firstCharacter);
+ }
+
+ return firstCharacter === character;
+ });
+
+ if (index != null) {
+ const row = Math.floor(index / columnCount);
+ const scrollTop = rowHeight * row;
+
+ this.props.onScroll({ scrollTop });
+ }
+ }
+
+ setGridRef = (ref) => {
+ this._grid = ref;
+ }
+
+ calculateGrid = (width = this.state.width, isSmallScreen) => {
+ const {
+ sortKey,
+ posterOptions
+ } = this.props;
+
+ const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding;
+ const columnWidth = calculateColumnWidth(width, 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,
+ showQualityProfile
+ } = posterOptions;
+
+ const artist = items[rowIndex * columnCount + columnIndex];
+
+ if (!artist) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.calculateGrid(width, this.props.isSmallScreen);
+ }
+
+ onSectionRendered = () => {
+ if (!this._isInitialized && this._contentBodyNode) {
+ this.props.onRender();
+ this._isInitialized = true;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ scrollTop,
+ isSmallScreen,
+ onScroll
+ } = this.props;
+
+ const {
+ width,
+ columnWidth,
+ columnCount,
+ rowHeight
+ } = this.state;
+
+ const rowCount = Math.ceil(items.length / columnCount);
+
+ return (
+
+
+ {({ height, isScrolling }) => {
+ return (
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+ArtistIndexPosters.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ posterOptions: PropTypes.object.isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ contentBody: PropTypes.object.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default ArtistIndexPosters;
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js b/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js
new file mode 100644
index 000000000..6ec135987
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js
@@ -0,0 +1,33 @@
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import ArtistIndexPosters from './ArtistIndexPosters';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistIndex.posterOptions,
+ createClientSideCollectionSelector(),
+ createUISettingsSelector(),
+ createDimensionsSelector(),
+ (posterOptions, artist, uiSettings, dimensions) => {
+ return {
+ posterOptions,
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ timeFormat: uiSettings.timeFormat,
+ isSmallScreen: dimensions.isSmallScreen,
+ ...artist
+ };
+ }
+ );
+}
+
+export default connectSection(
+ createMapStateToProps,
+ undefined,
+ undefined,
+ { withRef: true },
+ { section: 'artist', uiSection: 'artistIndex' }
+)(ArtistIndexPosters);
diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js
new file mode 100644
index 000000000..e1b0a257a
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import ArtistIndexPosterOptionsModalContentConnector from './ArtistIndexPosterOptionsModalContentConnector';
+
+function ArtistIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+ArtistIndexPosterOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistIndexPosterOptionsModal;
diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js
new file mode 100644
index 000000000..6e0a5aa54
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js
@@ -0,0 +1,173 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+const posterSizeOptions = [
+ { key: 'small', value: 'Small' },
+ { key: 'medium', value: 'Medium' },
+ { key: 'large', value: 'Large' }
+];
+
+class ArtistIndexPosterOptionsModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ detailedProgressBar: props.detailedProgressBar,
+ size: props.size,
+ showTitle: props.showTitle,
+ showQualityProfile: props.showQualityProfile
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ detailedProgressBar,
+ size,
+ showTitle,
+ showQualityProfile
+ } = this.props;
+
+ const state = {};
+
+ if (detailedProgressBar !== prevProps.detailedProgressBar) {
+ state.detailedProgressBar = detailedProgressBar;
+ }
+
+ if (size !== prevProps.size) {
+ state.size = size;
+ }
+
+ if (showTitle !== prevProps.showTitle) {
+ state.showTitle = showTitle;
+ }
+
+ if (showQualityProfile !== prevProps.showQualityProfile) {
+ state.showQualityProfile = showQualityProfile;
+ }
+
+ if (!_.isEmpty(state)) {
+ this.setState(state);
+ }
+ }
+
+ //
+ // Listeners
+
+ onChangePosterOption = ({ name, value }) => {
+ this.setState({
+ [name]: value
+ }, () => {
+ this.props.onChangePosterOption({ [name]: value });
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ onModalClose
+ } = this.props;
+
+ const {
+ detailedProgressBar,
+ size,
+ showTitle,
+ showQualityProfile
+ } = this.state;
+
+ return (
+
+
+ Poster Options
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+ArtistIndexPosterOptionsModalContent.propTypes = {
+ size: PropTypes.string.isRequired,
+ showTitle: PropTypes.bool.isRequired,
+ showQualityProfile: PropTypes.bool.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired,
+ onChangePosterOption: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default ArtistIndexPosterOptionsModalContent;
diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js
new file mode 100644
index 000000000..72af268ad
--- /dev/null
+++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js
@@ -0,0 +1,23 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setArtistPosterOption } from 'Store/Actions/artistIndexActions';
+import ArtistIndexPosterOptionsModalContent from './ArtistIndexPosterOptionsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.artistIndex,
+ (artistIndex) => {
+ return artistIndex.posterOptions;
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onChangePosterOption(payload) {
+ dispatch(setArtistPosterOption(payload));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexPosterOptionsModalContent);
diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css
new file mode 100644
index 000000000..dbf3499ab
--- /dev/null
+++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css
@@ -0,0 +1,14 @@
+.progress {
+ composes: container from 'Components/ProgressBar.css';
+
+ border-radius: 0;
+ background-color: #5b5b5b;
+ color: $white;
+ transition: width 200ms ease;
+}
+
+.progressBar {
+ composes: progressBar from 'Components/ProgressBar.css';
+
+ transition: width 200ms ease;
+}
diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js
new file mode 100644
index 000000000..89d85f80a
--- /dev/null
+++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
+import { sizes } from 'Helpers/Props';
+import ProgressBar from 'Components/ProgressBar';
+import styles from './ArtistIndexProgressBar.css';
+
+function ArtistIndexProgressBar(props) {
+ const {
+ monitored,
+ status,
+ trackCount,
+ trackFileCount,
+ posterWidth,
+ detailedProgressBar
+ } = props;
+
+ const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
+ const text = `${trackFileCount} / ${trackCount}`;
+
+ return (
+
+ );
+}
+
+ArtistIndexProgressBar.propTypes = {
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ trackCount: PropTypes.number.isRequired,
+ trackFileCount: PropTypes.number.isRequired,
+ posterWidth: PropTypes.number.isRequired,
+ detailedProgressBar: PropTypes.bool.isRequired
+};
+
+export default ArtistIndexProgressBar;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js b/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js
new file mode 100644
index 000000000..3f37cd56a
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js
@@ -0,0 +1,102 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+
+class ArtistIndexActionsCell extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditArtistPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditArtistModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteArtistPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ isRefreshingArtist,
+ onRefreshArtistPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexActionsCell.propTypes = {
+ id: PropTypes.number.isRequired,
+ isRefreshingArtist: PropTypes.bool.isRequired,
+ onRefreshArtistPress: PropTypes.func.isRequired
+};
+
+export default ArtistIndexActionsCell;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css
new file mode 100644
index 000000000..a128e8ee1
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css
@@ -0,0 +1,76 @@
+.status {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 60px;
+}
+
+.sortName {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 4 0 110px;
+}
+
+.qualityProfileId,
+.languageProfileId {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 125px;
+}
+
+.nextAiring,
+.previousAiring,
+.added {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 180px;
+}
+
+.albumCount {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 100px;
+}
+
+.trackProgress,
+.latestAlbum {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 150px;
+}
+
+.artistType,
+.trackCount {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 120px;
+}
+
+.path {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 150px;
+}
+
+.sizeOnDisk {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 110px;
+}
+
+.tags {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 1 0 60px;
+}
+
+.useSceneNumbering {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 145px;
+}
+
+.actions {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ flex: 0 0 70px;
+}
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.js b/frontend/src/Artist/Index/Table/ArtistIndexHeader.js
new file mode 100644
index 000000000..8c1bd8682
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexHeader.js
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
+import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
+import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal';
+import styles from './ArtistIndexHeader.css';
+
+class ArtistIndexHeader extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isTableOptionsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onTableOptionsPress = () => {
+ this.setState({ isTableOptionsModalOpen: true });
+ }
+
+ onTableOptionsModalClose = () => {
+ this.setState({ isTableOptionsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ columns,
+ onTableOptionChange,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ label,
+ isSortable,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {label}
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+ArtistIndexHeader.propTypes = {
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onTableOptionChange: PropTypes.func.isRequired
+};
+
+export default ArtistIndexHeader;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js
new file mode 100644
index 000000000..37ddd9ef3
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux';
+import { setArtistTableOption } from 'Store/Actions/artistIndexActions';
+import ArtistIndexHeader from './ArtistIndexHeader';
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onTableOptionChange(payload) {
+ dispatch(setArtistTableOption(payload));
+ }
+ };
+}
+
+export default connect(undefined, createMapDispatchToProps)(ArtistIndexHeader);
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.css b/frontend/src/Artist/Index/Table/ArtistIndexRow.css
new file mode 100644
index 000000000..2d2d94a59
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.css
@@ -0,0 +1,85 @@
+.status {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 60px;
+}
+
+.sortName {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 4 0 110px;
+}
+
+.qualityProfileId,
+.languageProfileId {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 1 0 125px;
+}
+
+.nextAiring,
+.previousAiring,
+.added {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 180px;
+}
+
+.albumCount {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 100px;
+}
+
+.trackProgress,
+.latestAlbum {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ display: flex;
+ justify-content: center;
+ flex: 0 0 150px;
+ flex-direction: column;
+}
+
+.artistType,
+.trackCount {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 120px;
+}
+
+.path {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 1 0 150px;
+}
+
+.sizeOnDisk {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 110px;
+}
+
+.tags {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 1 0 60px;
+}
+
+.useSceneNumbering {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 145px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css';
+
+ flex: 0 0 70px;
+}
+
+.checkInput {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin-top: 0;
+}
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.js b/frontend/src/Artist/Index/Table/ArtistIndexRow.js
new file mode 100644
index 000000000..8f6cf1c05
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.js
@@ -0,0 +1,372 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { icons } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import ProgressBar from 'Components/ProgressBar';
+import TagListConnector from 'Components/TagListConnector';
+// import CheckInput from 'Components/Form/CheckInput';
+import VirtualTableRow from 'Components/Table/VirtualTableRow';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
+import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
+import ArtistStatusCell from './ArtistStatusCell';
+import styles from './ArtistIndexRow.css';
+
+class ArtistIndexRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: false
+ };
+ }
+
+ onEditArtistPress = () => {
+ this.setState({ isEditArtistModalOpen: true });
+ }
+
+ onEditArtistModalClose = () => {
+ this.setState({ isEditArtistModalOpen: false });
+ }
+
+ onDeleteArtistPress = () => {
+ this.setState({
+ isEditArtistModalOpen: false,
+ isDeleteArtistModalOpen: true
+ });
+ }
+
+ onDeleteArtistModalClose = () => {
+ this.setState({ isDeleteArtistModalOpen: false });
+ }
+
+ onUseSceneNumberingChange = () => {
+ // Mock handler to satisfy `onChange` being required for `CheckInput`.
+ //
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ style,
+ id,
+ monitored,
+ status,
+ artistName,
+ nameSlug,
+ artistType,
+ qualityProfile,
+ languageProfile,
+ nextAiring,
+ previousAiring,
+ added,
+ albumCount,
+ trackCount,
+ trackFileCount,
+ totalTrackCount,
+ latestAlbum,
+ path,
+ sizeOnDisk,
+ tags,
+ // useSceneNumbering,
+ columns,
+ isRefreshingArtist,
+ onRefreshArtistPress
+ } = this.props;
+
+ const {
+ isEditArtistModalOpen,
+ isDeleteArtistModalOpen
+ } = this.state;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'status') {
+ return (
+
+ );
+ }
+
+ if (name === 'sortName') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'artistType') {
+ return (
+
+ {artistType}
+
+ );
+ }
+
+ if (name === 'qualityProfileId') {
+ return (
+
+ {qualityProfile.name}
+
+ );
+ }
+
+ if (name === 'languageProfileId') {
+ return (
+
+ {languageProfile.name}
+
+ );
+ }
+
+ if (name === 'nextAiring') {
+ return (
+
+ );
+ }
+
+ if (name === 'previousAiring') {
+ return (
+
+ );
+ }
+
+ if (name === 'added') {
+ return (
+
+ );
+ }
+
+ if (name === 'albumCount') {
+ return (
+
+ {albumCount}
+
+ );
+ }
+
+ if (name === 'trackProgress') {
+ const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
+
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'latestAlbum') {
+ const albumStatistics = latestAlbum.statistics;
+ const progress = albumStatistics.trackCount ? albumStatistics.trackFileCount / albumStatistics.trackCount * 100 : 100;
+
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'trackCount') {
+ return (
+
+ {totalTrackCount}
+
+ );
+ }
+
+ if (name === 'path') {
+ return (
+
+ {path}
+
+ );
+ }
+
+ if (name === 'sizeOnDisk') {
+ return (
+
+ {formatBytes(sizeOnDisk)}
+
+ );
+ }
+
+ if (name === 'tags') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+
+
+
+
+ );
+ }
+}
+
+ArtistIndexRow.propTypes = {
+ style: PropTypes.object.isRequired,
+ id: PropTypes.number.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ nameSlug: PropTypes.string.isRequired,
+ artistType: PropTypes.string,
+ qualityProfile: PropTypes.object.isRequired,
+ languageProfile: PropTypes.object.isRequired,
+ nextAiring: PropTypes.string,
+ previousAiring: PropTypes.string,
+ added: PropTypes.string,
+ albumCount: PropTypes.number.isRequired,
+ trackCount: PropTypes.number,
+ trackFileCount: PropTypes.number,
+ totalTrackCount: PropTypes.number,
+ latestAlbum: PropTypes.object,
+ path: PropTypes.string.isRequired,
+ sizeOnDisk: PropTypes.number,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ // useSceneNumbering: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isRefreshingArtist: PropTypes.bool.isRequired,
+ onRefreshArtistPress: PropTypes.func.isRequired
+};
+
+ArtistIndexRow.defaultProps = {
+ trackCount: 0,
+ trackFileCount: 0
+};
+
+export default ArtistIndexRow;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.css b/frontend/src/Artist/Index/Table/ArtistIndexTable.css
new file mode 100644
index 000000000..e46160a96
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.css
@@ -0,0 +1,5 @@
+.tableContainer {
+ composes: tableContainer from 'Components/Table/VirtualTable.css';
+
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.js b/frontend/src/Artist/Index/Table/ArtistIndexTable.js
new file mode 100644
index 000000000..acd9b2fa7
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.js
@@ -0,0 +1,143 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sortDirections } from 'Helpers/Props';
+import VirtualTable from 'Components/Table/VirtualTable';
+import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
+import ArtistIndexHeaderConnector from './ArtistIndexHeaderConnector';
+import ArtistIndexRow from './ArtistIndexRow';
+import styles from './ArtistIndexTable.css';
+
+class ArtistIndexTable extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._table = null;
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ columns,
+ filterKey,
+ filterValue,
+ sortKey,
+ sortDirection
+ } = this.props;
+
+ if (prevProps.columns !== columns ||
+ prevProps.filterKey !== filterKey ||
+ prevProps.filterValue !== filterValue ||
+ prevProps.sortKey !== sortKey ||
+ prevProps.sortDirection !== sortDirection
+ ) {
+ this._table.forceUpdateGrid();
+ }
+ }
+
+ //
+ // Control
+
+ scrollToFirstCharacter(character) {
+ const items = this.props.items;
+
+ const row = _.findIndex(items, (item) => {
+ const firstCharacter = item.sortName.charAt(0);
+
+ if (character === '#') {
+ return !isNaN(firstCharacter);
+ }
+
+ return firstCharacter === character;
+ });
+
+ if (row != null) {
+ this._table.scrollToRow(row);
+ }
+ }
+
+ setTableRef = (ref) => {
+ this._table = ref;
+ }
+
+ rowRenderer = ({ key, rowIndex, style }) => {
+ const {
+ items,
+ columns
+ } = this.props;
+
+ const artist = items[rowIndex];
+
+ return (
+
+ );
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ columns,
+ sortKey,
+ sortDirection,
+ isSmallScreen,
+ scrollTop,
+ contentBody,
+ onSortPress,
+ onRender,
+ onScroll
+ } = this.props;
+
+ return (
+
+ }
+ onRender={onRender}
+ onScroll={onScroll}
+ />
+ );
+ }
+}
+
+ArtistIndexTable.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ scrollTop: PropTypes.number.isRequired,
+ contentBody: PropTypes.object.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ onSortPress: PropTypes.func.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+export default ArtistIndexTable;
diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js
new file mode 100644
index 000000000..c49c0cf07
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js
@@ -0,0 +1,34 @@
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import { setArtistSort } from 'Store/Actions/artistIndexActions';
+import ArtistIndexTable from './ArtistIndexTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.dimensions,
+ createClientSideCollectionSelector(),
+ (dimensions, artist) => {
+ return {
+ isSmallScreen: dimensions.isSmallScreen,
+ ...artist
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onSortPress(sortKey) {
+ dispatch(setArtistSort({ sortKey }));
+ }
+ };
+}
+
+export default connectSection(
+ createMapStateToProps,
+ createMapDispatchToProps,
+ undefined,
+ { withRef: true },
+ { section: 'artist', uiSection: 'artistIndex' }
+)(ArtistIndexTable);
diff --git a/frontend/src/Artist/Index/Table/ArtistStatusCell.css b/frontend/src/Artist/Index/Table/ArtistStatusCell.css
new file mode 100644
index 000000000..d19ddf05b
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistStatusCell.css
@@ -0,0 +1,9 @@
+.status {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 60px;
+}
+
+.statusIcon {
+ width: 20px;
+}
diff --git a/frontend/src/Artist/Index/Table/ArtistStatusCell.js b/frontend/src/Artist/Index/Table/ArtistStatusCell.js
new file mode 100644
index 000000000..ab3042a10
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/ArtistStatusCell.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './ArtistStatusCell.css';
+
+function ArtistStatusCell(props) {
+ const {
+ className,
+ monitored,
+ status,
+ component: Component,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+ );
+}
+
+ArtistStatusCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+ component: PropTypes.func
+};
+
+ArtistStatusCell.defaultProps = {
+ className: styles.status,
+ component: VirtualTableRowCell
+};
+
+export default ArtistStatusCell;
diff --git a/frontend/src/Artist/Index/Table/artistIndexCellRenderers.js b/frontend/src/Artist/Index/Table/artistIndexCellRenderers.js
new file mode 100644
index 000000000..b5ea45e4f
--- /dev/null
+++ b/frontend/src/Artist/Index/Table/artistIndexCellRenderers.js
@@ -0,0 +1,125 @@
+import React from 'react';
+import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
+import ProgressBar from 'Components/ProgressBar';
+import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector';
+import ArtistIndexActionsCell from './ArtistIndexActionsCell';
+import ArtistStatusCell from './ArtistStatusCell';
+
+export default function artistIndexCellRenderers(cellProps) {
+ const {
+ cellKey,
+ dataKey,
+ rowData,
+ ...otherProps
+ } = cellProps;
+
+ const {
+ id,
+ monitored,
+ status,
+ name,
+ nameSlug,
+ qualityProfileId,
+ nextAiring,
+ previousAiring,
+ albumCount,
+ trackCount,
+ trackFileCount
+ } = rowData;
+
+ const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
+
+ if (dataKey === 'status') {
+ return (
+
+ );
+ }
+
+ if (dataKey === 'sortName') {
+ return (
+
+
+
+
+ );
+ }
+
+ if (dataKey === 'qualityProfileId') {
+ return (
+
+
+
+ );
+ }
+
+ if (dataKey === 'nextAiring') {
+ return (
+
+ );
+ }
+
+ if (dataKey === 'albumCount') {
+ return (
+
+ {albumCount}
+
+ );
+ }
+
+ if (dataKey === 'trackProgress') {
+ return (
+
+
+
+ );
+ }
+
+ if (dataKey === 'actions') {
+ return (
+
+ );
+ }
+}
diff --git a/frontend/src/Artist/NoArtist.css b/frontend/src/Artist/NoArtist.css
new file mode 100644
index 000000000..38a01f391
--- /dev/null
+++ b/frontend/src/Artist/NoArtist.css
@@ -0,0 +1,11 @@
+.message {
+ margin-top: 10px;
+ margin-bottom: 30px;
+ text-align: center;
+ font-size: 20px;
+}
+
+.buttonContainer {
+ margin-top: 20px;
+ text-align: center;
+}
diff --git a/frontend/src/Artist/NoArtist.js b/frontend/src/Artist/NoArtist.js
new file mode 100644
index 000000000..b6e90cf63
--- /dev/null
+++ b/frontend/src/Artist/NoArtist.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import styles from './NoArtist.css';
+
+function NoArtist() {
+ return (
+
+
+ No artist found, to get started you'll want to add a new artist or import some existing ones.
+
+
+
+
+ Import Existing Artist(s)
+
+
+
+
+
+ Add New Artist
+
+
+
+ );
+}
+
+export default NoArtist;
diff --git a/frontend/src/Calendar/Agenda/Agenda.css b/frontend/src/Calendar/Agenda/Agenda.css
new file mode 100644
index 000000000..0304d9db5
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/Agenda.css
@@ -0,0 +1,3 @@
+.agenda {
+ margin-top: 10px;
+}
diff --git a/frontend/src/Calendar/Agenda/Agenda.js b/frontend/src/Calendar/Agenda/Agenda.js
new file mode 100644
index 000000000..33d02cd79
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/Agenda.js
@@ -0,0 +1,38 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import AgendaEventConnector from './AgendaEventConnector';
+import styles from './Agenda.css';
+
+function Agenda(props) {
+ const {
+ items
+ } = props;
+
+ return (
+
+ {
+ items.map((item, index) => {
+ const momentDate = moment(item.releaseDate);
+ const showDate = index === 0 ||
+ !moment(items[index - 1].releaseDate).isSame(momentDate, 'day');
+
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+Agenda.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Agenda;
diff --git a/frontend/src/Calendar/Agenda/AgendaConnector.js b/frontend/src/Calendar/Agenda/AgendaConnector.js
new file mode 100644
index 000000000..b6f238873
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaConnector.js
@@ -0,0 +1,14 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import Agenda from './Agenda';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ (calendar) => {
+ return calendar;
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(Agenda);
diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css b/frontend/src/Calendar/Agenda/AgendaEvent.css
new file mode 100644
index 000000000..f85b87cef
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.css
@@ -0,0 +1,105 @@
+.event {
+ display: flex;
+ overflow-x: hidden;
+ padding: 5px;
+ border-bottom: 1px solid $borderColor;
+ font-size: 14px;
+
+ &:hover {
+ background-color: $tableRowHoverBackgroundColor;
+ }
+}
+
+.status {
+ width: 10px;
+ border-left-width: 4px;
+ border-left-style: solid;
+}
+
+.date {
+ flex: 0 0 250px;
+ font-weight: bold;
+}
+
+.time {
+ flex: 0 0 120px;
+ margin-right: 10px;
+}
+
+.artistName,
+.albumTitle {
+ @add-mixin truncate;
+
+ flex: 0 1 300px;
+ margin-right: 10px;
+}
+
+.albumTitle {
+ flex: 1 1 1px;
+}
+
+.seasonEpisodeNumber {
+ flex: 0 0 100px;
+}
+
+.episodeSeparator {
+ display: none;
+}
+
+.absoluteEpisodeNumber {
+ margin-left: 3px;
+}
+
+/*
+ * Status
+ */
+
+.downloaded {
+ composes: downloaded from 'Calendar/Events/CalendarEvent.css';
+}
+
+.downloading {
+ composes: downloading from 'Calendar/Events/CalendarEvent.css';
+}
+
+.unmonitored {
+ composes: unmonitored from 'Calendar/Events/CalendarEvent.css';
+}
+
+.missing {
+ composes: missing from 'Calendar/Events/CalendarEvent.css';
+}
+
+.unreleased {
+ composes: unreleased from 'Calendar/Events/CalendarEvent.css';
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .event {
+ position: relative;
+ flex-wrap: wrap;
+ padding-left: 10px;
+ }
+
+ .status {
+ position: absolute;
+ top: 7%;
+ left: 0;
+ height: 86%;
+ }
+
+ .date,
+ .time,
+ .artistName {
+ 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..44fac0a88
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.js
@@ -0,0 +1,142 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import formatTime from 'Utilities/Date/formatTime';
+import padNumber from 'Utilities/Number/padNumber';
+import { icons } from 'Helpers/Props';
+import getStatusStyle from 'Calendar/getStatusStyle';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import episodeEntities from 'Album/episodeEntities';
+import EpisodeDetailsModal from 'Album/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,
+ artist,
+ title,
+ releaseDate,
+ monitored,
+ hasFile,
+ grabbed,
+ queueItem,
+ showDate,
+ timeFormat,
+ longDateFormat
+ } = this.props;
+
+ const startTime = moment(releaseDate);
+ // const endTime = startTime.add(artist.runtime, 'minutes');
+ const downloading = !!(queueItem || grabbed);
+ const isMonitored = artist.monitored && monitored;
+ const statusStyle = getStatusStyle(id, hasFile, downloading, startTime, isMonitored);
+
+ return (
+
+
+
+ {
+ showDate &&
+ startTime.format(longDateFormat)
+ }
+
+
+
+
+
+ {formatTime(releaseDate, timeFormat)}
+
+
+
+ {artist.artistName}
+
+
+
-
+
+
+ {title}
+
+
+ {
+ !!queueItem &&
+
+ }
+
+ {
+ !queueItem && grabbed &&
+
+ }
+
+
+
+
+ );
+ }
+}
+
+AgendaEvent.propTypes = {
+ id: PropTypes.number.isRequired,
+ artist: PropTypes.object.isRequired,
+ title: PropTypes.string.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ hasFile: PropTypes.bool.isRequired,
+ grabbed: PropTypes.bool,
+ queueItem: PropTypes.object,
+ showDate: PropTypes.bool.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired
+};
+
+export default AgendaEvent;
diff --git a/frontend/src/Calendar/Agenda/AgendaEventConnector.js b/frontend/src/Calendar/Agenda/AgendaEventConnector.js
new file mode 100644
index 000000000..76de94184
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEventConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import AgendaEvent from './AgendaEvent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createQueueItemSelector(),
+ createUISettingsSelector(),
+ (artist, queueItem, uiSettings) => {
+ return {
+ artist,
+ queueItem,
+ timeFormat: uiSettings.timeFormat,
+ longDateFormat: uiSettings.longDateFormat
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(AgendaEvent);
diff --git a/frontend/src/Calendar/Calendar.css b/frontend/src/Calendar/Calendar.css
new file mode 100644
index 000000000..37e6ff618
--- /dev/null
+++ b/frontend/src/Calendar/Calendar.css
@@ -0,0 +1,8 @@
+.calendar {
+ flex-grow: 1;
+ width: 100%;
+}
+
+.calendarContent {
+ width: 100%;
+}
diff --git a/frontend/src/Calendar/Calendar.js b/frontend/src/Calendar/Calendar.js
new file mode 100644
index 000000000..6ceb1f3bb
--- /dev/null
+++ b/frontend/src/Calendar/Calendar.js
@@ -0,0 +1,64 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import * as calendarViews from './calendarViews';
+import CalendarHeaderConnector from './Header/CalendarHeaderConnector';
+import DaysOfWeekConnector from './Day/DaysOfWeekConnector';
+import CalendarDaysConnector from './Day/CalendarDaysConnector';
+import AgendaConnector from './Agenda/AgendaConnector';
+import styles from './Calendar.css';
+
+class Calendar extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ view
+ } = this.props;
+
+ return (
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+
Unable to load the calendar
+ }
+
+ {
+ !error && isPopulated && view === calendarViews.AGENDA &&
+
+ }
+
+ {
+ !error && isPopulated && view !== calendarViews.AGENDA &&
+
+
+
+
+
+ }
+
+ );
+ }
+}
+
+Calendar.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ view: PropTypes.string.isRequired
+};
+
+export default Calendar;
diff --git a/frontend/src/Calendar/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js
new file mode 100644
index 000000000..a5a99f853
--- /dev/null
+++ b/frontend/src/Calendar/CalendarConnector.js
@@ -0,0 +1,159 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import * as calendarActions from 'Store/Actions/calendarActions';
+import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import Calendar from './Calendar';
+
+const UPDATE_DELAY = 3600000; // 1 hour
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ (calendar) => {
+ return calendar;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...calendarActions,
+ fetchTrackFiles,
+ clearTrackFiles,
+ fetchQueueDetails,
+ clearQueueDetails
+};
+
+class CalendarConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.updateTimeoutId = null;
+ }
+
+ componentDidMount() {
+ registerPagePopulator(this.repopulate);
+ this.props.gotoCalendarToday();
+ this.scheduleUpdate();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ items,
+ time
+ } = this.props;
+
+ if (hasDifferentItems(prevProps.items, items)) {
+ const albumIds = selectUniqueIds(items, 'id');
+ // const trackFileIds = selectUniqueIds(items, 'trackFileId');
+
+ this.props.fetchQueueDetails({ albumIds });
+
+ // if (trackFileIds.length) {
+ // this.props.fetchTrackFiles({ trackFileIds });
+ // }
+ }
+
+ if (prevProps.time !== time) {
+ this.scheduleUpdate();
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearCalendar();
+ this.props.clearQueueDetails();
+ this.props.clearTrackFiles();
+ this.clearUpdateTimeout();
+ }
+
+ //
+ // Control
+ repopulate = () => {
+ const {
+ time,
+ view
+ } = this.props;
+
+ this.props.fetchQueueDetails({ time, view });
+ this.props.fetchCalendar({ time, view });
+ }
+
+ scheduleUpdate = () => {
+ this.clearUpdateTimeout();
+
+ this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY);
+ }
+
+ clearUpdateTimeout = () => {
+ if (this.updateTimeoutId) {
+ clearTimeout(this.updateTimeoutId);
+ }
+ }
+
+ updateCalendar = () => {
+ this.props.gotoCalendarToday();
+ this.scheduleUpdate();
+ }
+
+ //
+ // Listeners
+
+ onCalendarViewChange = (view) => {
+ this.props.setCalendarView({ view });
+ }
+
+ onTodayPress = () => {
+ this.props.gotoCalendarToday();
+ }
+
+ onPreviousPress = () => {
+ this.props.gotoCalendarPreviousRange();
+ }
+
+ onNextPress = () => {
+ this.props.gotoCalendarNextRange();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CalendarConnector.propTypes = {
+ time: PropTypes.string,
+ view: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ setCalendarView: PropTypes.func.isRequired,
+ gotoCalendarToday: PropTypes.func.isRequired,
+ gotoCalendarPreviousRange: PropTypes.func.isRequired,
+ gotoCalendarNextRange: PropTypes.func.isRequired,
+ clearCalendar: PropTypes.func.isRequired,
+ fetchCalendar: PropTypes.func.isRequired,
+ fetchTrackFiles: PropTypes.func.isRequired,
+ clearTrackFiles: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector);
diff --git a/frontend/src/Calendar/CalendarPage.css b/frontend/src/Calendar/CalendarPage.css
new file mode 100644
index 000000000..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..bf1e5cfe8
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPage.js
@@ -0,0 +1,137 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Measure from 'react-measure';
+import { align, icons } from 'Helpers/Props';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import CalendarLinkModal from './iCal/CalendarLinkModal';
+import Legend from './Legend/Legend';
+import CalendarConnector from './CalendarConnector';
+import styles from './CalendarPage.css';
+
+const MINIMUM_DAY_WIDTH = 120;
+
+class CalendarPage extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isCalendarLinkModalOpen: false,
+ width: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.setState({ width });
+ const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)));
+
+ this.props.onDaysCountChange(days);
+ }
+
+ onFilterMenuItemPress = (filterKey, unmonitored) => {
+ this.props.onUnmonitoredChange(unmonitored);
+ }
+
+ onGetCalendarLinkPress = () => {
+ this.setState({ isCalendarLinkModalOpen: true });
+ }
+
+ onGetCalendarLinkModalClose = () => {
+ this.setState({ isCalendarLinkModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ unmonitored,
+ colorImpairedMode
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ All
+
+
+
+ Monitored Only
+
+
+
+
+
+
+
+
+ {
+ this.state.width > 0 ?
+ :
+
+ }
+
+
+
+
+
+
+
+ );
+ }
+}
+
+CalendarPage.propTypes = {
+ unmonitored: PropTypes.bool.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired,
+ onDaysCountChange: PropTypes.func.isRequired,
+ onUnmonitoredChange: PropTypes.func.isRequired
+};
+
+export default CalendarPage;
diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js
new file mode 100644
index 000000000..59de66f74
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPageConnector.js
@@ -0,0 +1,33 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { setCalendarDaysCount, setCalendarIncludeUnmonitored } from 'Store/Actions/calendarActions';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import CalendarPage from './CalendarPage';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ createUISettingsSelector(),
+ (calendar, uiSettings) => {
+ return {
+ unmonitored: calendar.unmonitored,
+ showUpcoming: calendar.showUpcoming,
+ colorImpairedMode: uiSettings.enableColorImpairedMode
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onDaysCountChange(dayCount) {
+ dispatch(setCalendarDaysCount({ dayCount }));
+ },
+
+ onUnmonitoredChange(unmonitored) {
+ dispatch(setCalendarIncludeUnmonitored({ unmonitored }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage);
diff --git a/frontend/src/Calendar/Day/CalendarDay.css b/frontend/src/Calendar/Day/CalendarDay.css
new file mode 100644
index 000000000..1c7694f0b
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDay.css
@@ -0,0 +1,25 @@
+.day {
+ flex: 1 0 14.28%;
+ overflow: hidden;
+ min-height: 70px;
+ border-bottom: 1px solid $borderColor;
+ border-left: 1px solid $borderColor;
+}
+
+.isSingleDay {
+ width: 100%;
+}
+
+.dayOfMonth {
+ padding-right: 5px;
+ border-bottom: 1px solid $borderColor;
+ text-align: right;
+}
+
+.isToday {
+ background-color: $calendarTodayBackgroundColor;
+}
+
+.isDifferentMonth {
+ color: $disabledColor;
+}
diff --git a/frontend/src/Calendar/Day/CalendarDay.js b/frontend/src/Calendar/Day/CalendarDay.js
new file mode 100644
index 000000000..214c13ba5
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDay.js
@@ -0,0 +1,62 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import * as calendarViews from 'Calendar/calendarViews';
+import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
+import styles from './CalendarDay.css';
+
+function CalendarDay(props) {
+ const {
+ date,
+ time,
+ isTodaysDate,
+ events,
+ view,
+ onEventModalOpenToggle
+ } = props;
+
+ return (
+
+ {
+ view === calendarViews.MONTH &&
+
+ {moment(date).date()}
+
+ }
+
+ {
+ events.map((event) => {
+ return (
+
+ );
+ })
+ }
+
+
+ );
+}
+
+CalendarDay.propTypes = {
+ date: PropTypes.string.isRequired,
+ time: PropTypes.string.isRequired,
+ isTodaysDate: PropTypes.bool.isRequired,
+ events: PropTypes.arrayOf(PropTypes.object).isRequired,
+ view: PropTypes.string.isRequired,
+ onEventModalOpenToggle: PropTypes.func.isRequired
+};
+
+export default CalendarDay;
diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js
new file mode 100644
index 000000000..467f799e7
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDayConnector.js
@@ -0,0 +1,55 @@
+import _ from 'lodash';
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import CalendarDay from './CalendarDay';
+
+function createCalendarEventsConnector() {
+ return createSelector(
+ (state, { date }) => date,
+ (state) => state.calendar,
+ (date, calendar) => {
+ const filtered = _.filter(calendar.items, (item) => {
+ return moment(date).isSame(moment(item.releaseDate), 'day');
+ });
+
+ return _.sortBy(filtered, (item) => moment(item.releaseDate).unix());
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ createCalendarEventsConnector(),
+ (calendar, events) => {
+ return {
+ time: calendar.time,
+ view: calendar.view,
+ events
+ };
+ }
+ );
+}
+
+class CalendarDayConnector extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CalendarDayConnector.propTypes = {
+ date: PropTypes.string.isRequired
+};
+
+export default connect(createMapStateToProps)(CalendarDayConnector);
diff --git a/frontend/src/Calendar/Day/CalendarDays.css b/frontend/src/Calendar/Day/CalendarDays.css
new file mode 100644
index 000000000..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..9dd965146
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDaysConnector.js
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions';
+import CalendarDays from './CalendarDays';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ (state) => state.app.isSidebarVisible,
+ (calendar, isSidebarVisible) => {
+ return {
+ dates: calendar.dates,
+ view: calendar.view,
+ isSidebarVisible
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch) {
+ return {
+ onNavigatePrevious() {
+ dispatch(gotoCalendarPreviousRange());
+ },
+
+ onNavigateNext() {
+ dispatch(gotoCalendarNextRange());
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(CalendarDays);
diff --git a/frontend/src/Calendar/Day/DayOfWeek.css b/frontend/src/Calendar/Day/DayOfWeek.css
new file mode 100644
index 000000000..8c3552e55
--- /dev/null
+++ b/frontend/src/Calendar/Day/DayOfWeek.css
@@ -0,0 +1,13 @@
+.dayOfWeek {
+ flex: 1 0 14.28%;
+ background-color: #e4eaec;
+ text-align: center;
+}
+
+.isSingleDay {
+ width: 100%;
+}
+
+.isToday {
+ background-color: $calendarTodayBackgroundColor;
+}
diff --git a/frontend/src/Calendar/Day/DayOfWeek.js b/frontend/src/Calendar/Day/DayOfWeek.js
new file mode 100644
index 000000000..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..c30f3562e
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEvent.css
@@ -0,0 +1,70 @@
+.event {
+ overflow-x: hidden;
+ margin: 4px 2px;
+ padding: 5px;
+ border-bottom: 1px solid $borderColor;
+ border-left: 4px solid $borderColor;
+ font-size: 12px;
+}
+
+.info,
+.albumInfo {
+ display: flex;
+}
+
+.artistName,
+.albumTitle {
+ @add-mixin truncate;
+
+ flex: 1 0 1px;
+ margin-right: 10px;
+}
+
+.artistName {
+ color: #3a3f51;
+ font-size: 14px;
+}
+
+.absoluteEpisodeNumber {
+ margin-left: 3px;
+}
+
+.statusIcon {
+ margin-left: 3px;
+}
+
+/*
+ * Status
+ */
+
+.downloaded {
+ border-left-color: $successColor;
+}
+
+.downloading {
+ border-left-color: $purple;
+}
+
+.unmonitored {
+ border-left-color: $gray;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(45deg, transparent, transparent 5px, #eee 5px, #eee 10px);
+ }
+}
+
+.missing {
+ border-left-color: $dangerColor;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px);
+ }
+}
+
+.unreleased {
+ border-left-color: $primaryColor;
+
+ &:global(.colorImpaired) {
+ background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px);
+ }
+}
diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js
new file mode 100644
index 000000000..8e914cc0d
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEvent.js
@@ -0,0 +1,144 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import formatTime from 'Utilities/Date/formatTime';
+import padNumber from 'Utilities/Number/padNumber';
+import getStatusStyle from 'Calendar/getStatusStyle';
+import episodeEntities from 'Album/episodeEntities';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import EpisodeDetailsModal from 'Album/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,
+ artist,
+ title,
+ // seasonNumber,
+ // episodeNumber,
+ // absoluteEpisodeNumber,
+ releaseDate,
+ monitored,
+ // hasFile,
+ grabbed,
+ queueItem,
+ // timeFormat,
+ colorImpairedMode
+ } = this.props;
+
+ const startTime = moment(releaseDate);
+ // const endTime = startTime.add(artist.runtime, 'minutes');
+ const downloading = !!(queueItem || grabbed);
+ const isMonitored = artist.monitored && monitored;
+ const statusStyle = getStatusStyle(id, downloading, startTime, isMonitored);
+ // const missingAbsoluteNumber = artist.artistType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
+
+ return (
+
+
+
+
+ {artist.artistName}
+
+
+ {
+ !!queueItem &&
+
+
+
+ }
+
+ {
+ !queueItem && grabbed &&
+
+ }
+
+
+
+
+
+
+
+ );
+ }
+}
+
+CalendarEvent.propTypes = {
+ id: PropTypes.number.isRequired,
+ artist: PropTypes.object.isRequired,
+ title: PropTypes.string.isRequired,
+ // seasonNumber: PropTypes.number.isRequired,
+ // episodeNumber: PropTypes.number.isRequired,
+ // absoluteEpisodeNumber: PropTypes.number,
+ releaseDate: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ // hasFile: PropTypes.bool.isRequired,
+ grabbed: PropTypes.bool,
+ queueItem: PropTypes.object,
+ // timeFormat: PropTypes.string.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired,
+ onEventModalOpenToggle: PropTypes.func.isRequired
+};
+
+export default CalendarEvent;
diff --git a/frontend/src/Calendar/Events/CalendarEventConnector.js b/frontend/src/Calendar/Events/CalendarEventConnector.js
new file mode 100644
index 000000000..31706e2f7
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventConnector.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import CalendarEvent from './CalendarEvent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ createQueueItemSelector(),
+ createUISettingsSelector(),
+ (artist, queueItem, uiSettings) => {
+ return {
+ artist,
+ queueItem,
+ timeFormat: uiSettings.timeFormat,
+ colorImpairedMode: uiSettings.enableColorImpairedMode
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(CalendarEvent);
diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js
new file mode 100644
index 000000000..81d81465c
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import colors from 'Styles/Variables/colors';
+import CircularProgressBar from 'Components/CircularProgressBar';
+import QueueDetails from 'Activity/Queue/QueueDetails';
+
+function CalendarEventQueueDetails(props) {
+ const {
+ title,
+ size,
+ sizeleft,
+ estimatedCompletionTime,
+ status,
+ errorMessage
+ } = props;
+
+ const progress = (100 - sizeleft / size * 100);
+
+ return (
+
+
+
+ }
+ />
+ );
+}
+
+CalendarEventQueueDetails.propTypes = {
+ title: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ sizeleft: PropTypes.number.isRequired,
+ estimatedCompletionTime: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ errorMessage: PropTypes.string
+};
+
+export default CalendarEventQueueDetails;
diff --git a/frontend/src/Calendar/Header/CalendarHeader.css b/frontend/src/Calendar/Header/CalendarHeader.css
new file mode 100644
index 000000000..1127bb3c3
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeader.css
@@ -0,0 +1,53 @@
+.header {
+ display: flex;
+}
+
+.navigationButtons {
+ flex: 1 1 33%;
+ text-align: left;
+}
+
+.todayButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-left: 5px;
+}
+
+.titleDesktop,
+.titleMobile {
+ text-align: center;
+ font-size: 18px;
+}
+
+.titleMobile {
+ margin-bottom: 5px;
+}
+
+.viewButtonsContainer {
+ display: flex;
+ justify-content: flex-end;
+ flex: 1 1 33%;
+}
+
+.viewMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ line-height: 31px;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin-top: 5px;
+ margin-right: 10px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .navigationButtons {
+ flex: 1 0 50%;
+ }
+
+ .viewButtonsContainer {
+ flex: 0 0 100px;
+ }
+}
diff --git a/frontend/src/Calendar/Header/CalendarHeader.js b/frontend/src/Calendar/Header/CalendarHeader.js
new file mode 100644
index 000000000..4fea8356d
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeader.js
@@ -0,0 +1,253 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { align, icons } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Menu from 'Components/Menu/Menu';
+import MenuButton from 'Components/Menu/MenuButton';
+import MenuContent from 'Components/Menu/MenuContent';
+import ViewMenuItem from 'Components/Menu/ViewMenuItem';
+import * as calendarViews from 'Calendar/calendarViews';
+import CalendarHeaderViewButton from './CalendarHeaderViewButton';
+import styles from './CalendarHeader.css';
+
+function getTitle(time, start, end, view, longDateFormat) {
+ const timeMoment = moment(time);
+ const startMoment = moment(start);
+ const endMoment = moment(end);
+
+ if (view === 'day') {
+ return timeMoment.format(longDateFormat);
+ } else if (view === 'month') {
+ return timeMoment.format('MMMM YYYY');
+ } else if (view === 'agenda') {
+ return 'Agenda';
+ }
+
+ let startFormat = 'MMM D YYYY';
+ let endFormat = 'MMM D YYYY';
+
+ if (startMoment.isSame(endMoment, 'month')) {
+ startFormat = 'MMM D';
+ endFormat = 'D YYYY';
+ } else if (startMoment.isSame(endMoment, 'year')) {
+ startFormat = 'MMM D';
+ endFormat = 'MMM D YYYY';
+ }
+
+ return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`;
+}
+
+// TODO Convert to a stateful Component so we can track view internally when changed
+
+class CalendarHeader extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ view: props.view
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const view = this.props.view;
+
+ if (prevProps.view !== view) {
+ this.setState({ view });
+ }
+ }
+
+ //
+ // Listeners
+
+ onViewChange = (view) => {
+ this.setState({ view }, () => {
+ this.props.onViewChange(view);
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ time,
+ start,
+ end,
+ longDateFormat,
+ isSmallScreen,
+ onTodayPress,
+ onPreviousPress,
+ onNextPress
+ } = this.props;
+
+ const view = this.state.view;
+
+ const title = getTitle(time, start, end, view, longDateFormat);
+
+ return (
+
+ {
+ isSmallScreen &&
+
+ {title}
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ Today
+
+
+
+ {
+ !isSmallScreen &&
+
+ {title}
+
+ }
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ isSmallScreen ?
+
+
+
+
+
+
+
+ Week
+
+
+
+ Forecast
+
+
+
+ Day
+
+
+
+ Agenda
+
+
+ :
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+
+
+
+ );
+ }
+}
+
+CalendarHeader.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ time: PropTypes.string.isRequired,
+ start: PropTypes.string.isRequired,
+ end: PropTypes.string.isRequired,
+ view: PropTypes.oneOf(calendarViews.all).isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ onViewChange: PropTypes.func.isRequired,
+ onTodayPress: PropTypes.func.isRequired,
+ onPreviousPress: PropTypes.func.isRequired,
+ onNextPress: PropTypes.func.isRequired
+};
+
+export default CalendarHeader;
diff --git a/frontend/src/Calendar/Header/CalendarHeaderConnector.js b/frontend/src/Calendar/Header/CalendarHeaderConnector.js
new file mode 100644
index 000000000..c96cf2869
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeaderConnector.js
@@ -0,0 +1,84 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import { setCalendarView, gotoCalendarToday, gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions';
+import CalendarHeader from './CalendarHeader';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.calendar,
+ createDimensionsSelector(),
+ createUISettingsSelector(),
+ (calendar, dimensions, uiSettings) => {
+ const result = _.pick(calendar, [
+ 'isFetching',
+ 'view',
+ 'time',
+ 'start',
+ 'end'
+ ]);
+
+ result.isSmallScreen = dimensions.isSmallScreen;
+ result.longDateFormat = uiSettings.longDateFormat;
+
+ return result;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setCalendarView,
+ gotoCalendarToday,
+ gotoCalendarPreviousRange,
+ gotoCalendarNextRange
+};
+
+class CalendarHeaderConnector extends Component {
+
+ //
+ // Listeners
+
+ onViewChange = (view) => {
+ this.props.setCalendarView({ view });
+ }
+
+ onTodayPress = () => {
+ this.props.gotoCalendarToday();
+ }
+
+ onPreviousPress = () => {
+ this.props.gotoCalendarPreviousRange();
+ }
+
+ onNextPress = () => {
+ this.props.gotoCalendarNextRange();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CalendarHeaderConnector.propTypes = {
+ setCalendarView: PropTypes.func.isRequired,
+ gotoCalendarToday: PropTypes.func.isRequired,
+ gotoCalendarPreviousRange: PropTypes.func.isRequired,
+ gotoCalendarNextRange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector);
diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js
new file mode 100644
index 000000000..8dd5ae9f0
--- /dev/null
+++ b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+import Button from 'Components/Link/Button';
+import * as calendarViews from 'Calendar/calendarViews';
+// import styles from './CalendarHeaderViewButton.css';
+
+class CalendarHeaderViewButton extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onPress(this.props.view);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ view,
+ selectedView,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {titleCase(view)}
+
+ );
+ }
+}
+
+CalendarHeaderViewButton.propTypes = {
+ view: PropTypes.oneOf(calendarViews.all).isRequired,
+ selectedView: PropTypes.oneOf(calendarViews.all).isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default CalendarHeaderViewButton;
diff --git a/frontend/src/Calendar/Legend/Legend.css b/frontend/src/Calendar/Legend/Legend.css
new file mode 100644
index 000000000..296cbd9d5
--- /dev/null
+++ b/frontend/src/Calendar/Legend/Legend.css
@@ -0,0 +1,6 @@
+.legend {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 10px;
+ padding: 3px 0;
+}
diff --git a/frontend/src/Calendar/Legend/Legend.js b/frontend/src/Calendar/Legend/Legend.js
new file mode 100644
index 000000000..759ebf5a7
--- /dev/null
+++ b/frontend/src/Calendar/Legend/Legend.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import LegendItem from './LegendItem';
+import styles from './Legend.css';
+
+function Legend({ colorImpairedMode }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+Legend.propTypes = {
+ colorImpairedMode: PropTypes.bool.isRequired
+};
+
+export default Legend;
diff --git a/frontend/src/Calendar/Legend/LegendItem.css b/frontend/src/Calendar/Legend/LegendItem.css
new file mode 100644
index 000000000..fb49d8608
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendItem.css
@@ -0,0 +1,37 @@
+.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';
+}
+
+.unreleased {
+ composes: unreleased from 'Calendar/Events/CalendarEvent.css';
+}
diff --git a/frontend/src/Calendar/Legend/LegendItem.js b/frontend/src/Calendar/Legend/LegendItem.js
new file mode 100644
index 000000000..961f48b86
--- /dev/null
+++ b/frontend/src/Calendar/Legend/LegendItem.js
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import titleCase from 'Utilities/String/titleCase';
+import styles from './LegendItem.css';
+
+function LegendItem(props) {
+ const {
+ name,
+ status,
+ tooltip,
+ colorImpairedMode
+ } = props;
+
+ return (
+
+ {name ? name : titleCase(status)}
+
+ );
+}
+
+LegendItem.propTypes = {
+ name: PropTypes.string,
+ status: PropTypes.string.isRequired,
+ tooltip: PropTypes.string.isRequired,
+ colorImpairedMode: PropTypes.bool.isRequired
+};
+
+export default LegendItem;
diff --git a/frontend/src/Calendar/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..f46c19fb6
--- /dev/null
+++ b/frontend/src/Calendar/getStatusStyle.js
@@ -0,0 +1,26 @@
+/* eslint max-params: 0 */
+import moment from 'moment';
+
+function getStatusStyle(episodeNumber, downloading, startTime, isMonitored) {
+ const currentTime = moment();
+
+ // if (hasFile) {
+ // return 'downloaded';
+ // }
+
+ if (downloading) {
+ return 'downloading';
+ }
+
+ if (!isMonitored) {
+ return 'unmonitored';
+ }
+
+ if (currentTime.isAfter(startTime)) {
+ return 'missing';
+ }
+
+ return 'unreleased';
+}
+
+export default getStatusStyle;
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js
new file mode 100644
index 000000000..8cc487c16
--- /dev/null
+++ b/frontend/src/Calendar/iCal/CalendarLinkModal.js
@@ -0,0 +1,29 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector';
+
+function CalendarLinkModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+CalendarLinkModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default CalendarLinkModal;
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js
new file mode 100644
index 000000000..82ea4e776
--- /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/Lidarr.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 (
+
+
+ Lidarr Calendar Feed
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+CalendarLinkModalContent.propTypes = {
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default CalendarLinkModalContent;
diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js
new file mode 100644
index 000000000..e10c5c3f9
--- /dev/null
+++ b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import CalendarLinkModalContent from './CalendarLinkModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTagsSelector(),
+ (tagList) => {
+ return {
+ tagList
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(CalendarLinkModalContent);
diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js
new file mode 100644
index 000000000..e432c42e2
--- /dev/null
+++ b/frontend/src/Commands/commandNames.js
@@ -0,0 +1,19 @@
+export const APPLICATION_UPDATE = 'ApplicationUpdate';
+export const BACKUP = 'Backup';
+export const CHECK_FOR_FINISHED_DOWNLOAD = 'CheckForFinishedDownload';
+export const CLEAR_BLACKLIST = 'ClearBlacklist';
+export const CLEAR_LOGS = 'ClearLog';
+export const CUTOFF_UNMET_ALBUM_SEARCH = 'CutoffUnmetAlbumSearch';
+export const DELETE_LOG_FILES = 'DeleteLogFiles';
+export const DELETE_UPDATE_LOG_FILES = 'DeleteUpdateLogFiles';
+export const DOWNLOADED_ALBUMS_SCAN = 'DownloadedAlbumsScan';
+export const ALBUM_SEARCH = 'AlbumSearch';
+export const INTERACTIVE_IMPORT = 'ManualImport';
+export const MISSING_ALBUM_SEARCH = 'MissingAlbumSearch';
+export const REFRESH_ARTIST = 'RefreshArtist';
+export const RENAME_FILES = 'RenameFiles';
+export const RENAME_ARTIST = 'RenameArtist';
+export const RESET_API_KEY = 'ResetApiKey';
+export const RSS_SYNC = 'RssSync';
+export const SEASON_SEARCH = 'AlbumSearch';
+export const ARTIST_SEARCH = 'ArtistSearch';
diff --git a/frontend/src/Components/Alert.css b/frontend/src/Components/Alert.css
new file mode 100644
index 000000000..312fbb4f2
--- /dev/null
+++ b/frontend/src/Components/Alert.css
@@ -0,0 +1,31 @@
+.alert {
+ display: block;
+ margin: 5px;
+ padding: 15px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+}
+
+.danger {
+ border-color: $alertDangerBorderColor;
+ background-color: $alertDangerBackgroundColor;
+ color: $alertDangerColor;
+}
+
+.info {
+ border-color: $alertInfoBorderColor;
+ background-color: $alertInfoBackgroundColor;
+ color: $alertInfoColor;
+}
+
+.success {
+ border-color: $alertSuccessBorderColor;
+ background-color: $alertSuccessBackgroundColor;
+ color: $alertSuccessColor;
+}
+
+.warning {
+ border-color: $alertWarningBorderColor;
+ background-color: $alertWarningBackgroundColor;
+ color: $alertWarningColor;
+}
diff --git a/frontend/src/Components/Alert.js b/frontend/src/Components/Alert.js
new file mode 100644
index 000000000..dc19a418c
--- /dev/null
+++ b/frontend/src/Components/Alert.js
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { kinds } from 'Helpers/Props';
+import styles from './Alert.css';
+
+function Alert({ className, kind, children, ...otherProps }) {
+ return (
+
+ {children}
+
+ );
+}
+
+Alert.propTypes = {
+ className: PropTypes.string.isRequired,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ children: PropTypes.node.isRequired
+};
+
+Alert.defaultProps = {
+ className: styles.alert,
+ kind: kinds.INFO
+};
+
+export default Alert;
diff --git a/frontend/src/Components/Card.css b/frontend/src/Components/Card.css
new file mode 100644
index 000000000..e500ca154
--- /dev/null
+++ b/frontend/src/Components/Card.css
@@ -0,0 +1,8 @@
+.card {
+ margin: 10px;
+ padding: 10px;
+ border-radius: 3px;
+ background-color: $white;
+ box-shadow: 0 0 10px 1px $cardShadowColor;
+ color: $defaultColor;
+}
diff --git a/frontend/src/Components/Card.js b/frontend/src/Components/Card.js
new file mode 100644
index 000000000..cc45edba3
--- /dev/null
+++ b/frontend/src/Components/Card.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './Card.css';
+
+class Card extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ onPress
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+Card.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+Card.defaultProps = {
+ className: styles.card
+};
+
+export default Card;
diff --git a/frontend/src/Components/CircularProgressBar.css b/frontend/src/Components/CircularProgressBar.css
new file mode 100644
index 000000000..32b349404
--- /dev/null
+++ b/frontend/src/Components/CircularProgressBar.css
@@ -0,0 +1,21 @@
+.circularProgressBarContainer {
+ position: relative;
+ display: inline-block;
+ vertical-align: top;
+ text-align: center;
+}
+
+.circularProgressBar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ transform: rotate(-90deg);
+ transform-origin: center center;
+}
+
+.circularProgressBarText {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ font-weight: bold;
+}
diff --git a/frontend/src/Components/CircularProgressBar.js b/frontend/src/Components/CircularProgressBar.js
new file mode 100644
index 000000000..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..94cd75ba9
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionList.css
@@ -0,0 +1,4 @@
+.descriptionList {
+ margin-top: 0;
+ margin-bottom: 20px;
+}
diff --git a/frontend/src/Components/DescriptionList/DescriptionList.js b/frontend/src/Components/DescriptionList/DescriptionList.js
new file mode 100644
index 000000000..b7a1d1634
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionList.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './DescriptionList.css';
+
+class DescriptionList extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+DescriptionList.propTypes = {
+ children: PropTypes.node
+};
+
+export default DescriptionList;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js
new file mode 100644
index 000000000..4ba70bf33
--- /dev/null
+++ b/frontend/src/Components/DescriptionList/DescriptionListItem.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import DescriptionListItemTitle from './DescriptionListItemTitle';
+import DescriptionListItemDescription from './DescriptionListItemDescription';
+
+class DescriptionListItem extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ titleClassName,
+ descriptionClassName,
+ title,
+ data
+ } = this.props;
+
+ return (
+
+
+ {title}
+
+
+
+ {data}
+
+
+ );
+ }
+}
+
+DescriptionListItem.propTypes = {
+ titleClassName: PropTypes.string,
+ descriptionClassName: PropTypes.string,
+ title: PropTypes.string,
+ data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
+};
+
+export default DescriptionListItem;
diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css
new file mode 100644
index 000000000..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/FieldSet.css b/frontend/src/Components/FieldSet.css
new file mode 100644
index 000000000..daf3bdf2e
--- /dev/null
+++ b/frontend/src/Components/FieldSet.css
@@ -0,0 +1,19 @@
+.fieldSet {
+ margin: 0;
+ margin-bottom: 20px;
+ padding: 0;
+ min-width: 0;
+ border: 0;
+}
+
+.legend {
+ display: block;
+ margin-bottom: 21px;
+ padding: 0;
+ width: 100%;
+ border: 0;
+ border-bottom: 1px solid #e5e5e5;
+ color: #3a3f51;
+ font-size: 21px;
+ line-height: inherit;
+}
diff --git a/frontend/src/Components/FieldSet.js b/frontend/src/Components/FieldSet.js
new file mode 100644
index 000000000..76e68a934
--- /dev/null
+++ b/frontend/src/Components/FieldSet.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './FieldSet.css';
+
+class FieldSet extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ legend,
+ children
+ } = this.props;
+
+ return (
+
+
+ {legend}
+
+ {children}
+
+ );
+ }
+
+}
+
+FieldSet.propTypes = {
+ legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
+ children: PropTypes.node
+};
+
+export default FieldSet;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.css b/frontend/src/Components/FileBrowser/FileBrowserModal.css
new file mode 100644
index 000000000..30b936800
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModal.css
@@ -0,0 +1,5 @@
+.modal {
+ composes: modal from 'Components/Modal/Modal.css';
+
+ height: 600px;
+}
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.js b/frontend/src/Components/FileBrowser/FileBrowserModal.js
new file mode 100644
index 000000000..6b58dbb8c
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModal.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import FileBrowserModalContentConnector from './FileBrowserModalContentConnector';
+import styles from './FileBrowserModal.css';
+
+class FileBrowserModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+FileBrowserModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default FileBrowserModal;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.css b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css
new file mode 100644
index 000000000..7da2ea225
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css
@@ -0,0 +1,16 @@
+.modalBody {
+ composes: modalBody from 'Components/Modal/ModalBody.css';
+
+ display: flex;
+ flex-direction: column;
+}
+
+.pathInput {
+ composes: pathInputWrapper from 'Components/Form/PathInput.css';
+
+ flex: 0 0 auto;
+}
+
+.scroller {
+ margin-top: 20px;
+}
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js
new file mode 100644
index 000000000..f81019e1c
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js
@@ -0,0 +1,213 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import { scrollDirections } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Scroller from 'Components/Scroller/Scroller';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import PathInput from 'Components/Form/PathInput';
+import FileBrowserRow from './FileBrowserRow';
+import styles from './FileBrowserModalContent.css';
+
+const columns = [
+ {
+ name: 'type',
+ label: 'Type',
+ isVisible: true
+ },
+ {
+ name: 'name',
+ label: 'Name',
+ isVisible: true
+ }
+];
+
+class FileBrowserModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._scrollerNode = null;
+
+ this.state = {
+ isFileBrowserModalOpen: false,
+ currentPath: props.value
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ currentPath
+ } = this.props;
+
+ if (currentPath !== this.state.currentPath) {
+ this.setState({ currentPath });
+ this._scrollerNode.scrollTop = 0;
+ }
+ }
+
+ //
+ // Control
+
+ setScrollerRef = (ref) => {
+ if (ref) {
+ this._scrollerNode = ReactDOM.findDOMNode(ref);
+ } else {
+ this._scrollerNode = null;
+ }
+ }
+
+ //
+ // Listeners
+
+ onPathInputChange = ({ value }) => {
+ this.setState({ currentPath: value });
+ }
+
+ onRowPress = (path) => {
+ this.props.onFetchPaths(path);
+ }
+
+ onOkPress = () => {
+ this.props.onChange({
+ name: this.props.name,
+ value: this.state.currentPath
+ });
+
+ this.props.onClearPaths();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ parent,
+ directories,
+ files,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ const emptyParent = parent === '';
+
+ return (
+
+
+ File Browser
+
+
+
+
+
+
+
+
+ {
+ emptyParent &&
+
+ }
+
+ {
+ !emptyParent && parent &&
+
+ }
+
+ {
+ directories.map((directory) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ files.map((file) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ Ok
+
+
+
+ );
+ }
+}
+
+FileBrowserModalContent.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ parent: PropTypes.string,
+ currentPath: PropTypes.string.isRequired,
+ directories: PropTypes.arrayOf(PropTypes.object).isRequired,
+ files: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onFetchPaths: PropTypes.func.isRequired,
+ onClearPaths: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default FileBrowserModalContent;
diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js
new file mode 100644
index 000000000..adf52fbcd
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js
@@ -0,0 +1,86 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchPaths, clearPaths } from 'Store/Actions/pathActions';
+import FileBrowserModalContent from './FileBrowserModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.paths,
+ (paths) => {
+ const {
+ parent,
+ currentPath,
+ directories,
+ files
+ } = paths;
+
+ const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
+ return path.toLowerCase().startsWith(currentPath.toLowerCase());
+ });
+
+ return {
+ parent,
+ currentPath,
+ directories,
+ files,
+ paths: filteredPaths
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchPaths,
+ clearPaths
+};
+
+class FileBrowserModalContentConnector extends Component {
+
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchPaths({ path: this.props.value });
+ }
+
+ //
+ // Listeners
+
+ onFetchPaths = (path) => {
+ this.props.fetchPaths({ path });
+ }
+
+ onClearPaths = () => {
+ // this.props.clearPaths();
+ }
+
+ onModalClose = () => {
+ this.props.clearPaths();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+FileBrowserModalContentConnector.propTypes = {
+ value: PropTypes.string,
+ fetchPaths: PropTypes.func.isRequired,
+ clearPaths: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector);
diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.css b/frontend/src/Components/FileBrowser/FileBrowserRow.css
new file mode 100644
index 000000000..a9c34be6a
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserRow.css
@@ -0,0 +1,5 @@
+.type {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 32px;
+}
diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.js b/frontend/src/Components/FileBrowser/FileBrowserRow.js
new file mode 100644
index 000000000..42ac30405
--- /dev/null
+++ b/frontend/src/Components/FileBrowser/FileBrowserRow.js
@@ -0,0 +1,62 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import TableRowButton from 'Components/Table/TableRowButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './FileBrowserRow.css';
+
+function getIconName(type) {
+ switch (type) {
+ case 'computer':
+ return icons.COMPUTER;
+ case 'drive':
+ return icons.DRIVE;
+ case 'file':
+ return icons.FILE;
+ case 'parent':
+ return icons.PARENT;
+ default:
+ return icons.FOLDER;
+ }
+}
+
+class FileBrowserRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onPress(this.props.path);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ type,
+ name
+ } = this.props;
+
+ return (
+
+
+
+
+
+ {name}
+
+ );
+ }
+
+}
+
+FileBrowserRow.propTypes = {
+ type: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default FileBrowserRow;
diff --git a/frontend/src/Components/Form/CaptchaInput.css b/frontend/src/Components/Form/CaptchaInput.css
new file mode 100644
index 000000000..e7cd1dc4e
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInput.css
@@ -0,0 +1,23 @@
+.captchaInputWrapper {
+ display: flex;
+}
+
+.input {
+ composes: input from 'Components/Form/Input.css';
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.hasButton {
+ composes: hasButton from 'Components/Form/Input.css';
+}
+
+.recaptchaWrapper {
+ margin-top: 10px;
+}
diff --git a/frontend/src/Components/Form/CaptchaInput.js b/frontend/src/Components/Form/CaptchaInput.js
new file mode 100644
index 000000000..a8600255a
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInput.js
@@ -0,0 +1,86 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ReCAPTCHA from 'react-google-recaptcha';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FormInputButton from './FormInputButton';
+import TextInput from './TextInput';
+import styles from './CaptchaInput.css';
+
+function CaptchaInput(props) {
+ const {
+ className,
+ name,
+ value,
+ hasError,
+ hasWarning,
+ refreshing,
+ siteKey,
+ secretToken,
+ onChange,
+ onRefreshPress,
+ onCaptchaChange
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ !!siteKey && !!secretToken &&
+
+
+
+ }
+
+ );
+}
+
+CaptchaInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ refreshing: PropTypes.bool.isRequired,
+ siteKey: PropTypes.string,
+ secretToken: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onCaptchaChange: PropTypes.func.isRequired
+};
+
+CaptchaInput.defaultProps = {
+ className: styles.input,
+ value: ''
+};
+
+export default CaptchaInput;
diff --git a/frontend/src/Components/Form/CaptchaInputConnector.js b/frontend/src/Components/Form/CaptchaInputConnector.js
new file mode 100644
index 000000000..17b875c88
--- /dev/null
+++ b/frontend/src/Components/Form/CaptchaInputConnector.js
@@ -0,0 +1,98 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { refreshCaptcha, getCaptchaCookie, resetCaptcha } from 'Store/Actions/captchaActions';
+import CaptchaInput from './CaptchaInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.captcha,
+ (captcha) => {
+ return captcha;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ refreshCaptcha,
+ getCaptchaCookie,
+ resetCaptcha
+};
+
+class CaptchaInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ const {
+ name,
+ token,
+ onChange
+ } = this.props;
+
+ if (token && token !== prevProps.token) {
+ onChange({ name, value: token });
+ }
+ }
+
+ componentWillUnmount = () => {
+ this.props.resetCaptcha();
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ const {
+ provider,
+ providerData
+ } = this.props;
+
+ this.props.refreshCaptcha({ provider, providerData });
+ }
+
+ onCaptchaChange = (captchaResponse) => {
+ // If the captcha has expired `captchaResponse` will be null.
+ // In the event it's null don't try to get the captchaCookie.
+ // TODO: Should we clear the cookie? or reset the captcha?
+
+ if (!captchaResponse) {
+ return;
+ }
+
+ const {
+ provider,
+ providerData
+ } = this.props;
+
+ this.props.getCaptchaCookie({ provider, providerData, captchaResponse });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CaptchaInputConnector.propTypes = {
+ provider: PropTypes.string.isRequired,
+ providerData: PropTypes.object.isRequired,
+ name: PropTypes.string.isRequired,
+ token: PropTypes.string,
+ onChange: PropTypes.func.isRequired,
+ refreshCaptcha: PropTypes.func.isRequired,
+ getCaptchaCookie: PropTypes.func.isRequired,
+ resetCaptcha: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector);
diff --git a/frontend/src/Components/Form/CheckInput.css b/frontend/src/Components/Form/CheckInput.css
new file mode 100644
index 000000000..5c35e5d2f
--- /dev/null
+++ b/frontend/src/Components/Form/CheckInput.css
@@ -0,0 +1,105 @@
+.container {
+ position: relative;
+ display: flex;
+ flex: 1 1 65%;
+ user-select: none;
+}
+
+.label {
+ display: flex;
+ margin-bottom: 0;
+ min-height: 21px;
+ font-weight: normal;
+ cursor: pointer;
+}
+
+.checkbox {
+ position: absolute;
+ opacity: 0;
+ cursor: pointer;
+ pointer-events: none;
+
+ &:global(.isDisabled) {
+ cursor: not-allowed;
+ }
+}
+
+.input {
+ flex: 1 0 auto;
+ margin-top: 7px;
+ margin-right: 5px;
+ width: 20px;
+ height: 20px;
+ border: 1px solid #ccc;
+ border-radius: 2px;
+ background-color: $white;
+ color: $white;
+ text-align: center;
+ line-height: 20px;
+}
+
+.checkbox:focus + .input {
+ outline: 0;
+ border-color: $inputFocusBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
+}
+
+.dangerIsChecked {
+ border-color: $dangerColor;
+ background-color: $dangerColor;
+
+ &.isDisabled {
+ opacity: 0.7;
+ }
+}
+
+.primaryIsChecked {
+ border-color: $primaryColor;
+ background-color: $primaryColor;
+
+ &.isDisabled {
+ opacity: 0.7;
+ }
+}
+
+.successIsChecked {
+ border-color: $successColor;
+ background-color: $successColor;
+
+ &.isDisabled {
+ opacity: 0.7;
+ }
+}
+
+.warningIsChecked {
+ border-color: $warningColor;
+ background-color: $warningColor;
+
+ &.isDisabled {
+ opacity: 0.7;
+ }
+}
+
+.isNotChecked {
+ &.isDisabled {
+ border-color: $disabledCheckInputColor;
+ background-color: $disabledCheckInputColor;
+ opacity: 0.7;
+ }
+}
+
+.isIndeterminate {
+ border-color: $gray;
+ background-color: $gray;
+}
+
+.helpText {
+ composes: helpText from 'Components/Form/FormInputHelpText.css';
+
+ margin-top: 8px;
+ margin-left: 5px;
+}
+
+.isDisabled {
+ cursor: not-allowed;
+}
diff --git a/frontend/src/Components/Form/CheckInput.js b/frontend/src/Components/Form/CheckInput.js
new file mode 100644
index 000000000..0fc0be9cd
--- /dev/null
+++ b/frontend/src/Components/Form/CheckInput.js
@@ -0,0 +1,187 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FormInputHelpText from './FormInputHelpText';
+import styles from './CheckInput.css';
+
+class CheckInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._checkbox = null;
+ }
+
+ componentDidMount() {
+ this.setIndeterminate();
+ }
+
+ componentDidUpdate() {
+ this.setIndeterminate();
+ }
+
+ //
+ // Control
+
+ setIndeterminate() {
+ if (!this._checkbox) {
+ return;
+ }
+
+ const {
+ value,
+ uncheckedValue,
+ checkedValue
+ } = this.props;
+
+ this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue;
+ }
+
+ toggleChecked = (checked, shiftKey) => {
+ const {
+ name,
+ value,
+ checkedValue,
+ uncheckedValue
+ } = this.props;
+
+ const newValue = checked ? checkedValue : uncheckedValue;
+
+ if (value !== newValue) {
+ this.props.onChange({
+ name,
+ value: newValue,
+ shiftKey
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ setRef = (ref) => {
+ this._checkbox = ref;
+ }
+
+ onClick = (event) => {
+ const shiftKey = event.nativeEvent.shiftKey;
+ const checked = !this._checkbox.checked;
+
+ event.preventDefault();
+ this.toggleChecked(checked, shiftKey);
+ }
+
+ onChange = (event) => {
+ const checked = event.target.checked;
+ const shiftKey = event.nativeEvent.shiftKey;
+
+ this.toggleChecked(checked, shiftKey);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ containerClassName,
+ name,
+ value,
+ checkedValue,
+ uncheckedValue,
+ helpText,
+ helpTextWarning,
+ isDisabled,
+ kind
+ } = this.props;
+
+ const isChecked = value === checkedValue;
+ const isUnchecked = value === uncheckedValue;
+ const isIndeterminate = !isChecked && !isUnchecked;
+ const isCheckClass = `${kind}IsChecked`;
+
+ return (
+
+
+
+
+
+ {
+ isChecked &&
+
+ }
+
+ {
+ isIndeterminate &&
+
+ }
+
+
+ {
+ helpText &&
+
+ }
+
+ {
+ !helpText && helpTextWarning &&
+
+ }
+
+
+ );
+ }
+}
+
+CheckInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ containerClassName: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ checkedValue: PropTypes.bool,
+ uncheckedValue: PropTypes.bool,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
+ helpText: PropTypes.string,
+ helpTextWarning: PropTypes.string,
+ isDisabled: PropTypes.bool,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+CheckInput.defaultProps = {
+ className: styles.input,
+ containerClassName: styles.container,
+ checkedValue: true,
+ uncheckedValue: false,
+ kind: kinds.PRIMARY
+};
+
+export default CheckInput;
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css
new file mode 100644
index 000000000..4662cc581
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInput.css
@@ -0,0 +1,66 @@
+.tether {
+ z-index: 2000;
+}
+
+.enhancedSelect {
+ composes: input from 'Components/Form/Input.css';
+ composes: link from 'Components/Link/Link.css';
+
+ position: relative;
+ display: flex;
+ align-items: center;
+ padding: 6px 16px;
+ width: 100%;
+ height: 35px;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+ color: $black;
+ cursor: default;
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.isDisabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
+
+.dropdownArrowContainer {
+ margin-left: 12px;
+}
+
+.optionsContainer {
+ width: auto;
+}
+
+.options {
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
+
+.optionsModal {
+ display: flex;
+ justify-content: center;
+ max-width: 90%;
+ width: 350px !important;
+ height: auto !important;
+}
+
+.optionsInnerModalBody {
+ composes: innerModalBody from 'Components/Modal/ModalBody.css';
+
+ padding: 0;
+ width: 100%;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js
new file mode 100644
index 000000000..6310b4854
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInput.js
@@ -0,0 +1,399 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import Measure from 'react-measure';
+import TetherComponent from 'react-tether';
+import classNames from 'classnames';
+import isMobileUtil from 'Utilities/isMobile';
+import * as keyCodes from 'Utilities/Constants/keyCodes';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import Modal from 'Components/Modal/Modal';
+import ModalBody from 'Components/Modal/ModalBody';
+import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
+import EnhancedSelectInputOption from './EnhancedSelectInputOption';
+import styles from './EnhancedSelectInput.css';
+
+const tetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ],
+ attachment: 'top left',
+ targetAttachment: 'bottom left'
+};
+
+function isArrowKey(keyCode) {
+ return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
+}
+
+function getSelectedOption(selectedIndex, values) {
+ return values[selectedIndex];
+}
+
+function findIndex(startingIndex, direction, values) {
+ let indexToTest = startingIndex + direction;
+
+ while (indexToTest !== startingIndex) {
+ if (indexToTest < 0) {
+ indexToTest = values.length - 1;
+ } else if (indexToTest >= values.length) {
+ indexToTest = 0;
+ }
+
+ if (getSelectedOption(indexToTest, values).isDisabled) {
+ indexToTest = indexToTest + direction;
+ } else {
+ return indexToTest;
+ }
+ }
+}
+
+function previousIndex(selectedIndex, values) {
+ return findIndex(selectedIndex, -1, values);
+}
+
+function nextIndex(selectedIndex, values) {
+ return findIndex(selectedIndex, 1, values);
+}
+
+function getSelectedIndex(props) {
+ const {
+ value,
+ values
+ } = props;
+
+ return values.findIndex((v) => {
+ return v.key === value;
+ });
+}
+
+function getKey(selectedIndex, values) {
+ return values[selectedIndex].key;
+}
+
+class EnhancedSelectInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOpen: false,
+ selectedIndex: getSelectedIndex(props),
+ width: 0,
+ isMobile: isMobileUtil()
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.value !== this.props.value) {
+ this.setState({
+ selectedIndex: getSelectedIndex(this.props)
+ });
+ }
+ }
+
+ //
+ // Control
+
+ _setButtonRef = (ref) => {
+ this._buttonRef = ref;
+ }
+
+ _setOptionsRef = (ref) => {
+ this._optionsRef = ref;
+ }
+
+ _addListener() {
+ window.addEventListener('click', this.onWindowClick);
+ }
+
+ _removeListener() {
+ window.removeEventListener('click', this.onWindowClick);
+ }
+
+ //
+ // Listeners
+
+ onWindowClick = (event) => {
+ const button = ReactDOM.findDOMNode(this._buttonRef);
+ const options = ReactDOM.findDOMNode(this._optionsRef);
+
+ if (!button || this.state.isMobile) {
+ return;
+ }
+
+ if (
+ !button.contains(event.target) &&
+ options &&
+ !options.contains(event.target) &&
+ this.state.isOpen
+ ) {
+ this.setState({ isOpen: false });
+ this._removeListener();
+ }
+ }
+
+ onBlur = () => {
+ this.setState({
+ selectedIndex: getSelectedIndex(this.props)
+ });
+ }
+
+ onKeyDown = (event) => {
+ const {
+ values
+ } = this.props;
+
+ const {
+ isOpen,
+ selectedIndex
+ } = this.state;
+
+ const keyCode = event.keyCode;
+ const newState = {};
+
+ if (!isOpen) {
+ if (isArrowKey(keyCode)) {
+ event.preventDefault();
+ newState.isOpen = true;
+ }
+
+ if (
+ selectedIndex == null ||
+ getSelectedOption(selectedIndex, values).isDisabled
+ ) {
+ if (keyCode === keyCodes.UP_ARROW) {
+ newState.selectedIndex = previousIndex(0, values);
+ } else if (keyCode === keyCodes.DOWN_ARROW) {
+ newState.selectedIndex = nextIndex(values.length - 1, values);
+ }
+ }
+
+ this.setState(newState);
+ return;
+ }
+
+ if (keyCode === keyCodes.UP_ARROW) {
+ event.preventDefault();
+ newState.selectedIndex = previousIndex(selectedIndex, values);
+ }
+
+ if (keyCode === keyCodes.DOWN_ARROW) {
+ event.preventDefault();
+ newState.selectedIndex = nextIndex(selectedIndex, values);
+ }
+
+ if (keyCode === keyCodes.ENTER) {
+ event.preventDefault();
+ newState.isOpen = false;
+ this.onSelect(getKey(selectedIndex, values));
+ }
+
+ if (keyCode === keyCodes.TAB) {
+ newState.isOpen = false;
+ this.onSelect(getKey(selectedIndex, values));
+ }
+
+ if (keyCode === keyCodes.ESCAPE) {
+ event.preventDefault();
+ event.stopPropagation();
+ newState.isOpen = false;
+ newState.selectedIndex = getSelectedIndex(this.props);
+ }
+
+ if (!_.isEmpty(newState)) {
+ this.setState(newState);
+ }
+ }
+
+ onPress = () => {
+ if (this.state.isOpen) {
+ this._removeListener();
+ } else {
+ this._addListener();
+ }
+
+ this.setState({ isOpen: !this.state.isOpen });
+ }
+
+ onSelect = (value) => {
+ this.setState({ isOpen: false });
+
+ this.props.onChange({
+ name: this.props.name,
+ value
+ });
+ }
+
+ onMeasure = ({ width }) => {
+ this.setState({ width });
+ }
+
+ onOptionsModalClose = () => {
+ this.setState({ isOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ disabledClassName,
+ values,
+ isDisabled,
+ hasError,
+ hasWarning,
+ selectedValueOptions,
+ selectedValueComponent: SelectedValueComponent,
+ optionComponent: OptionComponent
+ } = this.props;
+
+ const {
+ selectedIndex,
+ width,
+ isOpen,
+ isMobile
+ } = this.state;
+
+ const selectedOption = getSelectedOption(selectedIndex, values);
+
+ return (
+
+
+
+
+
+ {selectedOption ? selectedOption.value : null}
+
+
+
+
+
+
+
+
+ {
+ isOpen && !isMobile &&
+
+
+ {
+ values.map((v, index) => {
+ return (
+
+ {v.value}
+
+ );
+ })
+ }
+
+
+ }
+
+
+ {
+ isMobile &&
+
+
+ {
+ values.map((v, index) => {
+ return (
+
+ {v.value}
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+ }
+}
+
+EnhancedSelectInput.propTypes = {
+ className: PropTypes.string,
+ disabledClassName: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDisabled: PropTypes.bool,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ selectedValueOptions: PropTypes.object.isRequired,
+ selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
+ optionComponent: PropTypes.func,
+ onChange: PropTypes.func.isRequired
+};
+
+EnhancedSelectInput.defaultProps = {
+ className: styles.enhancedSelect,
+ disabledClassName: styles.isDisabled,
+ isDisabled: false,
+ selectedValueOptions: {},
+ selectedValueComponent: EnhancedSelectInputSelectedValue,
+ optionComponent: EnhancedSelectInputOption
+};
+
+export default EnhancedSelectInput;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.css b/frontend/src/Components/Form/EnhancedSelectInputOption.css
new file mode 100644
index 000000000..dedf7beaa
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputOption.css
@@ -0,0 +1,37 @@
+.option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 5px 10px;
+ width: 100%;
+ cursor: default;
+
+ &:hover {
+ background-color: #f9f9f9;
+ }
+}
+
+.isSelected {
+ background-color: #e2e2e2;
+
+ &.isMobile {
+ background-color: inherit;
+
+ .iconContainer {
+ color: $primaryColor;
+ }
+ }
+}
+
+.isDisabled {
+ background-color: #aaa;
+}
+
+.isMobile {
+ height: 50px;
+ border-bottom: 1px solid $borderColor;
+
+ &:last-child {
+ border: none;
+ }
+}
diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js
new file mode 100644
index 000000000..a1a161c79
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputOption.js
@@ -0,0 +1,77 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import styles from './EnhancedSelectInputOption.css';
+
+class EnhancedSelectInputOption extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ id,
+ onSelect
+ } = this.props;
+
+ onSelect(id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ isSelected,
+ isDisabled,
+ isMobile,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ {
+ isMobile &&
+
+
+
+ }
+
+ );
+ }
+}
+
+EnhancedSelectInputOption.propTypes = {
+ className: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ isMobile: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired,
+ onSelect: PropTypes.func.isRequired
+};
+
+EnhancedSelectInputOption.defaultProps = {
+ className: styles.option,
+ isDisabled: false
+};
+
+export default EnhancedSelectInputOption;
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css
new file mode 100644
index 000000000..aab9f1b7d
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css
@@ -0,0 +1,3 @@
+.selectedValue {
+ flex: 1 1 auto;
+}
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
new file mode 100644
index 000000000..2343fedc2
--- /dev/null
+++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './EnhancedSelectInputSelectedValue.css';
+
+function EnhancedSelectInputSelectedValue(props) {
+ const {
+ className,
+ children
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+EnhancedSelectInputSelectedValue.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node
+};
+
+EnhancedSelectInputSelectedValue.defaultProps = {
+ className: styles.selectedValue
+};
+
+export default EnhancedSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/Form.css b/frontend/src/Components/Form/Form.css
new file mode 100644
index 000000000..987b1a0a1
--- /dev/null
+++ b/frontend/src/Components/Form/Form.css
@@ -0,0 +1,11 @@
+.form {
+
+}
+
+.error {
+ color: $dangerColor;
+}
+
+.warning {
+ color: $warningColor;
+}
diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js
new file mode 100644
index 000000000..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..41a56eff0
--- /dev/null
+++ b/frontend/src/Components/Form/FormGroup.css
@@ -0,0 +1,24 @@
+.group {
+ display: flex;
+ margin-bottom: 20px;
+}
+
+/* Sizes */
+
+.small {
+ max-width: $formGroupSmallWidth;
+}
+
+.medium {
+ max-width: $formGroupMediumWidth;
+}
+
+.large {
+ max-width: $formGroupLargeWidth;
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .group {
+ display: block;
+ }
+}
diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js
new file mode 100644
index 000000000..edec4b86d
--- /dev/null
+++ b/frontend/src/Components/Form/FormGroup.js
@@ -0,0 +1,56 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { map } from 'Helpers/elementChildren';
+import { sizes } from 'Helpers/Props';
+import styles from './FormGroup.css';
+
+function FormGroup(props) {
+ const {
+ className,
+ children,
+ size,
+ advancedSettings,
+ isAdvanced,
+ ...otherProps
+ } = props;
+
+ if (!advancedSettings && isAdvanced) {
+ return null;
+ }
+
+ const childProps = isAdvanced ? { isAdvanced } : {};
+
+ return (
+
+ {
+ map(children, (child) => {
+ return React.cloneElement(child, childProps);
+ })
+ }
+
+ );
+}
+
+FormGroup.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+ size: PropTypes.string.isRequired,
+ advancedSettings: PropTypes.bool.isRequired,
+ isAdvanced: PropTypes.bool.isRequired
+};
+
+FormGroup.defaultProps = {
+ className: styles.group,
+ size: sizes.SMALL,
+ advancedSettings: false,
+ isAdvanced: false
+};
+
+export default FormGroup;
diff --git a/frontend/src/Components/Form/FormInputButton.css b/frontend/src/Components/Form/FormInputButton.css
new file mode 100644
index 000000000..27a1923be
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputButton.css
@@ -0,0 +1,12 @@
+.button {
+ composes: button from 'Components/Link/Button.css';
+
+ border-left: none;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.middleButton {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js
new file mode 100644
index 000000000..4b6491663
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputButton.js
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import { kinds } from 'Helpers/Props';
+import styles from './FormInputButton.css';
+
+function FormInputButton(props) {
+ const {
+ className,
+ canSpin,
+ isLastButton,
+ ...otherProps
+ } = props;
+
+ if (canSpin) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+FormInputButton.propTypes = {
+ className: PropTypes.string.isRequired,
+ isLastButton: PropTypes.bool.isRequired,
+ canSpin: PropTypes.bool.isRequired
+};
+
+FormInputButton.defaultProps = {
+ className: styles.button,
+ isLastButton: true,
+ canSpin: false
+};
+
+export default FormInputButton;
diff --git a/frontend/src/Components/Form/FormInputGroup.css b/frontend/src/Components/Form/FormInputGroup.css
new file mode 100644
index 000000000..ef7a29809
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.css
@@ -0,0 +1,30 @@
+.inputGroupContainer {
+ flex: 1 1 auto;
+}
+
+.inputGroup {
+ display: flex;
+ flex: 1 1 auto;
+ flex-wrap: wrap;
+}
+
+.inputContainer {
+ flex: 1 1 auto;
+}
+
+.pendingChangesContainer {
+ display: flex;
+ justify-content: flex-end;
+ width: 30px;
+}
+
+.pendingChangesIcon {
+ color: $warningColor;
+ font-size: 20px;
+ line-height: 35px;
+}
+
+.helpLink {
+ margin-top: 5px;
+ line-height: 20px;
+}
diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js
new file mode 100644
index 000000000..7002a10a3
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputGroup.js
@@ -0,0 +1,234 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import CaptchaInputConnector from './CaptchaInputConnector';
+import CheckInput from './CheckInput';
+import MonitorAlbumsSelectInput from './MonitorAlbumsSelectInput';
+import NumberInput from './NumberInput';
+import OAuthInputConnector from './OAuthInputConnector';
+import PasswordInput from './PasswordInput';
+import PathInputConnector from './PathInputConnector';
+import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector';
+import LanguageProfileSelectInputConnector from './LanguageProfileSelectInputConnector';
+import RootFolderSelectInputConnector from './RootFolderSelectInputConnector';
+import SeriesTypeSelectInput from './SeriesTypeSelectInput';
+import SelectInput from './SelectInput';
+import TagInputConnector from './TagInputConnector';
+import TextTagInputConnector from './TextTagInputConnector';
+import TextInput from './TextInput';
+import FormInputHelpText from './FormInputHelpText';
+import styles from './FormInputGroup.css';
+
+function getComponent(type) {
+ switch (type) {
+ case inputTypes.CAPTCHA:
+ return CaptchaInputConnector;
+
+ case inputTypes.CHECK:
+ return CheckInput;
+
+ case inputTypes.MONITOR_ALBUMS_SELECT:
+ return MonitorAlbumsSelectInput;
+
+ case inputTypes.NUMBER:
+ return NumberInput;
+
+ case inputTypes.OAUTH:
+ return OAuthInputConnector;
+
+ case inputTypes.PASSWORD:
+ return PasswordInput;
+
+ case inputTypes.PATH:
+ return PathInputConnector;
+
+ case inputTypes.QUALITY_PROFILE_SELECT:
+ return QualityProfileSelectInputConnector;
+
+ case inputTypes.LANGUAGE_PROFILE_SELECT:
+ return LanguageProfileSelectInputConnector;
+
+ case inputTypes.ROOT_FOLDER_SELECT:
+ return RootFolderSelectInputConnector;
+
+ case inputTypes.SELECT:
+ return SelectInput;
+
+ case inputTypes.SERIES_TYPE_SELECT:
+ return SeriesTypeSelectInput;
+
+ case inputTypes.TAG:
+ return TagInputConnector;
+
+ case inputTypes.TEXT_TAG:
+ return TextTagInputConnector;
+
+ default:
+ return TextInput;
+ }
+}
+
+function FormInputGroup(props) {
+ const {
+ className,
+ containerClassName,
+ inputClassName,
+ type,
+ buttons,
+ helpText,
+ helpTexts,
+ helpTextWarning,
+ helpLink,
+ pending,
+ errors,
+ warnings,
+ ...otherProps
+ } = props;
+
+ const InputComponent = getComponent(type);
+ const checkInput = type === inputTypes.CHECK;
+ const hasError = !!errors.length;
+ const hasWarning = !hasError && !!warnings.length;
+ const buttonsArray = React.Children.toArray(buttons);
+ const lastButtonIndex = buttonsArray.length - 1;
+ const hasButton = !!buttonsArray.length;
+
+ return (
+
+
+
+
+
+
+ {
+ buttonsArray.map((button, index) => {
+ return React.cloneElement(
+ button,
+ {
+ isLastButton: index === lastButtonIndex
+ }
+ );
+ })
+ }
+
+ {/*
+ {
+ pending &&
+
+ }
+
*/}
+
+
+ {
+ !checkInput && helpText &&
+
+ }
+
+ {
+ !checkInput && helpTexts &&
+
+ {
+ helpTexts.map((text, index) => {
+ return (
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !checkInput && helpTextWarning &&
+
+ }
+
+ {
+ helpLink &&
+
+ More Info
+
+ }
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+FormInputGroup.propTypes = {
+ className: PropTypes.string.isRequired,
+ containerClassName: PropTypes.string.isRequired,
+ inputClassName: PropTypes.string,
+ type: PropTypes.string.isRequired,
+ buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]),
+ helpText: PropTypes.string,
+ helpTexts: PropTypes.arrayOf(PropTypes.string),
+ helpTextWarning: PropTypes.string,
+ helpLink: PropTypes.string,
+ pending: PropTypes.bool,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object)
+};
+
+FormInputGroup.defaultProps = {
+ className: styles.inputGroup,
+ containerClassName: styles.inputGroupContainer,
+ type: inputTypes.TEXT,
+ buttons: [],
+ helpTexts: [],
+ errors: [],
+ warnings: []
+};
+
+export default FormInputGroup;
diff --git a/frontend/src/Components/Form/FormInputHelpText.css b/frontend/src/Components/Form/FormInputHelpText.css
new file mode 100644
index 000000000..c760d957c
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputHelpText.css
@@ -0,0 +1,39 @@
+.helpText {
+ margin-top: 5px;
+ color: $helpTextColor;
+ line-height: 20px;
+}
+
+.isError {
+ color: $dangerColor;
+
+ .link {
+ color: $dangerColor;
+
+ &:hover {
+ color: #e01313;
+ }
+ }
+}
+
+.isWarning {
+ color: $warningColor;
+
+ .link {
+ color: $warningColor;
+
+ &:hover {
+ color: #e36c00;
+ }
+ }
+}
+
+.isCheckInput {
+ padding-left: 30px;
+}
+
+.link {
+ composes: link from 'Components/Link/Link.css';
+
+ margin-left: 5px;
+}
diff --git a/frontend/src/Components/Form/FormInputHelpText.js b/frontend/src/Components/Form/FormInputHelpText.js
new file mode 100644
index 000000000..d9195568b
--- /dev/null
+++ b/frontend/src/Components/Form/FormInputHelpText.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import styles from './FormInputHelpText.css';
+
+function FormInputHelpText(props) {
+ const {
+ className,
+ text,
+ link,
+ linkTooltip,
+ isError,
+ isWarning,
+ isCheckInput
+ } = props;
+
+ return (
+
+ {text}
+
+ {
+ !!link &&
+
+
+
+ }
+
+ );
+}
+
+FormInputHelpText.propTypes = {
+ className: PropTypes.string.isRequired,
+ text: PropTypes.string.isRequired,
+ link: PropTypes.string,
+ linkTooltip: PropTypes.string,
+ isError: PropTypes.bool,
+ isWarning: PropTypes.bool,
+ isCheckInput: PropTypes.bool
+};
+
+FormInputHelpText.defaultProps = {
+ className: styles.helpText,
+ isError: false,
+ isWarning: false,
+ isCheckInput: false
+};
+
+export default FormInputHelpText;
diff --git a/frontend/src/Components/Form/FormLabel.css b/frontend/src/Components/Form/FormLabel.css
new file mode 100644
index 000000000..ad175f202
--- /dev/null
+++ b/frontend/src/Components/Form/FormLabel.css
@@ -0,0 +1,22 @@
+.label {
+ display: flex;
+ justify-content: flex-end;
+ flex: 0 0 $formLabelWidth;
+ margin-right: $formLabelRightMarginWidth;
+ font-weight: bold;
+ line-height: 35px;
+}
+
+.hasError {
+ color: $dangerColor;
+}
+
+.isAdvanced {
+ color: $advancedFormLabelColor;
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .label {
+ justify-content: flex-start;
+ }
+}
diff --git a/frontend/src/Components/Form/FormLabel.js b/frontend/src/Components/Form/FormLabel.js
new file mode 100644
index 000000000..dca800826
--- /dev/null
+++ b/frontend/src/Components/Form/FormLabel.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import styles from './FormLabel.css';
+
+function FormLabel({
+ children,
+ className,
+ errorClassName,
+ name,
+ hasError,
+ isAdvanced,
+ ...otherProps
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+FormLabel.propTypes = {
+ children: PropTypes.node.isRequired,
+ className: PropTypes.string,
+ errorClassName: PropTypes.string,
+ name: PropTypes.string,
+ hasError: PropTypes.bool,
+ isAdvanced: PropTypes.bool.isRequired
+};
+
+FormLabel.defaultProps = {
+ className: styles.label,
+ errorClassName: styles.hasError,
+ isAdvanced: false
+};
+
+export default FormLabel;
diff --git a/frontend/src/Components/Form/Input.css b/frontend/src/Components/Form/Input.css
new file mode 100644
index 000000000..e9ca23d8f
--- /dev/null
+++ b/frontend/src/Components/Form/Input.css
@@ -0,0 +1,30 @@
+.input {
+ padding: 6px 16px;
+ width: 100%;
+ height: 35px;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+
+ &:focus {
+ outline: 0;
+ border-color: $inputFocusBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
+ }
+}
+
+.hasError {
+ border-color: $inputErrorBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputErrorBoxShadowColor;
+}
+
+.hasWarning {
+ border-color: $inputWarningBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputWarningBoxShadowColor;
+}
+
+.hasButton {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
diff --git a/frontend/src/Components/Form/LanguageProfileSelectInputConnector.js b/frontend/src/Components/Form/LanguageProfileSelectInputConnector.js
new file mode 100644
index 000000000..970400a64
--- /dev/null
+++ b/frontend/src/Components/Form/LanguageProfileSelectInputConnector.js
@@ -0,0 +1,98 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import sortByName from 'Utilities/Array/sortByName';
+import SelectInput from './SelectInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.languageProfiles,
+ (state, { includeNoChange }) => includeNoChange,
+ (state, { includeMixed }) => includeMixed,
+ (languageProfiles, includeNoChange, includeMixed) => {
+ const values = _.map(languageProfiles.items.sort(sortByName), (languageProfile) => {
+ return {
+ key: languageProfile.id,
+ value: languageProfile.name
+ };
+ });
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: 'No Change',
+ disabled: true
+ });
+ }
+
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ value: '(Mixed)',
+ disabled: true
+ });
+ }
+
+ return {
+ values
+ };
+ }
+ );
+}
+
+class LanguageProfileSelectInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ name,
+ value,
+ values
+ } = this.props;
+
+ if (!value || !_.some(values, (option) => parseInt(option.key) === value)) {
+ const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key)));
+
+ if (firstValue) {
+ this.onChange({ name, value: firstValue.key });
+ }
+ }
+ }
+
+ //
+ // Listeners
+
+ onChange = ({ name, value }) => {
+ this.props.onChange({ name, value: parseInt(value) });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+LanguageProfileSelectInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ includeNoChange: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+LanguageProfileSelectInputConnector.defaultProps = {
+ includeNoChange: false
+};
+
+export default connect(createMapStateToProps)(LanguageProfileSelectInputConnector);
diff --git a/frontend/src/Components/Form/MonitorAlbumsSelectInput.js b/frontend/src/Components/Form/MonitorAlbumsSelectInput.js
new file mode 100644
index 000000000..71f39a146
--- /dev/null
+++ b/frontend/src/Components/Form/MonitorAlbumsSelectInput.js
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import SelectInput from './SelectInput';
+
+const monitorOptions = [
+ { key: 'all', value: 'All Albums' },
+ { key: 'future', value: 'Future Albums' },
+ { key: 'missing', value: 'Missing Albums' },
+ { key: 'existing', value: 'Existing Albums' },
+ { key: 'first', value: 'Only First Album' },
+ { key: 'latest', value: 'Only Latest Album' },
+ { key: 'none', value: 'None' }
+];
+
+function MonitorAlbumsSelectInput(props) {
+ const {
+ includeNoChange,
+ includeMixed,
+ ...otherProps
+ } = props;
+
+ const values = [...monitorOptions];
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: 'No Change',
+ disabled: true
+ });
+ }
+
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ value: '(Mixed)',
+ disabled: true
+ });
+ }
+
+ return (
+
+ );
+}
+
+MonitorAlbumsSelectInput.propTypes = {
+ includeNoChange: PropTypes.bool.isRequired,
+ includeMixed: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+MonitorAlbumsSelectInput.defaultProps = {
+ includeNoChange: false,
+ includeMixed: false
+};
+
+export default MonitorAlbumsSelectInput;
diff --git a/frontend/src/Components/Form/NumberInput.js b/frontend/src/Components/Form/NumberInput.js
new file mode 100644
index 000000000..b760c0a72
--- /dev/null
+++ b/frontend/src/Components/Form/NumberInput.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TextInput from './TextInput';
+
+class NumberInput extends Component {
+
+ //
+ // Listeners
+
+ onChange = ({ name, value }) => {
+ let newValue = null;
+
+ if (value) {
+ newValue = this.props.isFloat ? parseFloat(value) : parseInt(value);
+ }
+
+ this.props.onChange({
+ name,
+ value: newValue
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+NumberInput.propTypes = {
+ value: PropTypes.number,
+ isFloat: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+NumberInput.defaultProps = {
+ value: null,
+ isFloat: false
+};
+
+export default NumberInput;
diff --git a/frontend/src/Components/Form/OAuthInput.js b/frontend/src/Components/Form/OAuthInput.js
new file mode 100644
index 000000000..ddbc1e310
--- /dev/null
+++ b/frontend/src/Components/Form/OAuthInput.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+
+function OAuthInput(props) {
+ const {
+ authorizing,
+ onPress
+ } = props;
+
+ return (
+
+
+ Start OAuth
+
+
+ );
+}
+
+OAuthInput.propTypes = {
+ authorizing: PropTypes.bool.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default OAuthInput;
diff --git a/frontend/src/Components/Form/OAuthInputConnector.js b/frontend/src/Components/Form/OAuthInputConnector.js
new file mode 100644
index 000000000..6e9ad110c
--- /dev/null
+++ b/frontend/src/Components/Form/OAuthInputConnector.js
@@ -0,0 +1,82 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { startOAuth, resetOAuth } from 'Store/Actions/oAuthActions';
+import OAuthInput from './OAuthInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.oAuth,
+ (oAuth) => {
+ return oAuth;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ startOAuth,
+ resetOAuth
+};
+
+class OAuthInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ const {
+ accessToken,
+ accessTokenSecret,
+ onChange
+ } = this.props;
+
+ if (accessToken &&
+ accessToken !== prevProps.accessToken &&
+ accessTokenSecret &&
+ accessTokenSecret !== prevProps.accessTokenSecret) {
+ onChange({ name: 'AccessToken', value: accessToken });
+ onChange({ name: 'AccessTokenSecret', value: accessTokenSecret });
+ }
+ }
+
+ componentWillUnmount = () => {
+ this.props.resetOAuth();
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ provider,
+ providerData
+ } = this.props;
+
+ this.props.startOAuth({ provider, providerData });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+OAuthInputConnector.propTypes = {
+ accessToken: PropTypes.string,
+ accessTokenSecret: PropTypes.string,
+ provider: PropTypes.string.isRequired,
+ providerData: PropTypes.object.isRequired,
+ onChange: PropTypes.func.isRequired,
+ startOAuth: PropTypes.func.isRequired,
+ resetOAuth: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(OAuthInputConnector);
diff --git a/frontend/src/Components/Form/PasswordInput.js b/frontend/src/Components/Form/PasswordInput.js
new file mode 100644
index 000000000..2560ce3c2
--- /dev/null
+++ b/frontend/src/Components/Form/PasswordInput.js
@@ -0,0 +1,13 @@
+import React from 'react';
+import TextInput from './TextInput';
+
+function PasswordInput(props) {
+ return (
+
+ );
+}
+
+export default PasswordInput;
diff --git a/frontend/src/Components/Form/PathInput.css b/frontend/src/Components/Form/PathInput.css
new file mode 100644
index 000000000..d3851b204
--- /dev/null
+++ b/frontend/src/Components/Form/PathInput.css
@@ -0,0 +1,68 @@
+.path {
+ composes: input from 'Components/Form/Input.css';
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.hasFileBrowser {
+ composes: hasButton from 'Components/Form/Input.css';
+}
+
+.pathInputWrapper {
+ display: flex;
+}
+
+.pathInputContainer {
+ position: relative;
+ flex-grow: 1;
+}
+
+.pathContainer {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.pathInputContainerOpen {
+ .pathContainer {
+ position: absolute;
+ z-index: 1;
+ overflow-y: auto;
+ max-height: 200px;
+ width: 100%;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+ }
+}
+
+.pathList {
+ margin: 5px 0;
+ padding-left: 0;
+ list-style-type: none;
+}
+
+.pathListItem {
+ padding: 0 16px;
+}
+
+.pathMatch {
+ font-weight: bold;
+}
+
+.pathHighlighted {
+ background-color: $menuItemHoverColor;
+}
+
+.fileBrowserButton {
+ composes: button from './FormInputButton.css';
+
+ height: 35px;
+}
diff --git a/frontend/src/Components/Form/PathInput.js b/frontend/src/Components/Form/PathInput.js
new file mode 100644
index 000000000..84e8209d8
--- /dev/null
+++ b/frontend/src/Components/Form/PathInput.js
@@ -0,0 +1,204 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Autosuggest from 'react-autosuggest';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import FormInputButton from './FormInputButton';
+import styles from './PathInput.css';
+
+class PathInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isFileBrowserModalOpen: false
+ };
+ }
+
+ //
+ // Control
+
+ getSuggestionValue({ path }) {
+ return path;
+ }
+
+ renderSuggestion({ path }, { query }) {
+ const lastSeparatorIndex = query.lastIndexOf('\\') || query.lastIndexOf('/');
+
+ if (lastSeparatorIndex === -1) {
+ return (
+ {path}
+ );
+ }
+
+ return (
+
+
+ {path.substr(0, lastSeparatorIndex)}
+
+ {path.substr(lastSeparatorIndex)}
+
+ );
+ }
+
+ //
+ // Listeners
+
+ onInputChange = (event, { newValue }) => {
+ this.props.onChange({
+ name: this.props.name,
+ value: newValue
+ });
+ }
+
+ onInputKeyDown = (event) => {
+ if (event.key === 'Tab') {
+ event.preventDefault();
+ const path = this.props.paths[0];
+
+ this.props.onChange({
+ name: this.props.name,
+ value: path.path
+ });
+
+ if (path.type !== 'file') {
+ this.props.onFetchPaths(path.path);
+ }
+ }
+ }
+
+ onInputBlur = () => {
+ this.props.onClearPaths();
+ }
+
+ onSuggestionsFetchRequested = ({ value }) => {
+ this.props.onFetchPaths(value);
+ }
+
+ onSuggestionsClearRequested = () => {
+ // Required because props aren't always rendered, but no-op
+ // because we don't want to reset the paths after a path is selected.
+ }
+
+ onSuggestionSelected = (event, { suggestionValue }) => {
+ this.props.onFetchPaths(suggestionValue);
+ }
+
+ onFileBrowserOpenPress = () => {
+ this.setState({ isFileBrowserModalOpen: true });
+ }
+
+ onFileBrowserModalClose = () => {
+ this.setState({ isFileBrowserModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ inputClassName,
+ name,
+ value,
+ placeholder,
+ paths,
+ hasError,
+ hasWarning,
+ hasFileBrowser,
+ onChange
+ } = this.props;
+
+ const inputProps = {
+ className: classNames(
+ inputClassName,
+ hasError && styles.hasError,
+ hasWarning && styles.hasWarning,
+ hasFileBrowser && styles.hasFileBrowser
+ ),
+ name,
+ value,
+ placeholder,
+ autoComplete: 'off',
+ spellCheck: false,
+ onChange: this.onInputChange,
+ onKeyDown: this.onInputKeyDown,
+ onBlur: this.onInputBlur
+ };
+
+ const theme = {
+ container: styles.pathInputContainer,
+ containerOpen: styles.pathInputContainerOpen,
+ suggestionsContainer: styles.pathContainer,
+ suggestionsList: styles.pathList,
+ suggestion: styles.pathListItem,
+ suggestionHighlighted: styles.pathHighlighted
+ };
+
+ return (
+
+
+
+ {
+ hasFileBrowser &&
+
+
+
+
+
+
+
+ }
+
+ );
+ }
+}
+
+PathInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ inputClassName: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string,
+ placeholder: PropTypes.string,
+ paths: PropTypes.array.isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ hasFileBrowser: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ onFetchPaths: PropTypes.func.isRequired,
+ onClearPaths: PropTypes.func.isRequired
+};
+
+PathInput.defaultProps = {
+ className: styles.pathInputWrapper,
+ inputClassName: styles.path,
+ value: '',
+ hasFileBrowser: true
+};
+
+export default PathInput;
diff --git a/frontend/src/Components/Form/PathInputConnector.js b/frontend/src/Components/Form/PathInputConnector.js
new file mode 100644
index 000000000..4916daec8
--- /dev/null
+++ b/frontend/src/Components/Form/PathInputConnector.js
@@ -0,0 +1,67 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchPaths, clearPaths } from 'Store/Actions/pathActions';
+import PathInput from './PathInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.paths,
+ (paths) => {
+ const {
+ currentPath,
+ directories,
+ files
+ } = paths;
+
+ const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
+ return path.toLowerCase().startsWith(currentPath.toLowerCase());
+ });
+
+ return {
+ paths: filteredPaths
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchPaths,
+ clearPaths
+};
+
+class PathInputConnector extends Component {
+
+ //
+ // Listeners
+
+ onFetchPaths = (path) => {
+ this.props.fetchPaths({ path });
+ }
+
+ onClearPaths = () => {
+ this.props.clearPaths();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+PathInputConnector.propTypes = {
+ fetchPaths: PropTypes.func.isRequired,
+ clearPaths: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(PathInputConnector);
diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js
new file mode 100644
index 000000000..c7b422251
--- /dev/null
+++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js
@@ -0,0 +1,126 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function getType(type) {
+ // Textbox,
+ // Password,
+ // Checkbox,
+ // Select,
+ // Path,
+ // FilePath,
+ // Hidden,
+ // Tag,
+ // Action,
+ // Url,
+ // Captcha
+ // OAuth
+
+ switch (type) {
+ case 'captcha':
+ return inputTypes.CAPTCHA;
+ case 'checkbox':
+ return inputTypes.CHECK;
+ case 'password':
+ return inputTypes.PASSWORD;
+ case 'path':
+ return inputTypes.PATH;
+ case 'select':
+ return inputTypes.SELECT;
+ case 'textbox':
+ return inputTypes.TEXT;
+ case 'oauth':
+ return inputTypes.OAUTH;
+ default:
+ return inputTypes.TEXT;
+ }
+}
+
+function getSelectValues(selectOptions) {
+ if (!selectOptions) {
+ return;
+ }
+
+ return _.reduce(selectOptions, (result, option) => {
+ result.push({
+ key: option.value,
+ value: option.name
+ });
+
+ return result;
+ }, []);
+}
+
+function ProviderFieldFormGroup(props) {
+ const {
+ advancedSettings,
+ name,
+ label,
+ helpText,
+ helpLink,
+ value,
+ type,
+ advanced,
+ pending,
+ errors,
+ warnings,
+ selectOptions,
+ onChange,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {label}
+
+
+
+ );
+}
+
+const selectOptionsShape = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.number.isRequired
+};
+
+ProviderFieldFormGroup.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ helpText: PropTypes.string,
+ helpLink: PropTypes.string,
+ value: PropTypes.any,
+ type: PropTypes.string.isRequired,
+ advanced: PropTypes.bool.isRequired,
+ pending: PropTypes.bool.isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object).isRequired,
+ warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectOptions: PropTypes.arrayOf(PropTypes.shape(selectOptionsShape)),
+ onChange: PropTypes.func.isRequired
+};
+
+ProviderFieldFormGroup.defaultProps = {
+ advancedSettings: false
+};
+
+export default ProviderFieldFormGroup;
diff --git a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js
new file mode 100644
index 000000000..16e0e46f2
--- /dev/null
+++ b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js
@@ -0,0 +1,98 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import sortByName from 'Utilities/Array/sortByName';
+import SelectInput from './SelectInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (state, { includeNoChange }) => includeNoChange,
+ (state, { includeMixed }) => includeMixed,
+ (qualityProfiles, includeNoChange, includeMixed) => {
+ const values = _.map(qualityProfiles.items.sort(sortByName), (qualityProfile) => {
+ return {
+ key: qualityProfile.id,
+ value: qualityProfile.name
+ };
+ });
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: 'No Change',
+ disabled: true
+ });
+ }
+
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ value: '(Mixed)',
+ disabled: true
+ });
+ }
+
+ return {
+ values
+ };
+ }
+ );
+}
+
+class QualityProfileSelectInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ name,
+ value,
+ values
+ } = this.props;
+
+ if (!value || !_.some(values, (option) => parseInt(option.key) === value)) {
+ const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key)));
+
+ if (firstValue) {
+ this.onChange({ name, value: firstValue.key });
+ }
+ }
+ }
+
+ //
+ // Listeners
+
+ onChange = ({ name, value }) => {
+ this.props.onChange({ name, value: parseInt(value) });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityProfileSelectInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ includeNoChange: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired
+};
+
+QualityProfileSelectInputConnector.defaultProps = {
+ includeNoChange: false
+};
+
+export default connect(createMapStateToProps)(QualityProfileSelectInputConnector);
diff --git a/frontend/src/Components/Form/RootFolderSelectInput.js b/frontend/src/Components/Form/RootFolderSelectInput.js
new file mode 100644
index 000000000..5cc27a72b
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInput.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
+import EnhancedSelectInput from './EnhancedSelectInput';
+import RootFolderSelectInputOption from './RootFolderSelectInputOption';
+import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue';
+
+class RootFolderSelectInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddNewRootFolderModalOpen: false,
+ newRootFolderPath: ''
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ name,
+ values,
+ isSaving,
+ saveError,
+ onChange
+ } = this.props;
+
+ if (
+ prevProps.isSaving &&
+ !isSaving &&
+ !saveError &&
+ values.length - prevProps.values.length === 1
+ ) {
+ const newRootFolderPath = this.state.newRootFolderPath;
+
+ onChange({ name, value: newRootFolderPath });
+ this.setState({ newRootFolderPath: '' });
+ }
+ }
+
+ //
+ // Listeners
+
+ onChange = ({ name, value }) => {
+ if (value === 'addNew') {
+ this.setState({ isAddNewRootFolderModalOpen: true });
+ } else {
+ this.props.onChange({ name, value });
+ }
+ }
+
+ onNewRootFolderSelect = ({ value }) => {
+ this.setState({ newRootFolderPath: value }, () => {
+ this.props.onNewRootFolderSelect(value);
+ });
+ }
+
+ onAddRootFolderModalClose = () => {
+ this.setState({ isAddNewRootFolderModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ includeNoChange,
+ onNewRootFolderSelect,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+RootFolderSelectInput.propTypes = {
+ name: PropTypes.string.isRequired,
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ includeNoChange: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onNewRootFolderSelect: PropTypes.func.isRequired
+};
+
+RootFolderSelectInput.defaultProps = {
+ includeNoChange: false
+};
+
+export default RootFolderSelectInput;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputConnector.js b/frontend/src/Components/Form/RootFolderSelectInputConnector.js
new file mode 100644
index 000000000..b9ba1a992
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputConnector.js
@@ -0,0 +1,124 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { addRootFolder } from 'Store/Actions/rootFolderActions';
+import RootFolderSelectInput from './RootFolderSelectInput';
+
+const ADD_NEW_KEY = 'addNew';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.rootFolders,
+ (state, { includeNoChange }) => includeNoChange,
+ (rootFolders, includeNoChange) => {
+ const values = _.map(rootFolders.items, (rootFolder) => {
+ return {
+ key: rootFolder.path,
+ value: rootFolder.path,
+ freeSpace: rootFolder.freeSpace
+ };
+ });
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: 'No Change',
+ isDisabled: true
+ });
+ }
+
+ if (!values.length) {
+ values.push({
+ key: '',
+ value: '',
+ isDisabled: true
+ });
+ }
+
+ values.push({
+ key: ADD_NEW_KEY,
+ value: 'Add a new path'
+ });
+
+ return {
+ values,
+ isSaving: rootFolders.isSaving,
+ saveError: rootFolders.saveError
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchAddRootFolder(path) {
+ dispatch(addRootFolder({ path }));
+ }
+ };
+}
+
+class RootFolderSelectInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ name,
+ value,
+ values,
+ onChange
+ } = this.props;
+
+ if (!value || !_.some(values, (v) => v.hasOwnProperty(value)) || value === ADD_NEW_KEY) {
+ const defaultValue = values[0];
+
+ if (defaultValue.key === ADD_NEW_KEY) {
+ onChange({ name, value: '' });
+ } else {
+ onChange({ name, value: defaultValue.key });
+ }
+ }
+ }
+
+ //
+ // Listeners
+
+ onNewRootFolderSelect = (path) => {
+ this.props.dispatchAddRootFolder(path);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchAddRootFolder,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+RootFolderSelectInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string,
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ includeNoChange: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ dispatchAddRootFolder: PropTypes.func.isRequired
+};
+
+RootFolderSelectInputConnector.defaultProps = {
+ includeNoChange: false
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(RootFolderSelectInputConnector);
diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.css b/frontend/src/Components/Form/RootFolderSelectInputOption.css
new file mode 100644
index 000000000..061587119
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputOption.css
@@ -0,0 +1,20 @@
+.optionText {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex: 1 0 0;
+
+ &.isMobile {
+ display: block;
+
+ .freeSpace {
+ margin-left: 0;
+ }
+ }
+}
+
+.freeSpace {
+ margin-left: 15px;
+ color: $gray;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.js b/frontend/src/Components/Form/RootFolderSelectInputOption.js
new file mode 100644
index 000000000..a4db9cd82
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputOption.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import formatBytes from 'Utilities/Number/formatBytes';
+import EnhancedSelectInputOption from './EnhancedSelectInputOption';
+import styles from './RootFolderSelectInputOption.css';
+
+function RootFolderSelectInputOption(props) {
+ const {
+ value,
+ freeSpace,
+ isMobile,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
{value}
+
+ {
+ freeSpace != null &&
+
+ {formatBytes(freeSpace)} Free
+
+ }
+
+
+ );
+}
+
+RootFolderSelectInputOption.propTypes = {
+ value: PropTypes.string.isRequired,
+ freeSpace: PropTypes.number,
+ isMobile: PropTypes.bool.isRequired
+};
+
+export default RootFolderSelectInputOption;
diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css
new file mode 100644
index 000000000..d1fdcb08e
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css
@@ -0,0 +1,24 @@
+.selectedValue {
+ composes: selectedValue from './EnhancedSelectInputSelectedValue.css';
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ overflow: hidden;
+}
+
+.path {
+ @add-mixin truncate;
+
+ flex: 1 0 0;
+}
+
+.freeSpace {
+ @add-mixin truncate;
+
+ flex: 1 0 0;
+ margin-left: 15px;
+ color: $gray;
+ text-align: right;
+ font-size: $smallFontSize;
+}
diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js
new file mode 100644
index 000000000..e25664fee
--- /dev/null
+++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatBytes from 'Utilities/Number/formatBytes';
+import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
+import styles from './RootFolderSelectInputSelectedValue.css';
+
+function RootFolderSelectInputSelectedValue(props) {
+ const {
+ value,
+ freeSpace,
+ includeFreeSpace,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ {value}
+
+
+ {
+ freeSpace != null && includeFreeSpace &&
+
+ {formatBytes(freeSpace)} Free
+
+ }
+
+ );
+}
+
+RootFolderSelectInputSelectedValue.propTypes = {
+ value: PropTypes.string.isRequired,
+ freeSpace: PropTypes.number,
+ includeFreeSpace: PropTypes.bool.isRequired
+};
+
+RootFolderSelectInputSelectedValue.defaultProps = {
+ includeFreeSpace: true
+};
+
+export default RootFolderSelectInputSelectedValue;
diff --git a/frontend/src/Components/Form/SelectInput.css b/frontend/src/Components/Form/SelectInput.css
new file mode 100644
index 000000000..5f1c10e83
--- /dev/null
+++ b/frontend/src/Components/Form/SelectInput.css
@@ -0,0 +1,18 @@
+.select {
+ composes: input from 'Components/Form/Input.css';
+
+ padding: 0 11px;
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.isDisabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+}
diff --git a/frontend/src/Components/Form/SelectInput.js b/frontend/src/Components/Form/SelectInput.js
new file mode 100644
index 000000000..79218f54f
--- /dev/null
+++ b/frontend/src/Components/Form/SelectInput.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import styles from './SelectInput.css';
+
+class SelectInput extends Component {
+
+ //
+ // Listeners
+
+ onChange = (event) => {
+ this.props.onChange({
+ name: this.props.name,
+ value: event.target.value
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ disabledClassName,
+ name,
+ value,
+ values,
+ isDisabled,
+ hasError,
+ hasWarning
+ } = this.props;
+
+ return (
+
+ {
+ values.map((option) => {
+ const {
+ key,
+ value: optionValue,
+ ...otherOptionProps
+ } = option;
+
+ return (
+
+ {optionValue}
+
+ );
+ })
+ }
+
+ );
+ }
+}
+
+SelectInput.propTypes = {
+ className: PropTypes.string,
+ disabledClassName: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
+ values: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDisabled: PropTypes.bool,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ onChange: PropTypes.func.isRequired
+};
+
+SelectInput.defaultProps = {
+ className: styles.select,
+ disabledClassName: styles.isDisabled,
+ isDisabled: false
+};
+
+export default SelectInput;
diff --git a/frontend/src/Components/Form/SeriesTypeSelectInput.js b/frontend/src/Components/Form/SeriesTypeSelectInput.js
new file mode 100644
index 000000000..4fe0a974c
--- /dev/null
+++ b/frontend/src/Components/Form/SeriesTypeSelectInput.js
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import SelectInput from './SelectInput';
+
+const artistTypeOptions = [
+ { key: 'standard', value: 'Standard' },
+ { key: 'daily', value: 'Daily' },
+ { key: 'anime', value: 'Anime' }
+];
+
+function SeriesTypeSelectInput(props) {
+ const values = [...artistTypeOptions];
+
+ const {
+ includeNoChange,
+ includeMixed
+ } = props;
+
+ if (includeNoChange) {
+ values.unshift({
+ key: 'noChange',
+ value: 'No Change',
+ disabled: true
+ });
+ }
+
+ if (includeMixed) {
+ values.unshift({
+ key: 'mixed',
+ value: '(Mixed)',
+ disabled: true
+ });
+ }
+
+ return (
+
+ );
+}
+
+SeriesTypeSelectInput.propTypes = {
+ includeNoChange: PropTypes.bool.isRequired,
+ includeMixed: PropTypes.bool.isRequired
+};
+
+SeriesTypeSelectInput.defaultProps = {
+ includeNoChange: false,
+ includeMixed: false
+};
+
+export default SeriesTypeSelectInput;
diff --git a/frontend/src/Components/Form/TagInput.css b/frontend/src/Components/Form/TagInput.css
new file mode 100644
index 000000000..87853ccfb
--- /dev/null
+++ b/frontend/src/Components/Form/TagInput.css
@@ -0,0 +1,97 @@
+.container {
+ composes: input from 'Components/Form/Input.css';
+
+ display: flex;
+ flex-wrap: wrap;
+ min-height: 35px;
+ height: auto;
+}
+
+.containerFocused {
+ outline: 0;
+ border-color: $inputFocusBorderColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor, 0 0 8px $inputFocusBoxShadowColor;
+}
+
+.selectedTagContainer {
+ flex: 0 0 auto;
+}
+
+.selectedTag {
+ composes: label from 'Components/Label.css';
+
+ border-style: none;
+ font-size: 13px;
+}
+
+/* Selected Tag Kinds */
+
+.info {
+ composes: info from 'Components/Label.css';
+}
+
+.success {
+ composes: success from 'Components/Label.css';
+}
+
+.warning {
+ composes: warning from 'Components/Label.css';
+}
+
+.danger {
+ composes: danger from 'Components/Label.css';
+}
+
+.searchInputContainer {
+ position: relative;
+ flex: 1 0 100px;
+ margin-top: 1px;
+ padding-left: 5px;
+}
+
+.searchInput {
+ max-width: 100%;
+ font-size: 13px;
+
+ input {
+ margin: 0;
+ padding: 0;
+ max-width: 100%;
+ outline: none;
+ border: 0;
+ }
+}
+
+.suggestions {
+ position: absolute;
+ z-index: 1;
+ overflow-y: auto;
+ max-height: 200px;
+ width: 100%;
+ border: 1px solid $inputBorderColor;
+ border-radius: 4px;
+ background-color: $white;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+
+ ul {
+ margin: 5px 0;
+ padding-left: 0;
+ list-style-type: none;
+ }
+
+ li {
+ padding: 0 16px;
+ }
+
+ li mark {
+ font-weight: bold;
+ }
+
+ li:hover {
+ background-color: $menuItemHoverColor;
+ }
+}
+
+.suggestionActive {
+ background-color: $menuItemHoverColor;
+}
diff --git a/frontend/src/Components/Form/TagInput.js b/frontend/src/Components/Form/TagInput.js
new file mode 100644
index 000000000..3ad360308
--- /dev/null
+++ b/frontend/src/Components/Form/TagInput.js
@@ -0,0 +1,126 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactTags from 'react-tag-autocomplete';
+import classNames from 'classnames';
+import { kinds } from 'Helpers/Props';
+import styles from './TagInput.css';
+
+class TagInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._tagsRef = null;
+ this._inputRef = null;
+ }
+
+ //
+ // Control
+
+ _setTagsRef = (ref) => {
+ this._tagsRef = ref;
+
+ if (ref) {
+ this._inputRef = this._tagsRef.input.input;
+
+ this._inputRef.addEventListener('blur', this.onInputBlur);
+ } else if (this._inputRef) {
+ this._inputRef.removeEventListener('blur', this.onInputBlur);
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputBlur = () => {
+ if (!this._tagsRef) {
+ return;
+ }
+
+ const {
+ tagList,
+ allowNew
+ } = this.props;
+
+ const query = this._tagsRef.state.query.trim();
+
+ if (query) {
+ const existingTag = _.find(tagList, { name: query });
+
+ if (existingTag) {
+ this._tagsRef.addTag(existingTag);
+ } else if (allowNew) {
+ this._tagsRef.addTag({ name: query });
+ }
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ tags,
+ tagList,
+ allowNew,
+ kind,
+ placeholder,
+ onTagAdd,
+ onTagDelete
+ } = this.props;
+
+ const tagInputClassNames = {
+ root: styles.container,
+ rootFocused: styles.containerFocused,
+ selected: styles.selectedTagContainer,
+ selectedTag: classNames(styles.selectedTag, styles[kind]),
+ search: styles.searchInputContainer,
+ searchInput: styles.searchInput,
+ suggestions: styles.suggestions,
+ suggestionActive: styles.suggestionActive,
+ suggestionDisabled: styles.suggestionDisabled
+ };
+
+ return (
+
+ );
+ }
+}
+
+const tagShape = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired
+};
+
+TagInput.propTypes = {
+ tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ allowNew: PropTypes.bool.isRequired,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ placeholder: PropTypes.string.isRequired,
+ onTagAdd: PropTypes.func.isRequired,
+ onTagDelete: PropTypes.func.isRequired
+};
+
+TagInput.defaultProps = {
+ allowNew: true,
+ kind: kinds.INFO,
+ placeholder: ''
+};
+
+export default TagInput;
diff --git a/frontend/src/Components/Form/TagInputConnector.js b/frontend/src/Components/Form/TagInputConnector.js
new file mode 100644
index 000000000..163b36895
--- /dev/null
+++ b/frontend/src/Components/Form/TagInputConnector.js
@@ -0,0 +1,156 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { addTag } from 'Store/Actions/tagActions';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import TagInput from './TagInput';
+
+const validTagRegex = new RegExp('[^-_a-z0-9]', 'i');
+
+function isValidTag(tagName) {
+ try {
+ return !validTagRegex.test(tagName);
+ } catch (e) {
+ return false;
+ }
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { value }) => value,
+ createTagsSelector(),
+ (tags, tagList) => {
+ const sortedTags = _.sortBy(tagList, 'label');
+ const filteredTagList = _.filter(sortedTags, (tag) => _.indexOf(tags, tag.id) === -1);
+
+ return {
+ tags: tags.reduce((acc, tag) => {
+ const matchingTag = _.find(tagList, { id: tag });
+
+ if (matchingTag) {
+ acc.push({
+ id: tag,
+ name: matchingTag.label
+ });
+ }
+
+ return acc;
+ }, []),
+
+ tagList: filteredTagList.map(({ id, label: name }) => {
+ return {
+ id,
+ name
+ };
+ }),
+
+ allTags: sortedTags
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ addTag
+};
+
+class TagInputConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ name,
+ value,
+ tags,
+ onChange
+ } = this.props;
+
+ if (value.length !== tags.length) {
+ onChange({ name, value: tags.map((tag) => tag.id) });
+ }
+ }
+
+ //
+ // Listeners
+
+ onTagAdd = (tag) => {
+ const {
+ name,
+ value,
+ allTags
+ } = this.props;
+
+ if (!tag.id) {
+ const existingTag =_.some(allTags, { label: tag.name });
+
+ if (isValidTag(tag.name) && !existingTag) {
+ this.props.addTag({
+ tag: { label: tag.name },
+ onTagCreated: this.onTagCreated
+ });
+ }
+
+ return;
+ }
+
+ const newValue = value.slice();
+ newValue.push(tag.id);
+
+ this.props.onChange({ name, value: newValue });
+ }
+
+ onTagDelete = (index) => {
+ const {
+ name,
+ value
+ } = this.props;
+
+ const newValue = value.slice();
+ newValue.splice(index, 1);
+
+ this.props.onChange({
+ name,
+ value: newValue
+ });
+ }
+
+ onTagCreated = (tag) => {
+ const {
+ name,
+ value
+ } = this.props;
+
+ const newValue = value.slice();
+ newValue.push(tag.id);
+
+ this.props.onChange({ name, value: newValue });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+TagInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tags: PropTypes.arrayOf(PropTypes.object).isRequired,
+ allTags: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onChange: PropTypes.func.isRequired,
+ addTag: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(TagInputConnector);
diff --git a/frontend/src/Components/Form/TextInput.css b/frontend/src/Components/Form/TextInput.css
new file mode 100644
index 000000000..25278adbc
--- /dev/null
+++ b/frontend/src/Components/Form/TextInput.css
@@ -0,0 +1,19 @@
+.text {
+ composes: input from 'Components/Form/Input.css';
+}
+
+.readOnly {
+ background-color: #eee;
+}
+
+.hasError {
+ composes: hasError from 'Components/Form/Input.css';
+}
+
+.hasWarning {
+ composes: hasWarning from 'Components/Form/Input.css';
+}
+
+.hasButton {
+ composes: hasButton from 'Components/Form/Input.css';
+}
diff --git a/frontend/src/Components/Form/TextInput.js b/frontend/src/Components/Form/TextInput.js
new file mode 100644
index 000000000..67c7776bf
--- /dev/null
+++ b/frontend/src/Components/Form/TextInput.js
@@ -0,0 +1,81 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import styles from './TextInput.css';
+
+class TextInput extends Component {
+
+ //
+ // Listeners
+
+ onChange = (event) => {
+ this.props.onChange({
+ name: this.props.name,
+ value: event.target.value
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ type,
+ readOnly,
+ autoFocus,
+ placeholder,
+ name,
+ value,
+ hasError,
+ hasWarning,
+ hasButton,
+ onFocus
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+TextInput.propTypes = {
+ className: PropTypes.string.isRequired,
+ type: PropTypes.string.isRequired,
+ readOnly: PropTypes.bool,
+ autoFocus: PropTypes.bool,
+ placeholder: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]).isRequired,
+ hasError: PropTypes.bool,
+ hasWarning: PropTypes.bool,
+ hasButton: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ onFocus: PropTypes.func
+};
+
+TextInput.defaultProps = {
+ className: styles.text,
+ type: 'text',
+ readOnly: false,
+ autoFocus: false,
+ value: ''
+};
+
+export default TextInput;
diff --git a/frontend/src/Components/Form/TextTagInput.js b/frontend/src/Components/Form/TextTagInput.js
new file mode 100644
index 000000000..ae9d35baa
--- /dev/null
+++ b/frontend/src/Components/Form/TextTagInput.js
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactTags from 'react-tag-autocomplete';
+import classNames from 'classnames';
+import { kinds } from 'Helpers/Props';
+import styles from './TagInput.css';
+
+class TextTagInput extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ tags,
+ allowNew,
+ kind,
+ placeholder,
+ onTagAdd,
+ onTagDelete
+ } = this.props;
+
+ const tagInputClassNames = {
+ root: styles.container,
+ rootFocused: styles.containerFocused,
+ selected: styles.selectedTagContainer,
+ selectedTag: classNames(styles.selectedTag, styles[kind]),
+ search: styles.searchInputContainer,
+ searchInput: styles.searchInput,
+ suggestions: styles.suggestions,
+ suggestionActive: styles.suggestionActive,
+ suggestionDisabled: styles.suggestionDisabled
+ };
+
+ return (
+
+ );
+ }
+}
+
+const tagShape = {
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired
+};
+
+TextTagInput.propTypes = {
+ tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
+ allowNew: PropTypes.bool.isRequired,
+ kind: PropTypes.string.isRequired,
+ placeholder: PropTypes.string,
+ onTagAdd: PropTypes.func.isRequired,
+ onTagDelete: PropTypes.func.isRequired
+};
+
+TextTagInput.defaultProps = {
+ allowNew: true,
+ kind: kinds.INFO
+};
+
+export default TextTagInput;
diff --git a/frontend/src/Components/Form/TextTagInputConnector.js b/frontend/src/Components/Form/TextTagInputConnector.js
new file mode 100644
index 000000000..2c9e52cbc
--- /dev/null
+++ b/frontend/src/Components/Form/TextTagInputConnector.js
@@ -0,0 +1,81 @@
+
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import split from 'Utilities/String/split';
+import TextTagInput from './TextTagInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { value }) => value,
+ (tags) => {
+ return {
+ tags: split(tags).reduce((result, tag) => {
+ if (tag) {
+ result.push({
+ id: tag,
+ name: tag
+ });
+ }
+
+ return result;
+ }, [])
+ };
+ }
+ );
+}
+
+class TextTagInputConnector extends Component {
+
+ //
+ // Listeners
+
+ onTagAdd = (tag) => {
+ const {
+ name,
+ value
+ } = this.props;
+
+ const newValue = split(value);
+ newValue.push(tag.name);
+
+ this.props.onChange({ name, value: newValue.join(',') });
+ }
+
+ onTagDelete = (index) => {
+ const {
+ name,
+ value
+ } = this.props;
+
+ const newValue = split(value);
+ newValue.splice(index, 1);
+
+ this.props.onChange({
+ name,
+ value: newValue.join(',')
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+TextTagInputConnector.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string,
+ onChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, null)(TextTagInputConnector);
diff --git a/frontend/src/Components/HeartRating.css b/frontend/src/Components/HeartRating.css
new file mode 100644
index 000000000..705adfcae
--- /dev/null
+++ b/frontend/src/Components/HeartRating.css
@@ -0,0 +1,4 @@
+.heart {
+ margin-right: 5px;
+ color: $themeRed;
+}
diff --git a/frontend/src/Components/HeartRating.js b/frontend/src/Components/HeartRating.js
new file mode 100644
index 000000000..98c3f817e
--- /dev/null
+++ b/frontend/src/Components/HeartRating.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import styles from './HeartRating.css';
+
+function HeartRating({ rating, iconSize }) {
+ return (
+
+
+
+ {rating * 10}%
+
+ );
+}
+
+HeartRating.propTypes = {
+ rating: PropTypes.number.isRequired,
+ iconSize: PropTypes.number.isRequired
+};
+
+HeartRating.defaultProps = {
+ iconSize: 14
+};
+
+export default HeartRating;
diff --git a/frontend/src/Components/Icon.css b/frontend/src/Components/Icon.css
new file mode 100644
index 000000000..4298ea38f
--- /dev/null
+++ b/frontend/src/Components/Icon.css
@@ -0,0 +1,15 @@
+.danger {
+ color: $dangerColor;
+}
+
+.default {
+ color: inherit;
+}
+
+.success {
+ color: $successColor;
+}
+
+.warning {
+ color: $warningColor;
+}
diff --git a/frontend/src/Components/Icon.js b/frontend/src/Components/Icon.js
new file mode 100644
index 000000000..4fd1c4c11
--- /dev/null
+++ b/frontend/src/Components/Icon.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import classNames from 'classnames';
+import styles from './Icon.css';
+
+function Icon(props) {
+ const {
+ className,
+ name,
+ kind,
+ size,
+ title
+ } = props;
+
+ return (
+
+ );
+}
+
+Icon.propTypes = {
+ className: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ kind: PropTypes.string.isRequired,
+ size: PropTypes.number.isRequired,
+ title: PropTypes.string
+};
+
+Icon.defaultProps = {
+ kind: kinds.DEFAULT,
+ size: 14
+};
+
+export default Icon;
diff --git a/frontend/src/Components/Label.css b/frontend/src/Components/Label.css
new file mode 100644
index 000000000..62f4af6c2
--- /dev/null
+++ b/frontend/src/Components/Label.css
@@ -0,0 +1,102 @@
+.label {
+ display: inline-block;
+ margin: 2px;
+ border: 1px solid;
+ border-radius: 2px;
+ color: $white;
+ text-align: center;
+ white-space: nowrap;
+ font-weight: bold;
+ line-height: 1;
+ cursor: default;
+}
+
+/** Kinds **/
+
+.danger {
+ border-color: $dangerColor;
+ background-color: $dangerColor;
+
+ &.outline {
+ color: $dangerColor;
+ }
+}
+
+.default {
+ border-color: $themeLightColor;
+ background-color: $themeLightColor;
+
+ &.outline {
+ color: $themeLightColor;
+ }
+}
+
+.info {
+ border-color: $infoColor;
+ background-color: $infoColor;
+
+ &.outline {
+ color: $infoColor;
+ }
+}
+
+.inverse {
+ border-color: $gray;
+ background-color: $gray;
+ color: $defaultColor;
+
+ &.outline {
+ background-color: $defaultColor !important;
+ color: $gray;
+ }
+}
+
+.primary {
+ border-color: $primaryColor;
+ background-color: $primaryColor;
+
+ &.outline {
+ color: $primaryColor;
+ }
+}
+
+.success {
+ border-color: $successColor;
+ background-color: $successColor;
+
+ &.outline {
+ color: $successColor;
+ }
+}
+
+.warning {
+ border-color: $warningColor;
+ background-color: $warningColor;
+
+ &.outline {
+ color: $warningColor;
+ }
+}
+
+/** Sizes **/
+
+.small {
+ padding: 1px 3px;
+ font-size: 11px;
+}
+
+.medium {
+ padding: 2px 5px;
+ font-size: 12px;
+}
+
+.large {
+ padding: 3px 7px;
+ font-size: 14px;
+}
+
+/** Outline **/
+
+.outline {
+ background-color: $white;
+}
diff --git a/frontend/src/Components/Label.js b/frontend/src/Components/Label.js
new file mode 100644
index 000000000..528974204
--- /dev/null
+++ b/frontend/src/Components/Label.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { kinds, sizes } from 'Helpers/Props';
+import styles from './Label.css';
+
+function Label(props) {
+ const {
+ className,
+ kind,
+ size,
+ outline,
+ children,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+Label.propTypes = {
+ className: PropTypes.string.isRequired,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ size: PropTypes.oneOf(sizes.all).isRequired,
+ outline: PropTypes.bool.isRequired,
+ children: PropTypes.node.isRequired
+};
+
+Label.defaultProps = {
+ className: styles.label,
+ kind: kinds.DEFAULT,
+ size: sizes.SMALL,
+ outline: false
+};
+
+export default Label;
diff --git a/frontend/src/Components/Link/Button.css b/frontend/src/Components/Link/Button.css
new file mode 100644
index 000000000..8913c4ab8
--- /dev/null
+++ b/frontend/src/Components/Link/Button.css
@@ -0,0 +1,119 @@
+.button {
+ composes: link from './Link.css';
+
+ overflow: hidden;
+ border: 1px solid;
+ border-radius: 4px;
+ vertical-align: middle;
+ text-align: center;
+ white-space: nowrap;
+ line-height: normal;
+
+ &:global(.isDisabled) {
+ opacity: 0.65;
+ }
+
+ &:hover {
+ text-decoration: none;
+ }
+}
+
+.danger {
+ border-color: $dangerBorderColor;
+ background-color: $dangerBackgroundColor;
+ color: $white;
+
+ &:hover {
+ border-color: $dangerHoverBorderColor;
+ background-color: $dangerHoverBackgroundColor;
+ color: $white;
+ }
+}
+
+.default {
+ border-color: $defaultBorderColor;
+ background-color: $defaultBackgroundColor;
+ color: $defaultColor;
+
+ &:hover {
+ border-color: $defaultHoverBorderColor;
+ background-color: $defaultHoverBackgroundColor;
+ color: $defaultColor;
+ }
+}
+
+.primary {
+ border-color: $primaryBorderColor;
+ background-color: $primaryBackgroundColor;
+ color: $white;
+
+ &:hover {
+ border-color: $primaryHoverBorderColor;
+ background-color: $primaryHoverBackgroundColor;
+ color: $white;
+ }
+}
+
+.success {
+ border-color: $successBorderColor;
+ background-color: $successBackgroundColor;
+ color: $white;
+
+ &:hover {
+ border-color: $successHoverBorderColor;
+ background-color: $successHoverBackgroundColor;
+ color: $white;
+ }
+}
+
+.warning {
+ border-color: $warningBorderColor;
+ background-color: $warningBackgroundColor;
+ color: $white;
+
+ &:hover {
+ border-color: $warningHoverBorderColor;
+ background-color: $warningHoverBackgroundColor;
+ color: $white;
+ }
+}
+
+/*
+ * Sizes
+ */
+
+.small {
+ padding: 1px 5px;
+ font-size: $smallFontSize;
+}
+
+.medium {
+ padding: 6px 16px;
+ font-size: $defaultFontSize;
+}
+
+.large {
+ padding: 10px 20px;
+ font-size: $largeFontSize;
+}
+
+/*
+ * Sizes
+*/
+
+.left {
+ margin-left: -1px;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.center {
+ margin-left: -1px;
+ border-radius: 0;
+}
+
+.right {
+ margin-left: -1px;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
diff --git a/frontend/src/Components/Link/Button.js b/frontend/src/Components/Link/Button.js
new file mode 100644
index 000000000..87d9fff78
--- /dev/null
+++ b/frontend/src/Components/Link/Button.js
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { align, kinds, sizes } from 'Helpers/Props';
+import Link from './Link';
+import styles from './Button.css';
+
+class Button extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ buttonGroupPosition,
+ kind,
+ size,
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+Button.propTypes = {
+ className: PropTypes.string.isRequired,
+ buttonGroupPosition: PropTypes.oneOf(align.all),
+ kind: PropTypes.oneOf(kinds.all),
+ size: PropTypes.oneOf(sizes.all),
+ children: PropTypes.node
+};
+
+Button.defaultProps = {
+ className: styles.button,
+ kind: kinds.DEFAULT,
+ size: sizes.MEDIUM
+};
+
+export default Button;
diff --git a/frontend/src/Components/Link/ClipboardButton.css b/frontend/src/Components/Link/ClipboardButton.css
new file mode 100644
index 000000000..09ed883cb
--- /dev/null
+++ b/frontend/src/Components/Link/ClipboardButton.css
@@ -0,0 +1,33 @@
+.button {
+ composes: button from 'Components/Form/FormInputButton.css';
+
+ position: relative;
+}
+
+.stateIconContainer {
+ position: absolute;
+ top: 50%;
+ left: -100%;
+ display: inline-flex;
+ visibility: hidden;
+ transition: left $defaultSpeed;
+ transform: translateX(-50%) translateY(-50%);
+}
+
+.clipboardIconContainer {
+ position: relative;
+ left: 0;
+ transition: left $defaultSpeed, opacity $defaultSpeed;
+}
+
+.showStateIcon {
+ .stateIconContainer {
+ left: 50%;
+ visibility: visible;
+ }
+
+ .clipboardIconContainer {
+ left: 100%;
+ opacity: 0;
+ }
+}
diff --git a/frontend/src/Components/Link/ClipboardButton.js b/frontend/src/Components/Link/ClipboardButton.js
new file mode 100644
index 000000000..26f6a4f9f
--- /dev/null
+++ b/frontend/src/Components/Link/ClipboardButton.js
@@ -0,0 +1,127 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Clipboard from 'Clipboard';
+import { icons, kinds } from 'Helpers/Props';
+import getUniqueElememtId from 'Utilities/getUniqueElementId';
+import Icon from 'Components/Icon';
+import FormInputButton from 'Components/Form/FormInputButton';
+import styles from './ClipboardButton.css';
+
+class ClipboardButton extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._id = getUniqueElememtId();
+ this._successTimeout = null;
+
+ this.state = {
+ showSuccess: false,
+ showError: false
+ };
+ }
+
+ componentDidMount() {
+ this._clipboard = new Clipboard(`#${this._id}`, {
+ text: () => this.props.value
+ });
+
+ this._clipboard.on('success', this.onSuccess);
+ }
+
+ componentDidUpdate() {
+ const {
+ showSuccess,
+ showError
+ } = this.state;
+
+ if (showSuccess || showError) {
+ this._testResultTimeout = setTimeout(this.resetState, 3000);
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._clipboard) {
+ this._clipboard.destroy();
+ }
+ }
+
+ //
+ // Control
+
+ resetState = () => {
+ this.setState({
+ showSuccess: false,
+ showError: false
+ });
+ }
+
+ //
+ // Listeners
+
+ onSuccess = () => {
+ this.setState({
+ showSuccess: true
+ });
+ }
+
+ onError = () => {
+ this.setState({
+ showError: true
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ value,
+ ...otherProps
+ } = this.props;
+
+ const {
+ showSuccess,
+ showError
+ } = this.state;
+
+ const showStateIcon = showSuccess || showError;
+ const iconName = showError ? icons.DANGER : icons.CHECK;
+ const iconKind = showError ? kinds.DANGER : kinds.SUCCESS;
+
+ return (
+
+
+ {
+ showSuccess &&
+
+
+
+ }
+
+ {
+
+
+
+ }
+
+
+ );
+ }
+}
+
+ClipboardButton.propTypes = {
+ value: PropTypes.string.isRequired
+};
+
+export default ClipboardButton;
diff --git a/frontend/src/Components/Link/IconButton.css b/frontend/src/Components/Link/IconButton.css
new file mode 100644
index 000000000..ba13af8d4
--- /dev/null
+++ b/frontend/src/Components/Link/IconButton.css
@@ -0,0 +1,16 @@
+.button {
+ composes: link from 'Components/Link/Link.css';
+
+ margin: 0 2px;
+ width: 22px;
+ border-radius: 4px;
+ background-color: transparent;
+ text-align: center;
+ font-size: inherit;
+
+ &:hover {
+ border: none;
+ background-color: inherit;
+ color: $iconButtonHoverColor;
+ }
+}
diff --git a/frontend/src/Components/Link/IconButton.js b/frontend/src/Components/Link/IconButton.js
new file mode 100644
index 000000000..3751530cc
--- /dev/null
+++ b/frontend/src/Components/Link/IconButton.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Icon from 'Components/Icon';
+import Link from './Link';
+import styles from './IconButton.css';
+
+function IconButton(props) {
+ const {
+ className,
+ iconClassName,
+ name,
+ kind,
+ size,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+IconButton.propTypes = {
+ className: PropTypes.string.isRequired,
+ iconClassName: PropTypes.string,
+ kind: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ size: PropTypes.number
+};
+
+IconButton.defaultProps = {
+ className: styles.button
+};
+
+export default IconButton;
diff --git a/frontend/src/Components/Link/Link.css b/frontend/src/Components/Link/Link.css
new file mode 100644
index 000000000..ec2b35ab6
--- /dev/null
+++ b/frontend/src/Components/Link/Link.css
@@ -0,0 +1,24 @@
+.link {
+ margin: 0;
+ padding: 0;
+ outline: none;
+ border: 0;
+ background: none;
+ color: inherit;
+ text-align: inherit;
+ text-decoration: none;
+ cursor: pointer;
+
+ &:global(.isDisabled) {
+ pointer-events: none;
+ }
+}
+
+.to {
+ color: $linkColor;
+
+ &:hover {
+ color: $linkHoverColor;
+ text-decoration: underline;
+ }
+}
diff --git a/frontend/src/Components/Link/Link.js b/frontend/src/Components/Link/Link.js
new file mode 100644
index 000000000..86701ef20
--- /dev/null
+++ b/frontend/src/Components/Link/Link.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Link as RouterLink } from 'react-router-dom';
+import classNames from 'classnames';
+import styles from './Link.css';
+
+class Link extends Component {
+
+ //
+ // Listeners
+
+ onClick = (event) => {
+ const {
+ isDisabled,
+ onPress
+ } = this.props;
+
+ if (!isDisabled && onPress) {
+ onPress(event);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ component,
+ to,
+ target,
+ isDisabled,
+ noRouter,
+ onPress,
+ ...otherProps
+ } = this.props;
+
+ const linkProps = { target };
+ let el = component;
+
+ if (to) {
+ if (/\w+?:\/\//.test(to)) {
+ el = 'a';
+ linkProps.href = to;
+ linkProps.target = target || '_blank';
+ } else if (noRouter) {
+ el = 'a';
+ linkProps.href = to;
+ linkProps.target = target || '_self';
+ } else if (to.startsWith(window.Sonarr.urlBase)) {
+ el = RouterLink;
+ linkProps.to = to;
+ linkProps.target = target;
+ } else {
+ el = RouterLink;
+ linkProps.to = `${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`;
+ linkProps.target = target;
+ }
+ }
+
+ if (el === 'button' || el === 'input') {
+ linkProps.type = otherProps.type || 'button';
+ linkProps.disabled = isDisabled;
+ }
+
+ linkProps.className = classNames(
+ className,
+ styles.link,
+ to && styles.to,
+ isDisabled && 'isDisabled'
+ );
+
+ const props = {
+ ...otherProps,
+ ...linkProps
+ };
+
+ props.onClick = this.onClick;
+
+ return (
+ React.createElement(el, props)
+ );
+ }
+}
+
+Link.propTypes = {
+ className: PropTypes.string,
+ component: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
+ to: PropTypes.string,
+ target: PropTypes.string,
+ isDisabled: PropTypes.bool,
+ noRouter: PropTypes.bool,
+ onPress: PropTypes.func
+};
+
+Link.defaultProps = {
+ component: 'button',
+ noRouter: false
+};
+
+export default Link;
diff --git a/frontend/src/Components/Link/SpinnerButton.css b/frontend/src/Components/Link/SpinnerButton.css
new file mode 100644
index 000000000..a29ab5625
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerButton.css
@@ -0,0 +1,37 @@
+.button {
+ composes: button from 'Components/Link/Button.css';
+
+ position: relative;
+}
+
+.spinnerContainer {
+ position: absolute;
+ top: 50%;
+ left: -100%;
+ display: inline-flex;
+ visibility: hidden;
+ transition: left $defaultSpeed;
+ transform: translateX(-50%) translateY(-50%);
+}
+
+.spinner {
+ z-index: 1;
+}
+
+.label {
+ position: relative;
+ left: 0;
+ transition: left $defaultSpeed, opacity $defaultSpeed;
+}
+
+.isSpinning {
+ .spinnerContainer {
+ left: 50%;
+ visibility: visible;
+ }
+
+ .label {
+ left: 100%;
+ opacity: 0;
+ }
+}
diff --git a/frontend/src/Components/Link/SpinnerButton.js b/frontend/src/Components/Link/SpinnerButton.js
new file mode 100644
index 000000000..8e5101afe
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerButton.js
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Button from './Button';
+import styles from './SpinnerButton.css';
+
+function SpinnerButton(props) {
+ const {
+ className,
+ isSpinning,
+ isDisabled,
+ spinnerIcon,
+ children,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
+
+SpinnerButton.propTypes = {
+ className: PropTypes.string.isRequired,
+ isSpinning: PropTypes.bool.isRequired,
+ isDisabled: PropTypes.bool,
+ spinnerIcon: PropTypes.string.isRequired,
+ children: PropTypes.node
+};
+
+SpinnerButton.defaultProps = {
+ className: styles.button,
+ spinnerIcon: icons.SPINNER
+};
+
+export default SpinnerButton;
diff --git a/frontend/src/Components/Link/SpinnerErrorButton.css b/frontend/src/Components/Link/SpinnerErrorButton.css
new file mode 100644
index 000000000..5f4e68545
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerErrorButton.css
@@ -0,0 +1,23 @@
+.iconContainer {
+ composes: spinnerContainer from 'Components/Link/SpinnerButton.css';
+}
+
+.icon {
+ z-index: 1;
+}
+
+.label {
+ composes: label from 'Components/Link/SpinnerButton.css';
+}
+
+.showIcon {
+ .iconContainer {
+ left: 50%;
+ visibility: visible;
+ }
+
+ .label {
+ left: 100%;
+ opacity: 0;
+ }
+}
diff --git a/frontend/src/Components/Link/SpinnerErrorButton.js b/frontend/src/Components/Link/SpinnerErrorButton.js
new file mode 100644
index 000000000..87cf55d95
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerErrorButton.js
@@ -0,0 +1,162 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import styles from './SpinnerErrorButton.css';
+
+function getTestResult(error) {
+ if (!error) {
+ return {
+ wasSuccessful: true,
+ hasWarning: false,
+ hasError: false
+ };
+ }
+
+ if (error.status !== 400) {
+ return {
+ wasSuccessful: false,
+ hasWarning: false,
+ hasError: true
+ };
+ }
+
+ const failures = error.responseJSON;
+
+ const hasWarning = _.some(failures, { isWarning: true });
+ const hasError = _.some(failures, (failure) => !failure.isWarning);
+
+ return {
+ wasSuccessful: false,
+ hasWarning,
+ hasError
+ };
+}
+
+class SpinnerErrorButton extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._testResultTimeout = null;
+
+ this.state = {
+ wasSuccessful: false,
+ hasWarning: false,
+ hasError: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isSpinning,
+ error
+ } = this.props;
+
+ if (prevProps.isSpinning && !isSpinning) {
+ const testResult = getTestResult(error);
+
+ this.setState(testResult, () => {
+ const {
+ wasSuccessful,
+ hasWarning,
+ hasError
+ } = testResult;
+
+ if (wasSuccessful || hasWarning || hasError) {
+ this._testResultTimeout = setTimeout(this.resetState, 3000);
+ }
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._testResultTimeout) {
+ clearTimeout(this._testResultTimeout);
+ }
+ }
+
+ //
+ // Control
+
+ resetState = () => {
+ this.setState({
+ wasSuccessful: false,
+ hasWarning: false,
+ hasError: false
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isSpinning,
+ error,
+ children,
+ ...otherProps
+ } = this.props;
+
+ const {
+ wasSuccessful,
+ hasWarning,
+ hasError
+ } = this.state;
+
+ const showIcon = wasSuccessful || hasWarning || hasError;
+
+ let iconName = icons.CHECK;
+ let iconKind = kinds.SUCCESS;
+
+ if (hasWarning) {
+ iconName = icons.WARNING;
+ iconKind = kinds.WARNING;
+ }
+
+ if (hasError) {
+ iconName = icons.DANGER;
+ iconKind = kinds.DANGER;
+ }
+
+ return (
+
+
+ {
+ showIcon &&
+
+
+
+ }
+
+ {
+
+ {
+ children
+ }
+
+ }
+
+
+ );
+ }
+}
+
+SpinnerErrorButton.propTypes = {
+ isSpinning: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ children: PropTypes.node.isRequired
+};
+
+export default SpinnerErrorButton;
diff --git a/frontend/src/Components/Link/SpinnerIconButton.js b/frontend/src/Components/Link/SpinnerIconButton.js
new file mode 100644
index 000000000..8f62d6031
--- /dev/null
+++ b/frontend/src/Components/Link/SpinnerIconButton.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import IconButton from './IconButton';
+
+function SpinnerIconButton(props) {
+ const {
+ name,
+ spinningName,
+ isDisabled,
+ isSpinning,
+ ...otherProps
+ } = props;
+
+ return (
+
+ );
+}
+
+SpinnerIconButton.propTypes = {
+ name: PropTypes.string.isRequired,
+ spinningName: PropTypes.string.isRequired,
+ isDisabled: PropTypes.bool.isRequired,
+ isSpinning: PropTypes.bool.isRequired
+};
+
+SpinnerIconButton.defaultProps = {
+ spinningName: icons.SPINNER,
+ isDisabled: false,
+ isSpinning: false
+};
+
+export default SpinnerIconButton;
diff --git a/frontend/src/Components/Loading/LoadingIndicator.css b/frontend/src/Components/Loading/LoadingIndicator.css
new file mode 100644
index 000000000..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..b27b49f00
--- /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 Lidarr...',
+ 'Bleep Bloop.',
+ 'Locating the required gigapixels to render...',
+ 'Spinning up the hamster wheel...',
+ 'At least you\'re not on hold',
+ 'Hum something loud while others stare',
+ 'Loading humorous message... Please Wait',
+ 'I could\'ve been faster in Python',
+ 'Don\'t forget to rewind your episodes',
+ 'Congratulations! you are the 1000th visitor.',
+ 'HELP!, I\'m being held hostage and forced to write these stupid lines!',
+ 'RE-calibrating the internet...',
+ 'I\'ll be here all week',
+ 'Don\'t forget to tip your waitress',
+ 'Apply directly to the forehead',
+ 'Loading Battlestation'
+];
+
+function LoadingMessage() {
+ const index = Math.floor(Math.random() * messages.length);
+ const message = messages[index];
+
+ return (
+
+ {message}
+
+ );
+}
+
+export default LoadingMessage;
diff --git a/frontend/src/Components/Menu/FilterMenu.css b/frontend/src/Components/Menu/FilterMenu.css
new file mode 100644
index 000000000..34991aed9
--- /dev/null
+++ b/frontend/src/Components/Menu/FilterMenu.css
@@ -0,0 +1,9 @@
+.filterMenu {
+ composes: menu from './Menu.css';
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .filterMenu {
+ margin-right: 10px;
+ }
+}
diff --git a/frontend/src/Components/Menu/FilterMenu.js b/frontend/src/Components/Menu/FilterMenu.js
new file mode 100644
index 000000000..6a76db432
--- /dev/null
+++ b/frontend/src/Components/Menu/FilterMenu.js
@@ -0,0 +1,38 @@
+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';
+import styles from './FilterMenu.css';
+
+function FilterMenu(props) {
+ const {
+ className,
+ children,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ {children}
+
+ );
+}
+
+FilterMenu.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+FilterMenu.defaultProps = {
+ className: styles.filterMenu
+};
+
+export default FilterMenu;
diff --git a/frontend/src/Components/Menu/FilterMenuItem.js b/frontend/src/Components/Menu/FilterMenuItem.js
new file mode 100644
index 000000000..54c293c49
--- /dev/null
+++ b/frontend/src/Components/Menu/FilterMenuItem.js
@@ -0,0 +1,57 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import SelectedMenuItem from './SelectedMenuItem';
+
+class FilterMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ value,
+ onPress
+ } = this.props;
+
+ onPress(name, value);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ value,
+ filterKey,
+ filterValue,
+ ...otherProps
+ } = this.props;
+
+ const isSelected = name === filterKey && value === filterValue;
+
+ return (
+
+ );
+ }
+}
+
+FilterMenuItem.propTypes = {
+ name: PropTypes.string,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]),
+ onPress: PropTypes.func.isRequired
+};
+
+FilterMenuItem.defaultProps = {
+ name: null,
+ value: null
+};
+
+export default FilterMenuItem;
diff --git a/frontend/src/Components/Menu/Menu.css b/frontend/src/Components/Menu/Menu.css
new file mode 100644
index 000000000..9cce48fee
--- /dev/null
+++ b/frontend/src/Components/Menu/Menu.css
@@ -0,0 +1,7 @@
+.tether {
+ z-index: 2000;
+}
+
+.menu {
+ position: relative;
+}
diff --git a/frontend/src/Components/Menu/Menu.js b/frontend/src/Components/Menu/Menu.js
new file mode 100644
index 000000000..06e38dcf7
--- /dev/null
+++ b/frontend/src/Components/Menu/Menu.js
@@ -0,0 +1,203 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import TetherComponent from 'react-tether';
+import { align } from 'Helpers/Props';
+import styles from './Menu.css';
+
+const baseTetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ]
+};
+
+const tetherOptions = {
+ [align.RIGHT]: {
+ ...baseTetherOptions,
+ attachment: 'top right',
+ targetAttachment: 'bottom right'
+ },
+
+ [align.LEFT]: {
+ ...baseTetherOptions,
+ attachment: 'top left',
+ targetAttachment: 'bottom left'
+ }
+};
+
+class Menu extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMenuOpen: false,
+ maxHeight: 0
+ };
+ }
+
+ componentDidMount() {
+ this.setMaxHeight();
+ }
+
+ //
+ // Control
+
+ getMaxHeight() {
+ if (!this.props.enforceMaxHeight) {
+ return;
+ }
+
+ const menu = ReactDOM.findDOMNode(this.refs.menu);
+
+ if (!menu) {
+ return;
+ }
+
+ const { bottom } = menu.getBoundingClientRect();
+ const maxHeight = window.innerHeight - bottom;
+
+ return maxHeight;
+ }
+
+ setMaxHeight() {
+ this.setState({
+ maxHeight: this.getMaxHeight()
+ });
+ }
+
+ _addListener() {
+ // Listen to resize events on the window and scroll events
+ // on all elements to ensure the menu is the best size possible.
+ // Listen for click events on the window to support closing the
+ // menu on clicks outside.
+
+ window.addEventListener('resize', this.onWindowResize);
+ window.addEventListener('scroll', this.onWindowScroll, { capture: true });
+ window.addEventListener('click', this.onWindowClick);
+ }
+
+ _removeListener() {
+ window.removeEventListener('resize', this.onWindowResize);
+ window.removeEventListener('scroll', this.onWindowScroll, { capture: true });
+ window.removeEventListener('click', this.onWindowClick);
+ }
+
+ //
+ // Listeners
+
+ onWindowClick = (event) => {
+ const menu = ReactDOM.findDOMNode(this.refs.menu);
+ const menuContent = ReactDOM.findDOMNode(this.refs.menuContent);
+
+ if (!menu) {
+ return;
+ }
+
+ if ((!menu.contains(event.target) || menuContent.contains(event.target)) && this.state.isMenuOpen) {
+ this.setState({ isMenuOpen: false });
+ this._removeListener();
+ }
+ }
+
+ onWindowResize = () => {
+ this.setMaxHeight();
+ }
+
+ onWindowScroll = () => {
+ this.setMaxHeight();
+ }
+
+ onMenuButtonPress = () => {
+ const state = {
+ isMenuOpen: !this.state.isMenuOpen
+ };
+
+ if (this.state.isMenuOpen) {
+ this._removeListener();
+ } else {
+ state.maxHeight = this.getMaxHeight();
+ this._addListener();
+ }
+
+ this.setState(state);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ alignMenu
+ } = this.props;
+
+ const {
+ maxHeight,
+ isMenuOpen
+ } = this.state;
+
+ const childrenArray = React.Children.toArray(children);
+ const button = React.cloneElement(
+ childrenArray[0],
+ {
+ onPress: this.onMenuButtonPress
+ }
+ );
+
+ const content = React.cloneElement(
+ childrenArray[1],
+ {
+ ref: 'menuContent',
+ alignMenu,
+ maxHeight,
+ isOpen: isMenuOpen
+ }
+ );
+
+ return (
+
+
+ {button}
+
+
+ {
+ isMenuOpen &&
+ content
+ }
+
+ );
+ }
+}
+
+Menu.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT]),
+ enforceMaxHeight: PropTypes.bool.isRequired
+};
+
+Menu.defaultProps = {
+ className: styles.menu,
+ alignMenu: align.LEFT,
+ enforceMaxHeight: true
+};
+
+export default Menu;
diff --git a/frontend/src/Components/Menu/MenuButton.css b/frontend/src/Components/Menu/MenuButton.css
new file mode 100644
index 000000000..04a7439cd
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuButton.css
@@ -0,0 +1,15 @@
+.menuButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+
+ &:hover {
+ color: $toobarButtonHoverColor;
+ }
+}
diff --git a/frontend/src/Components/Menu/MenuButton.js b/frontend/src/Components/Menu/MenuButton.js
new file mode 100644
index 000000000..d89a52d1d
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuButton.js
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './MenuButton.css';
+
+class MenuButton extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ onPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+MenuButton.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ onPress: PropTypes.func
+};
+
+MenuButton.defaultProps = {
+ className: styles.menuButton
+};
+
+export default MenuButton;
diff --git a/frontend/src/Components/Menu/MenuContent.css b/frontend/src/Components/Menu/MenuContent.css
new file mode 100644
index 000000000..0acc07390
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuContent.css
@@ -0,0 +1,11 @@
+.menuContent {
+ display: flex;
+ flex-direction: column;
+ background-color: $toolbarMenuItemBackgroundColor;
+ line-height: 20px;
+}
+
+.scroller {
+ display: flex;
+ flex-direction: column;
+}
diff --git a/frontend/src/Components/Menu/MenuContent.js b/frontend/src/Components/Menu/MenuContent.js
new file mode 100644
index 000000000..1acacf80f
--- /dev/null
+++ b/frontend/src/Components/Menu/MenuContent.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Scroller from 'Components/Scroller/Scroller';
+import styles from './MenuContent.css';
+
+class MenuContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ maxHeight
+ } = this.props;
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+}
+
+MenuContent.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ maxHeight: PropTypes.number
+};
+
+MenuContent.defaultProps = {
+ className: styles.menuContent
+};
+
+export default MenuContent;
diff --git a/frontend/src/Components/Menu/MenuItem.css b/frontend/src/Components/Menu/MenuItem.css
new file mode 100644
index 000000000..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/SelectedMenuItem.css b/frontend/src/Components/Menu/SelectedMenuItem.css
new file mode 100644
index 000000000..739419d69
--- /dev/null
+++ b/frontend/src/Components/Menu/SelectedMenuItem.css
@@ -0,0 +1,15 @@
+.item {
+ display: flex;
+ justify-content: space-between;
+ white-space: nowrap;
+}
+
+.isSelected {
+ visibility: visible;
+ margin-left: 20px;
+}
+
+.isNotSelected {
+ visibility: hidden;
+ margin-left: 20px;
+}
diff --git a/frontend/src/Components/Menu/SelectedMenuItem.js b/frontend/src/Components/Menu/SelectedMenuItem.js
new file mode 100644
index 000000000..bc8f41f5a
--- /dev/null
+++ b/frontend/src/Components/Menu/SelectedMenuItem.js
@@ -0,0 +1,63 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import MenuItem from './MenuItem';
+import styles from './SelectedMenuItem.css';
+
+class SelectedMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ onPress
+ } = this.props;
+
+ onPress(name);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ children,
+ selectedIconName,
+ isSelected,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+ {children}
+
+
+
+
+ );
+ }
+}
+
+SelectedMenuItem.propTypes = {
+ name: PropTypes.string,
+ children: PropTypes.node.isRequired,
+ selectedIconName: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+SelectedMenuItem.defaultProps = {
+ selectedIconName: icons.CHECK
+};
+
+export default SelectedMenuItem;
diff --git a/frontend/src/Components/Menu/SortMenu.js b/frontend/src/Components/Menu/SortMenu.js
new file mode 100644
index 000000000..7a2e931ea
--- /dev/null
+++ b/frontend/src/Components/Menu/SortMenu.js
@@ -0,0 +1,33 @@
+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,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ {children}
+
+ );
+}
+
+SortMenu.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+export default SortMenu;
diff --git a/frontend/src/Components/Menu/SortMenuItem.js b/frontend/src/Components/Menu/SortMenuItem.js
new file mode 100644
index 000000000..e35864ae6
--- /dev/null
+++ b/frontend/src/Components/Menu/SortMenuItem.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons, sortDirections } from 'Helpers/Props';
+import SelectedMenuItem from './SelectedMenuItem';
+
+function SortMenuItem(props) {
+ const {
+ name,
+ sortKey,
+ sortDirection,
+ ...otherProps
+ } = props;
+
+ const isSelected = name === sortKey;
+
+ return (
+
+ );
+}
+
+SortMenuItem.propTypes = {
+ name: PropTypes.string,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.oneOf(sortDirections.all),
+ onPress: PropTypes.func.isRequired
+};
+
+SortMenuItem.defaultProps = {
+ name: null
+};
+
+export default SortMenuItem;
diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.css b/frontend/src/Components/Menu/ToolbarMenuButton.css
new file mode 100644
index 000000000..ef08e2843
--- /dev/null
+++ b/frontend/src/Components/Menu/ToolbarMenuButton.css
@@ -0,0 +1,13 @@
+.menuButton {
+ composes: menuButton from './MenuButton.css';
+
+ width: $toolbarButtonWidth;
+ height: $toolbarHeight;
+ text-align: center;
+}
+
+.label {
+ height: 14px;
+ color: $toolbarLabelColor;
+ font-size: $extraSmallFontSize;
+}
diff --git a/frontend/src/Components/Menu/ToolbarMenuButton.js b/frontend/src/Components/Menu/ToolbarMenuButton.js
new file mode 100644
index 000000000..33f7802de
--- /dev/null
+++ b/frontend/src/Components/Menu/ToolbarMenuButton.js
@@ -0,0 +1,38 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Icon from 'Components/Icon';
+import MenuButton from 'Components/Menu/MenuButton';
+import styles from './ToolbarMenuButton.css';
+
+function ToolbarMenuButton(props) {
+ const {
+ iconName,
+ text,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+ToolbarMenuButton.propTypes = {
+ iconName: PropTypes.string.isRequired,
+ text: PropTypes.string
+};
+
+export default ToolbarMenuButton;
diff --git a/frontend/src/Components/Menu/ViewMenu.js b/frontend/src/Components/Menu/ViewMenu.js
new file mode 100644
index 000000000..9c39bb3e2
--- /dev/null
+++ b/frontend/src/Components/Menu/ViewMenu.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Menu from 'Components/Menu/Menu';
+import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
+
+function ViewMenu(props) {
+ const {
+ children,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+ {children}
+
+ );
+}
+
+ViewMenu.propTypes = {
+ children: PropTypes.node.isRequired
+};
+
+export default ViewMenu;
diff --git a/frontend/src/Components/Menu/ViewMenuItem.js b/frontend/src/Components/Menu/ViewMenuItem.js
new file mode 100644
index 000000000..d355d6e94
--- /dev/null
+++ b/frontend/src/Components/Menu/ViewMenuItem.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import SelectedMenuItem from './SelectedMenuItem';
+
+function ViewMenuItem(props) {
+ const {
+ name,
+ selectedView,
+ ...otherProps
+ } = props;
+
+ const isSelected = name === selectedView;
+
+ return (
+
+ );
+}
+
+ViewMenuItem.propTypes = {
+ name: PropTypes.string,
+ selectedView: PropTypes.string.isRequired
+};
+
+export default ViewMenuItem;
diff --git a/frontend/src/Components/Modal/ConfirmModal.js b/frontend/src/Components/Modal/ConfirmModal.js
new file mode 100644
index 000000000..5bb783d43
--- /dev/null
+++ b/frontend/src/Components/Modal/ConfirmModal.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+function ConfirmModal(props) {
+ const {
+ isOpen,
+ kind,
+ size,
+ title,
+ message,
+ confirmLabel,
+ cancelLabel,
+ hideCancelButton,
+ isSpinning,
+ onConfirm,
+ onCancel
+ } = props;
+
+ return (
+
+
+ {title}
+
+
+ {message}
+
+
+
+ {
+ !hideCancelButton &&
+
+ {cancelLabel}
+
+ }
+
+
+ {confirmLabel}
+
+
+
+
+ );
+}
+
+ConfirmModal.propTypes = {
+ className: PropTypes.string,
+ isOpen: PropTypes.bool.isRequired,
+ kind: PropTypes.oneOf(kinds.all),
+ size: PropTypes.oneOf(sizes.all),
+ title: PropTypes.string.isRequired,
+ message: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
+ confirmLabel: PropTypes.string,
+ cancelLabel: PropTypes.string,
+ hideCancelButton: PropTypes.bool,
+ isSpinning: PropTypes.bool.isRequired,
+ onConfirm: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired
+};
+
+ConfirmModal.defaultProps = {
+ kind: kinds.PRIMARY,
+ size: sizes.MEDIUM,
+ confirmLabel: 'OK',
+ cancelLabel: 'Cancel',
+ isSpinning: false
+};
+
+export default ConfirmModal;
diff --git a/frontend/src/Components/Modal/Modal.css b/frontend/src/Components/Modal/Modal.css
new file mode 100644
index 000000000..4872a011e
--- /dev/null
+++ b/frontend/src/Components/Modal/Modal.css
@@ -0,0 +1,79 @@
+.modalContainer {
+ position: absolute;
+ top: 0;
+ z-index: 1000;
+ width: 100%;
+ height: 100%;
+}
+
+.modalBackdrop {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ background-color: $modalBackdropBackgroundColor;
+ opacity: 1;
+}
+
+.modal {
+ position: relative;
+ display: flex;
+ max-height: 90%;
+ border-radius: 6px;
+ opacity: 1;
+}
+
+.modalOpen {
+ /* Prevent the body from scrolling when the modal is open */
+ overflow: hidden !important;
+}
+
+/*
+ * Sizes
+ */
+
+.small {
+ composes: modal;
+
+ width: 480px;
+}
+
+.medium {
+ composes: modal;
+
+ width: 720px;
+}
+
+.large {
+ composes: modal;
+
+ width: 1080px;
+}
+
+@media only screen and (max-width: $breakpointLarge) {
+ .modal.large {
+ width: 90%;
+ }
+}
+
+@media only screen and (max-width: $breakpointMedium) {
+ .modal.small,
+ .modal.medium {
+ width: 90%;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .modalContainer {
+ position: fixed;
+ }
+
+ .modal.small,
+ .modal.medium,
+ .modal.large {
+ max-height: 100%;
+ width: 100%;
+ height: 100%;
+ }
+}
diff --git a/frontend/src/Components/Modal/Modal.js b/frontend/src/Components/Modal/Modal.js
new file mode 100644
index 000000000..1696da829
--- /dev/null
+++ b/frontend/src/Components/Modal/Modal.js
@@ -0,0 +1,196 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import Portal from 'react-portal';
+import classNames from 'classnames';
+import elementClass from 'element-class';
+import getUniqueElememtId from 'Utilities/getUniqueElementId';
+import * as keyCodes from 'Utilities/Constants/keyCodes';
+import { sizes } from 'Helpers/Props';
+import styles from './Modal.css';
+
+const openModals = [];
+
+function removeFromOpenModals(id) {
+ const index = openModals.indexOf(id);
+
+ if (index >= 0) {
+ openModals.splice(index, 1);
+ }
+}
+
+class Modal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._modalId = getUniqueElememtId();
+ }
+
+ componentDidMount() {
+ if (this.props.isOpen) {
+ this._openModal();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isOpen
+ } = this.props;
+
+ if (!prevProps.isOpen && isOpen) {
+ this._openModal();
+ } else if (prevProps.isOpen && !isOpen) {
+ this._closeModal();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.props.isOpen) {
+ this._closeModal();
+ }
+ }
+
+ //
+ // Control
+
+ _openModal() {
+ openModals.push(this._modalId);
+ window.addEventListener('keydown', this.onKeyDown);
+
+ if (openModals.length === 1) {
+ elementClass(document.body).add(styles.modalOpen);
+ }
+ }
+
+ _closeModal() {
+ removeFromOpenModals(this._modalId);
+ window.removeEventListener('keydown', this.onKeyDown);
+
+ if (openModals.length === 0) {
+ elementClass(document.body).remove(styles.modalOpen);
+ }
+ }
+
+ _isBackdropTarget(event) {
+ const targetElement = this._findEventTarget(event);
+
+ if (targetElement) {
+ const modalElement = ReactDOM.findDOMNode(this.refs.modal);
+
+ return !modalElement || !modalElement.contains(targetElement);
+ }
+
+ return false;
+ }
+
+ _findEventTarget(event) {
+ const changedTouches = event.changedTouches;
+
+ if (!changedTouches) {
+ return event.target;
+ }
+
+ if (changedTouches.length === 1) {
+ const touch = changedTouches[0];
+
+ return document.elementFromPoint(touch.clientX, touch.clientY);
+ }
+ }
+
+ //
+ // Listeners
+
+ onBackdropBeginPress = (event) => {
+ this._isBackdropPressed = this._isBackdropTarget(event);
+ }
+
+ onBackdropEndPress = (event) => {
+ if (this._isBackdropPressed && this._isBackdropTarget(event)) {
+ this.props.onModalClose();
+ }
+
+ this._isBackdropPressed = false;
+ }
+
+ onKeyDown = (event) => {
+ const keyCode = event.keyCode;
+
+ if (keyCode === keyCodes.ESCAPE) {
+ if (openModals.indexOf(this._modalId) === openModals.length - 1) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ this.props.onModalClose();
+ }
+ }
+ }
+
+ onClosePress = (event) => {
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ backdropClassName,
+ size,
+ children,
+ isOpen
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+Modal.propTypes = {
+ className: PropTypes.string,
+ backdropClassName: PropTypes.string,
+ size: PropTypes.oneOf(sizes.all),
+ children: PropTypes.node,
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+Modal.defaultProps = {
+ className: styles.modal,
+ backdropClassName: styles.modalBackdrop,
+ size: sizes.LARGE
+};
+
+export default Modal;
diff --git a/frontend/src/Components/Modal/ModalBody.css b/frontend/src/Components/Modal/ModalBody.css
new file mode 100644
index 000000000..2e55a91cb
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalBody.css
@@ -0,0 +1,14 @@
+$modalBodyPadding: 30px;
+
+.modalBody {
+ flex: 1 0 1px;
+ padding: $modalBodyPadding;
+}
+
+.modalScroller {
+ flex-grow: 1;
+}
+
+.innerModalBody {
+ padding: $modalBodyPadding;
+}
diff --git a/frontend/src/Components/Modal/ModalBody.js b/frontend/src/Components/Modal/ModalBody.js
new file mode 100644
index 000000000..a35f2ecf5
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalBody.js
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { scrollDirections } from 'Helpers/Props';
+import Scroller from 'Components/Scroller/Scroller';
+import styles from './ModalBody.css';
+
+class ModalBody extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ innerClassName,
+ scrollDirection,
+ children,
+ ...otherProps
+ } = this.props;
+
+ let className = this.props.className;
+ const hasScroller = scrollDirection !== scrollDirections.NONE;
+
+ if (!className) {
+ className = hasScroller ? styles.modalScroller : styles.modalBody;
+ }
+
+ return (
+
+ {
+ hasScroller ?
+
+ {children}
+
:
+ children
+ }
+
+ );
+ }
+
+}
+
+ModalBody.propTypes = {
+ className: PropTypes.string,
+ innerClassName: PropTypes.string,
+ children: PropTypes.node,
+ scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL])
+};
+
+ModalBody.defaultProps = {
+ innerClassName: styles.innerModalBody,
+ scrollDirection: scrollDirections.VERTICAL
+};
+
+export default ModalBody;
diff --git a/frontend/src/Components/Modal/ModalContent.css b/frontend/src/Components/Modal/ModalContent.css
new file mode 100644
index 000000000..afd798dfa
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalContent.css
@@ -0,0 +1,23 @@
+.modalContent {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ width: 100%;
+ background-color: $modalBackgroundColor;
+}
+
+.closeButton {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 1;
+ width: 60px;
+ height: 60px;
+ text-align: center;
+ line-height: 60px;
+
+ &:hover {
+ color: $modalCloseButtonHoverColor;
+ }
+}
diff --git a/frontend/src/Components/Modal/ModalContent.js b/frontend/src/Components/Modal/ModalContent.js
new file mode 100644
index 000000000..cc165dda2
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalContent.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import Icon from 'Components/Icon';
+import styles from './ModalContent.css';
+
+function ModalContent(props) {
+ const {
+ className,
+ children,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+ {children}
+
+ );
+}
+
+ModalContent.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node,
+ onModalClose: PropTypes.func.isRequired
+};
+
+ModalContent.defaultProps = {
+ className: styles.modalContent
+};
+
+export default ModalContent;
diff --git a/frontend/src/Components/Modal/ModalFooter.css b/frontend/src/Components/Modal/ModalFooter.css
new file mode 100644
index 000000000..3b817d2bf
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalFooter.css
@@ -0,0 +1,23 @@
+.modalFooter {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex-shrink: 0;
+ padding: 15px 30px;
+ border-top: 1px solid $borderColor;
+
+ a,
+ button {
+ margin-left: 10px;
+
+ &:first-child {
+ margin-left: 0;
+ }
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .modalFooter {
+ padding: 15px;
+ }
+}
diff --git a/frontend/src/Components/Modal/ModalFooter.js b/frontend/src/Components/Modal/ModalFooter.js
new file mode 100644
index 000000000..0cf8811d3
--- /dev/null
+++ b/frontend/src/Components/Modal/ModalFooter.js
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './ModalFooter.css';
+
+class ModalFooter extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+ModalFooter.propTypes = {
+ children: PropTypes.node
+};
+
+export default ModalFooter;
diff --git a/frontend/src/Components/Modal/ModalHeader.css b/frontend/src/Components/Modal/ModalHeader.css
new file mode 100644
index 000000000..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..e2c68bed1
--- /dev/null
+++ b/frontend/src/Components/MonitorToggleButton.css
@@ -0,0 +1,13 @@
+.toggleButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ padding: 0;
+ font-size: inherit;
+}
+
+.disabledButton {
+ composes: button from 'Components/Link/IconButton.css';
+
+ color: $disabledColor;
+ cursor: not-allowed;
+}
diff --git a/frontend/src/Components/MonitorToggleButton.js b/frontend/src/Components/MonitorToggleButton.js
new file mode 100644
index 000000000..8802cb1a2
--- /dev/null
+++ b/frontend/src/Components/MonitorToggleButton.js
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import styles from './MonitorToggleButton.css';
+
+class MonitorToggleButton extends Component {
+
+ //
+ // Listeners
+
+ onPress = (event) => {
+ const shiftKey = event.nativeEvent.shiftKey;
+
+ this.props.onPress(!this.props.monitored, { shiftKey });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ monitored,
+ isDisabled,
+ isSaving,
+ size,
+ ...otherProps
+ } = this.props;
+
+ const monitoredMessage = 'Monitored, click to unmonitor';
+ const unmonitoredMessage = 'Unmonitored, click to monitor';
+ const iconName = monitored ? icons.MONITORED : icons.UNMONITORED;
+
+ if (isDisabled) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+MonitorToggleButton.propTypes = {
+ className: PropTypes.string.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ size: PropTypes.number,
+ isDisabled: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+MonitorToggleButton.defaultProps = {
+ className: styles.toggleButton,
+ isDisabled: false,
+ isSaving: false
+};
+
+export default MonitorToggleButton;
diff --git a/frontend/src/Components/NotFound.css b/frontend/src/Components/NotFound.css
new file mode 100644
index 000000000..9aaf1114f
--- /dev/null
+++ b/frontend/src/Components/NotFound.css
@@ -0,0 +1,14 @@
+.container {
+ text-align: center;
+}
+
+.message {
+ margin: 50px 0;
+ text-align: center;
+ font-weight: 300;
+ font-size: 36px;
+}
+
+.image {
+ height: 350px;
+}
diff --git a/frontend/src/Components/NotFound.js b/frontend/src/Components/NotFound.js
new file mode 100644
index 000000000..7043da46f
--- /dev/null
+++ b/frontend/src/Components/NotFound.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import PageContent from 'Components/Page/PageContent';
+import styles from './NotFound.css';
+
+function NotFound({ message }) {
+ return (
+
+
+
+ {message}
+
+
+
+
+
+ );
+}
+
+NotFound.propTypes = {
+ message: PropTypes.string.isRequired
+};
+
+NotFound.defaultProps = {
+ message: 'You must be lost, nothing to see here.'
+};
+
+export default NotFound;
diff --git a/frontend/src/Components/Page/ErrorPage.css b/frontend/src/Components/Page/ErrorPage.css
new file mode 100644
index 000000000..e62a82a6b
--- /dev/null
+++ b/frontend/src/Components/Page/ErrorPage.css
@@ -0,0 +1,12 @@
+.page {
+ composes: page from './Page.css';
+
+ margin-top: 20px;
+ text-align: center;
+ font-size: 20px;
+}
+
+.version {
+ margin-top: 20px;
+ font-size: 16px;
+}
diff --git a/frontend/src/Components/Page/ErrorPage.js b/frontend/src/Components/Page/ErrorPage.js
new file mode 100644
index 000000000..325575ff0
--- /dev/null
+++ b/frontend/src/Components/Page/ErrorPage.js
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import styles from './ErrorPage.css';
+
+function ErrorPage(props) {
+ const {
+ version,
+ isLocalStorageSupported,
+ artistError,
+ tagsError,
+ qualityProfilesError,
+ uiSettingsError
+ } = props;
+
+ let errorMessage = 'Failed to load Lidarr';
+
+ if (!isLocalStorageSupported) {
+ errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
+ } else if (artistError) {
+ errorMessage = getErrorMessage(artistError, 'Failed to load artist from API');
+ } else if (tagsError) {
+ errorMessage = getErrorMessage(artistError, 'Failed to load artist from API');
+ } else if (qualityProfilesError) {
+ errorMessage = getErrorMessage(qualityProfilesError, 'Failed to load quality profiles from API');
+ } else if (uiSettingsError) {
+ errorMessage = getErrorMessage(uiSettingsError, 'Failed to load UI settings from API');
+ }
+
+ return (
+
+
+ {errorMessage}
+
+
+
+ Version {version}
+
+
+ );
+}
+
+ErrorPage.propTypes = {
+ version: PropTypes.string.isRequired,
+ isLocalStorageSupported: PropTypes.bool.isRequired,
+ artistError: PropTypes.object,
+ tagsError: PropTypes.object,
+ qualityProfilesError: PropTypes.object,
+ uiSettingsError: PropTypes.object
+};
+
+export default ErrorPage;
diff --git a/frontend/src/Components/Page/Header/ArtistSearchInput.css b/frontend/src/Components/Page/Header/ArtistSearchInput.css
new file mode 100644
index 000000000..1cc1c3b2b
--- /dev/null
+++ b/frontend/src/Components/Page/Header/ArtistSearchInput.css
@@ -0,0 +1,98 @@
+.wrapper {
+ display: flex;
+}
+
+.icon {
+ line-height: 24px !important;
+}
+
+.input {
+ margin-left: 8px;
+ width: 200px;
+ border: none;
+ border-bottom: solid 1px $white;
+ background-color: transparent;
+ box-shadow: none;
+ color: $white;
+ transition: border 0.3s ease-out;
+
+ &::placeholder {
+ color: $white;
+ transition: color 0.3s ease-out;
+ }
+
+ &:focus {
+ outline: 0;
+ border-bottom-color: transparent;
+
+ &::placeholder {
+ color: transparent;
+ }
+ }
+}
+
+.container {
+ position: relative;
+ flex-grow: 1;
+}
+
+.artistContainer {
+ @add-mixin scrollbar;
+ @add-mixin scrollbarTrack;
+ @add-mixin scrollbarThumb;
+}
+
+.containerOpen {
+ .artistContainer {
+ position: absolute;
+ top: 42px;
+ z-index: 1;
+ overflow-y: auto;
+ min-width: 100%;
+ max-height: 230px;
+ border: 1px solid $themeDarkColor;
+ border-radius: 4px;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ background-color: $themeDarkColor;
+ box-shadow: inset 0 1px 1px $inputBoxShadowColor;
+ color: $menuItemColor;
+ }
+}
+
+.list {
+ margin: 5px 0;
+ padding-left: 0;
+ list-style-type: none;
+}
+
+.listItem {
+ padding: 0 16px;
+ white-space: nowrap;
+}
+
+.highlighted {
+ background-color: $themeLightColor;
+}
+
+.sectionTitle {
+ padding: 5px 8px;
+ color: $disabledColor;
+}
+
+.addNewArtistSuggestion {
+ padding: 0 3px;
+ cursor: pointer;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .input {
+ min-width: 150px;
+ max-width: 200px;
+ }
+
+ .container {
+ min-width: 0;
+ max-width: 200px;
+ }
+}
diff --git a/frontend/src/Components/Page/Header/ArtistSearchInput.js b/frontend/src/Components/Page/Header/ArtistSearchInput.js
new file mode 100644
index 000000000..c0fe5ab20
--- /dev/null
+++ b/frontend/src/Components/Page/Header/ArtistSearchInput.js
@@ -0,0 +1,250 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Autosuggest from 'react-autosuggest';
+import jdu from 'jdu';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
+import ArtistSearchResult from './ArtistSearchResult';
+import styles from './ArtistSearchInput.css';
+
+const ADD_NEW_TYPE = 'addNew';
+
+class ArtistSearchInput extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._autosuggest = null;
+
+ this.state = {
+ value: '',
+ suggestions: []
+ };
+ }
+
+ componentDidMount() {
+ this.props.bindShortcut(shortcuts.ARTIST_SEARCH_INPUT.key, this.focusInput);
+ }
+
+ //
+ // Control
+
+ setAutosuggestRef = (ref) => {
+ this._autosuggest = ref;
+ }
+
+ focusInput = (event) => {
+ event.preventDefault();
+ this._autosuggest.input.focus();
+ }
+
+ getSectionSuggestions(section) {
+ return section.suggestions;
+ }
+
+ renderSectionTitle(section) {
+ return (
+
+ {section.title}
+
+ );
+ }
+
+ getSuggestionValue({ title }) {
+ return title;
+ }
+
+ renderSuggestion(item, { query }) {
+ if (item.type === ADD_NEW_TYPE) {
+ return (
+
+ Search for {query}
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ goToArtist(artist) {
+ this.setState({ value: '' });
+ this.props.onGoToArtist(artist.nameSlug);
+ }
+
+ reset() {
+ this.setState({
+ value: '',
+ suggestions: []
+ });
+ }
+
+ //
+ // Listeners
+
+ onChange = (event, { newValue }) => {
+ this.setState({ value: newValue });
+ }
+
+ onKeyDown = (event) => {
+ if (event.key !== 'Tab' && event.key !== 'Enter') {
+ return;
+ }
+
+ const {
+ suggestions,
+ value
+ } = this.state;
+
+ const {
+ highlightedSectionIndex,
+ highlightedSuggestionIndex
+ } = this._autosuggest.state;
+
+ if (!suggestions.length || highlightedSectionIndex) {
+ this.props.onGoToAddNewArtist(value);
+ this._autosuggest.input.blur();
+
+ return;
+ }
+
+ // If an suggestion is not selected go to the first artist,
+ // otherwise go to the selected artist.
+
+ if (highlightedSuggestionIndex == null) {
+ this.goToArtist(suggestions[0]);
+ } else {
+ this.goToArtist(suggestions[highlightedSuggestionIndex]);
+ }
+ }
+
+ onBlur = () => {
+ this.reset();
+ }
+
+ onSuggestionsFetchRequested = ({ value }) => {
+ const lowerCaseValue = jdu.replace(value).toLowerCase();
+
+ const suggestions = _.filter(this.props.artist, (artist) => {
+ // Check the title first and if there isn't a match fallback to the alternate titles
+
+ const titleMatch = jdu.replace(artist.artistName).toLowerCase().contains(lowerCaseValue);
+
+ return titleMatch || _.some(artist.alternateTitles, (alternateTitle) => {
+ return jdu.replace(alternateTitle.title).toLowerCase().contains(lowerCaseValue);
+ });
+ });
+
+ this.setState({ suggestions });
+ }
+
+ onSuggestionsClearRequested = () => {
+ this.reset();
+ }
+
+ onSuggestionSelected = (event, { suggestion, sectionIndex }) => {
+ if (suggestion.type === ADD_NEW_TYPE) {
+ this.props.onGoToAddNewArtist(this.state.value);
+ } else {
+ this.goToArtist(suggestion);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ value,
+ suggestions
+ } = this.state;
+
+ const suggestionGroups = [];
+
+ if (suggestions.length) {
+ suggestionGroups.push({
+ title: 'Existing Artist',
+ suggestions
+ });
+ }
+
+ if (suggestions.length <= 3) {
+ suggestionGroups.push({
+ title: 'Add New Artist',
+ suggestions: [
+ {
+ type: ADD_NEW_TYPE,
+ title: value
+ }
+ ]
+ });
+ }
+
+ const inputProps = {
+ ref: this.setInputRef,
+ className: styles.input,
+ name: 'artistSearch',
+ value,
+ placeholder: 'Search',
+ autoComplete: 'off',
+ spellCheck: false,
+ onChange: this.onChange,
+ onKeyDown: this.onKeyDown,
+ onBlur: this.onBlur,
+ onFocus: this.onFocus
+ };
+
+ const theme = {
+ container: styles.container,
+ containerOpen: styles.containerOpen,
+ suggestionsContainer: styles.artistContainer,
+ suggestionsList: styles.list,
+ suggestion: styles.listItem,
+ suggestionHighlighted: styles.highlighted
+ };
+
+ return (
+
+ );
+ }
+}
+
+ArtistSearchInput.propTypes = {
+ artist: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onGoToArtist: PropTypes.func.isRequired,
+ onGoToAddNewArtist: PropTypes.func.isRequired,
+ bindShortcut: PropTypes.func.isRequired
+};
+
+export default keyboardShortcuts(ArtistSearchInput);
diff --git a/frontend/src/Components/Page/Header/ArtistSearchInputConnector.js b/frontend/src/Components/Page/Header/ArtistSearchInputConnector.js
new file mode 100644
index 000000000..3bf1a1678
--- /dev/null
+++ b/frontend/src/Components/Page/Header/ArtistSearchInputConnector.js
@@ -0,0 +1,31 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { push } from 'react-router-redux';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import ArtistSearchInput from './ArtistSearchInput';
+
+function createMapStateToProps() {
+ return createSelector(
+ createAllArtistSelector(),
+ (artist) => {
+ return {
+ artist: _.sortBy(artist, 'sortName')
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onGoToArtist(nameSlug) {
+ dispatch(push(`${window.Sonarr.urlBase}/artist/${nameSlug}`));
+ },
+
+ onGoToAddNewArtist(query) {
+ dispatch(push(`${window.Sonarr.urlBase}/add/new?term=${encodeURIComponent(query)}`));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistSearchInput);
diff --git a/frontend/src/Components/Page/Header/ArtistSearchResult.css b/frontend/src/Components/Page/Header/ArtistSearchResult.css
new file mode 100644
index 000000000..87988a35b
--- /dev/null
+++ b/frontend/src/Components/Page/Header/ArtistSearchResult.css
@@ -0,0 +1,34 @@
+.result {
+ display: flex;
+ padding: 3px;
+ cursor: pointer;
+}
+
+.poster {
+ width: 35px;
+ height: 50px;
+}
+
+.titles {
+ flex: 1 1 1px;
+}
+
+.title {
+ flex: 1 1 1px;
+ margin-left: 5px;
+}
+
+.alternateTitle {
+ flex: 1 1 1px;
+ margin-left: 5px;
+ color: $disabledColor;
+ font-size: $smallFontSize;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .titles,
+ .title,
+ .alternateTitle {
+ @add-mixin truncate;
+ }
+}
diff --git a/frontend/src/Components/Page/Header/ArtistSearchResult.js b/frontend/src/Components/Page/Header/ArtistSearchResult.js
new file mode 100644
index 000000000..95186d039
--- /dev/null
+++ b/frontend/src/Components/Page/Header/ArtistSearchResult.js
@@ -0,0 +1,59 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ArtistPoster from 'Artist/ArtistPoster';
+import styles from './ArtistSearchResult.css';
+
+function getMatchingAlternateTile(alternateTitles, query) {
+ return _.first(alternateTitles, (alternateTitle) => {
+ return alternateTitle.title.toLowerCase().contains(query.toLowerCase());
+ });
+}
+
+function ArtistSearchResult(props) {
+ const {
+ query,
+ artistName,
+ // alternateTitles,
+ images
+ } = props;
+
+ const index = artistName.toLowerCase().indexOf(query.toLowerCase());
+ // const alternateTitle = index === -1 ?
+ // getMatchingAlternateTile(alternateTitles, query) :
+ // null;
+
+ return (
+
+
+
+
+
+ {artistName}
+
+
+ {
+ // !!alternateTitle &&
+ //
+ // {alternateTitle.title}
+ //
+ }
+
+
+ );
+}
+
+ArtistSearchResult.propTypes = {
+ query: PropTypes.string.isRequired,
+ artistName: PropTypes.string.isRequired,
+ // alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ images: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default ArtistSearchResult;
diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js
new file mode 100644
index 000000000..a1d106b58
--- /dev/null
+++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModal.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { sizes } from 'Helpers/Props';
+import Modal from 'Components/Modal/Modal';
+import KeyboardShortcutsModalContentConnector from './KeyboardShortcutsModalContentConnector';
+
+function KeyboardShortcutsModal(props) {
+ const {
+ isOpen,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ );
+}
+
+KeyboardShortcutsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default KeyboardShortcutsModal;
diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css
new file mode 100644
index 000000000..4425e0e0d
--- /dev/null
+++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.css
@@ -0,0 +1,15 @@
+.shortcut {
+ display: flex;
+ justify-content: space-between;
+ padding: 5px 20px;
+ font-size: 18px;
+}
+
+.key {
+ padding: 2px 4px;
+ border-radius: 3px;
+ background-color: $defaultColor;
+ box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25);
+ color: $white;
+ font-size: 16px;
+}
diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js
new file mode 100644
index 000000000..9c07e047c
--- /dev/null
+++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContent.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { shortcuts } from 'Components/keyboardShortcuts';
+import Button from 'Components/Link/Button';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './KeyboardShortcutsModalContent.css';
+
+function getShortcuts() {
+ const allShortcuts = [];
+
+ Object.keys(shortcuts).forEach((key) => {
+ allShortcuts.push(shortcuts[key]);
+ });
+
+ return allShortcuts;
+}
+
+function getShortcutKey(combo, isOsx) {
+ const comboMatch = combo.match(/(.+?)\+(.)/);
+
+ if (!comboMatch) {
+ return combo;
+ }
+
+ const modifier = comboMatch[1];
+ const key = comboMatch[2];
+ let osModifier = modifier;
+
+ if (modifier === 'mod') {
+ osModifier = isOsx ? 'cmd' : 'ctrl';
+ }
+
+ return `${osModifier} + ${key}`;
+}
+
+function KeyboardShortcutsModalContent(props) {
+ const {
+ isOsx,
+ onModalClose
+ } = props;
+
+ const allShortcuts = getShortcuts();
+
+ return (
+
+
+ Keyboard Shortcuts
+
+
+
+ {
+ allShortcuts.map((shortcut) => {
+ return (
+
+
+ {getShortcutKey(shortcut.key, isOsx)}
+
+
+
+ {shortcut.name}
+
+
+ );
+ })
+ }
+
+
+
+
+ Close
+
+
+
+ );
+}
+
+KeyboardShortcutsModalContent.propTypes = {
+ isOsx: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default KeyboardShortcutsModalContent;
diff --git a/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js
new file mode 100644
index 000000000..d80877153
--- /dev/null
+++ b/frontend/src/Components/Page/Header/KeyboardShortcutsModalContentConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createSystemStatusSelector(),
+ (systemStatus) => {
+ return {
+ isOsx: systemStatus.isOsx
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(KeyboardShortcutsModalContent);
diff --git a/frontend/src/Components/Page/Header/PageHeader.css b/frontend/src/Components/Page/Header/PageHeader.css
new file mode 100644
index 000000000..3bfcbc10b
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeader.css
@@ -0,0 +1,60 @@
+.header {
+ z-index: 3;
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+ height: $headerHeight;
+ background-color: #00a65b;
+ color: $white;
+}
+
+.logoContainer {
+ display: flex;
+ justify-content: center;
+ flex: 0 0 $sidebarWidth;
+}
+
+.logo {
+ width: 32px;
+ height: 32px;
+}
+
+.sidebarToggleContainer {
+ display: none;
+ justify-content: center;
+ flex: 0 0 45px;
+ margin-right: 14px;
+}
+
+.right {
+ display: flex;
+ justify-content: flex-end;
+ flex-grow: 1;
+}
+
+.donate {
+ composes: link from 'Components/Link/Link.css';
+
+ width: 30px;
+ color: $themeRed;
+ text-align: center;
+ line-height: 60px;
+
+ &:hover {
+ color: #9c1f30;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .logoContainer {
+ flex: 0 0 60px;
+ }
+
+ .sidebarToggleContainer {
+ display: flex;
+ }
+
+ .donate {
+ display: none;
+ }
+}
diff --git a/frontend/src/Components/Page/Header/PageHeader.js b/frontend/src/Components/Page/Header/PageHeader.js
new file mode 100644
index 000000000..2d6b3dbd6
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeader.js
@@ -0,0 +1,96 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
+import IconButton from 'Components/Link/IconButton';
+import Link from 'Components/Link/Link';
+import ArtistSearchInputConnector from './ArtistSearchInputConnector';
+import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
+import KeyboardShortcutsModal from './KeyboardShortcutsModal';
+import styles from './PageHeader.css';
+
+class PageHeader extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props);
+
+ this.state = {
+ isKeyboardShortcutsModalOpen: false
+ };
+ }
+
+ componentDidMount() {
+ this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.openKeyboardShortcutsModal);
+ }
+
+ //
+ // Control
+
+ openKeyboardShortcutsModal = () => {
+ this.setState({ isKeyboardShortcutsModalOpen: true });
+ }
+
+ //
+ // Listeners
+
+ onKeyboardShortcutsModalClose = () => {
+ this.setState({ isKeyboardShortcutsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ onSidebarToggle
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+PageHeader.propTypes = {
+ onSidebarToggle: PropTypes.func.isRequired,
+ bindShortcut: PropTypes.func.isRequired
+};
+
+export default keyboardShortcuts(PageHeader);
diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css
new file mode 100644
index 000000000..44aa20453
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.css
@@ -0,0 +1,27 @@
+.menuButton {
+ margin-right: 15px;
+ width: 30px;
+ height: 60px;
+ text-align: center;
+
+ &:hover {
+ color: $themeDarkColor;
+ }
+}
+
+.itemIcon {
+ margin-right: 8px;
+}
+
+.separator {
+ overflow: hidden;
+ height: 1px;
+ background-color: $themeDarkColor;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .menuButton {
+ margin-right: 5px;
+ }
+}
+
diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js
new file mode 100644
index 000000000..a261c1b01
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js
@@ -0,0 +1,75 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { align, icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Menu from 'Components/Menu/Menu';
+import MenuButton from 'Components/Menu/MenuButton';
+import MenuContent from 'Components/Menu/MenuContent';
+import MenuItem from 'Components/Menu/MenuItem';
+import styles from './PageHeaderActionsMenu.css';
+
+function PageHeaderActionsMenu(props) {
+ const {
+ formsAuth,
+ onRestartPress,
+ onShutdownPress
+ } = props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ Restart
+
+
+
+
+ Shutdown
+
+
+ {
+ formsAuth &&
+
+ }
+
+ {
+ formsAuth &&
+
+
+ Logout
+
+ }
+
+
+
+ );
+}
+
+PageHeaderActionsMenu.propTypes = {
+ formsAuth: PropTypes.bool.isRequired,
+ onRestartPress: PropTypes.func.isRequired,
+ onShutdownPress: PropTypes.func.isRequired
+};
+
+export default PageHeaderActionsMenu;
diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js
new file mode 100644
index 000000000..66d131521
--- /dev/null
+++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js
@@ -0,0 +1,56 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { restart, shutdown } from 'Store/Actions/systemActions';
+import PageHeaderActionsMenu from './PageHeaderActionsMenu';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.status,
+ (status) => {
+ return {
+ formsAuth: status.item.authentication === 'forms'
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ restart,
+ shutdown
+};
+
+class PageHeaderActionsMenuConnector extends Component {
+
+ //
+ // Listeners
+
+ onRestartPress = () => {
+ this.props.restart();
+ }
+
+ onShutdownPress = () => {
+ this.props.shutdown();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+PageHeaderActionsMenuConnector.propTypes = {
+ restart: PropTypes.func.isRequired,
+ shutdown: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector);
diff --git a/frontend/src/Components/Page/LoadingPage.css b/frontend/src/Components/Page/LoadingPage.css
new file mode 100644
index 000000000..dd5852e61
--- /dev/null
+++ b/frontend/src/Components/Page/LoadingPage.css
@@ -0,0 +1,3 @@
+.page {
+ composes: page from './Page.css';
+}
diff --git a/frontend/src/Components/Page/LoadingPage.js b/frontend/src/Components/Page/LoadingPage.js
new file mode 100644
index 000000000..398b70c4b
--- /dev/null
+++ b/frontend/src/Components/Page/LoadingPage.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import LoadingMessage from 'Components/Loading/LoadingMessage';
+import styles from './LoadingPage.css';
+
+function LoadingPage() {
+ return (
+
+
+
+
+ );
+}
+
+export default LoadingPage;
diff --git a/frontend/src/Components/Page/Page.css b/frontend/src/Components/Page/Page.css
new file mode 100644
index 000000000..9facbfc22
--- /dev/null
+++ b/frontend/src/Components/Page/Page.css
@@ -0,0 +1,18 @@
+.page {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.main {
+ position: relative; /* need this to position inner content - is this really needed? */
+ display: flex;
+ flex: 1 1 auto;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .page {
+ flex-grow: 1;
+ height: initial;
+ }
+}
diff --git a/frontend/src/Components/Page/Page.js b/frontend/src/Components/Page/Page.js
new file mode 100644
index 000000000..fc7502fd4
--- /dev/null
+++ b/frontend/src/Components/Page/Page.js
@@ -0,0 +1,130 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import locationShape from 'Helpers/Props/Shapes/locationShape';
+import SignalRConnector from 'Components/SignalRConnector';
+import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector';
+import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector';
+import PageHeader from './Header/PageHeader';
+import PageSidebar from './Sidebar/PageSidebar';
+import styles from './Page.css';
+
+class Page extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isUpdatedModalOpen: false,
+ isConnectionLostModalOpen: false
+ };
+ }
+
+ componentDidMount() {
+ window.addEventListener('resize', this.onResize);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isDisconnected,
+ isUpdated
+ } = this.props;
+
+ if (!prevProps.isUpdated && isUpdated) {
+ this.setState({ isUpdatedModalOpen: true });
+ }
+
+ if (prevProps.isDisconnected !== isDisconnected) {
+ this.setState({ isConnectionLostModalOpen: isDisconnected });
+ }
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.onResize);
+ }
+
+ //
+ // Listeners
+
+ onResize = () => {
+ this.props.onResize({
+ width: window.innerWidth,
+ height: window.innerHeight
+ });
+ }
+
+ onUpdatedModalClose = () => {
+ this.setState({ isUpdatedModalOpen: false });
+ }
+
+ onConnectionLostModalClose = () => {
+ this.setState({ isConnectionLostModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ location,
+ children,
+ isSmallScreen,
+ isSidebarVisible,
+ onSidebarToggle,
+ onSidebarVisibleChange
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+Page.propTypes = {
+ className: PropTypes.string,
+ location: locationShape.isRequired,
+ children: PropTypes.node.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ isSidebarVisible: PropTypes.bool.isRequired,
+ isUpdated: PropTypes.bool.isRequired,
+ isDisconnected: PropTypes.bool.isRequired,
+ onResize: PropTypes.func.isRequired,
+ onSidebarToggle: PropTypes.func.isRequired,
+ onSidebarVisibleChange: PropTypes.func.isRequired
+};
+
+Page.defaultProps = {
+ className: styles.page
+};
+
+export default Page;
diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js
new file mode 100644
index 000000000..416b194b9
--- /dev/null
+++ b/frontend/src/Components/Page/PageConnector.js
@@ -0,0 +1,179 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { createSelector } from 'reselect';
+import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
+import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
+import { fetchArtist } from 'Store/Actions/artistActions';
+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.artist,
+ (state) => state.tags,
+ (state) => state.settings,
+ (state) => state.app,
+ createDimensionsSelector(),
+ (artist, tags, settings, app, dimensions) => {
+ const isPopulated = artist.isPopulated &&
+ tags.isPopulated &&
+ settings.qualityProfiles.isPopulated &&
+ settings.ui.isPopulated;
+
+ const hasError = !!artist.error ||
+ !!tags.error ||
+ !!settings.qualityProfiles.error ||
+ !!settings.ui.error;
+
+ return {
+ isPopulated,
+ hasError,
+ artistError: artist.error,
+ tagsError: tags.error,
+ qualityProfilesError: settings.qualityProfiles.error,
+ uiSettingsError: settings.ui.error,
+ isSmallScreen: dimensions.isSmallScreen,
+ isSidebarVisible: app.isSidebarVisible,
+ version: app.version,
+ isUpdated: app.isUpdated,
+ isDisconnected: app.isDisconnected
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchFetchSeries() {
+ dispatch(fetchArtist());
+ },
+ dispatchFetchTags() {
+ dispatch(fetchTags());
+ },
+ dispatchFetchQualityProfiles() {
+ dispatch(fetchQualityProfiles());
+ },
+ dispatchFetchLanguageProfiles() {
+ dispatch(fetchLanguageProfiles());
+ },
+ dispatchFetchUISettings() {
+ dispatch(fetchUISettings());
+ },
+ dispatchFetchStatus() {
+ dispatch(fetchStatus());
+ },
+ onResize(dimensions) {
+ dispatch(saveDimensions(dimensions));
+ },
+ onSidebarVisibleChange(isSidebarVisible) {
+ dispatch(setIsSidebarVisible({ isSidebarVisible }));
+ }
+ };
+}
+
+class PageConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isLocalStorageSupported: testLocalStorage()
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.isPopulated) {
+ this.props.dispatchFetchSeries();
+ this.props.dispatchFetchTags();
+ this.props.dispatchFetchQualityProfiles();
+ this.props.dispatchFetchLanguageProfiles();
+ this.props.dispatchFetchUISettings();
+ this.props.dispatchFetchStatus();
+ }
+ }
+
+ //
+ // Listeners
+
+ onSidebarToggle = () => {
+ this.props.onSidebarVisibleChange(!this.props.isSidebarVisible);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isPopulated,
+ hasError,
+ dispatchFetchSeries,
+ dispatchFetchTags,
+ dispatchFetchQualityProfiles,
+ dispatchFetchLanguageProfiles,
+ dispatchFetchUISettings,
+ dispatchFetchStatus,
+ ...otherProps
+ } = this.props;
+
+ if (hasError || !this.state.isLocalStorageSupported) {
+ return (
+
+ );
+ }
+
+ if (isPopulated) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+PageConnector.propTypes = {
+ isPopulated: PropTypes.bool.isRequired,
+ hasError: PropTypes.bool.isRequired,
+ isSidebarVisible: PropTypes.bool.isRequired,
+ dispatchFetchSeries: PropTypes.func.isRequired,
+ dispatchFetchTags: PropTypes.func.isRequired,
+ dispatchFetchQualityProfiles: PropTypes.func.isRequired,
+ dispatchFetchLanguageProfiles: PropTypes.func.isRequired,
+ dispatchFetchUISettings: PropTypes.func.isRequired,
+ dispatchFetchStatus: PropTypes.func.isRequired,
+ onSidebarVisibleChange: PropTypes.func.isRequired
+};
+
+export default withRouter(
+ connect(createMapStateToProps, createMapDispatchToProps)(PageConnector)
+);
diff --git a/frontend/src/Components/Page/PageContent.css b/frontend/src/Components/Page/PageContent.css
new file mode 100644
index 000000000..4580077c3
--- /dev/null
+++ b/frontend/src/Components/Page/PageContent.css
@@ -0,0 +1,8 @@
+.content {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ overflow-x: hidden;
+ width: 100%;
+}
diff --git a/frontend/src/Components/Page/PageContent.js b/frontend/src/Components/Page/PageContent.js
new file mode 100644
index 000000000..a416d268c
--- /dev/null
+++ b/frontend/src/Components/Page/PageContent.js
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import DocumentTitle from 'react-document-title';
+import styles from './PageContent.css';
+
+function PageContent(props) {
+ const {
+ className,
+ title,
+ children
+ } = props;
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+PageContent.propTypes = {
+ className: PropTypes.string,
+ title: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+PageContent.defaultProps = {
+ className: styles.content
+};
+
+export default PageContent;
diff --git a/frontend/src/Components/Page/PageContentBody.css b/frontend/src/Components/Page/PageContentBody.css
new file mode 100644
index 000000000..8b41754dd
--- /dev/null
+++ b/frontend/src/Components/Page/PageContentBody.css
@@ -0,0 +1,19 @@
+.contentBody {
+ /* 1px for flex-basis so the div grows correctly in Edge/Firefox */
+ flex: 1 0 1px;
+}
+
+.innerContentBody {
+ padding: $pageContentBodyPadding;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .contentBody {
+ flex-basis: auto;
+ overflow-y: hidden !important;
+ }
+
+ .innerContentBody {
+ padding: $pageContentBodyPaddingSmallScreen;
+ }
+}
diff --git a/frontend/src/Components/Page/PageContentBody.js b/frontend/src/Components/Page/PageContentBody.js
new file mode 100644
index 000000000..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/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..25844ec94
--- /dev/null
+++ b/frontend/src/Components/Page/PageJumpBar.js
@@ -0,0 +1,133 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Measure from 'react-measure';
+import dimensions from 'Styles/Variables/dimensions';
+import PageJumpBarItem from './PageJumpBarItem';
+import styles from './PageJumpBar.css';
+
+const ITEM_HEIGHT = parseInt(dimensions.jumpBarItemHeight);
+
+class PageJumpBar extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ height: 0,
+ visibleItems: props.items
+ };
+ }
+
+ componentDidMount() {
+ this.computeVisibleItems();
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (
+ prevProps.items !== this.props.items ||
+ prevState.height !== this.state.height
+ ) {
+ this.computeVisibleItems();
+ }
+ }
+
+ //
+ // Control
+
+ computeVisibleItems() {
+ const {
+ items,
+ minimumItems
+ } = this.props;
+
+ const height = this.state.height;
+ const maximumItems = Math.floor(height / ITEM_HEIGHT);
+ const diff = items.length - maximumItems;
+
+ if (diff < 0) {
+ this.setState({ visibleItems: items });
+ return;
+ }
+
+ if (items.length < minimumItems) {
+ this.setState({ visibleItems: items });
+ return;
+ }
+
+ const removeDiff = Math.ceil(items.length / maximumItems);
+
+ const visibleItems = _.reduce(items, (acc, item, index) => {
+ if (index % removeDiff === 0) {
+ acc.push(item);
+ }
+
+ return acc;
+ }, []);
+
+ this.setState({ visibleItems });
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ height }) => {
+ this.setState({ height });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ minimumItems,
+ onItemPress
+ } = this.props;
+
+ const {
+ visibleItems
+ } = this.state;
+
+ if (!visibleItems.length || visibleItems.length < minimumItems) {
+ return null;
+ }
+
+ return (
+
+
+
+ {
+ visibleItems.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+ }
+}
+
+PageJumpBar.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.string).isRequired,
+ minimumItems: PropTypes.number.isRequired,
+ onItemPress: PropTypes.func.isRequired
+};
+
+PageJumpBar.defaultProps = {
+ minimumItems: 5
+};
+
+export default PageJumpBar;
diff --git a/frontend/src/Components/Page/PageJumpBarItem.css b/frontend/src/Components/Page/PageJumpBarItem.css
new file mode 100644
index 000000000..e829dd31a
--- /dev/null
+++ b/frontend/src/Components/Page/PageJumpBarItem.css
@@ -0,0 +1,14 @@
+.jumpBarItem {
+ flex: 1 0 $jumpBarItemHeight;
+ border-bottom: 1px solid $borderColor;
+ text-align: center;
+ font-weight: bold;
+
+ &:hover {
+ color: #777;
+ }
+
+ &:last-child {
+ border: none;
+ }
+}
diff --git a/frontend/src/Components/Page/PageJumpBarItem.js b/frontend/src/Components/Page/PageJumpBarItem.js
new file mode 100644
index 000000000..aeffe4ddd
--- /dev/null
+++ b/frontend/src/Components/Page/PageJumpBarItem.js
@@ -0,0 +1,40 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './PageJumpBarItem.css';
+
+class PageJumpBarItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ label,
+ onItemPress
+ } = this.props;
+
+ onItemPress(label);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ {this.props.label.toUpperCase()}
+
+ );
+ }
+}
+
+PageJumpBarItem.propTypes = {
+ label: PropTypes.string.isRequired,
+ onItemPress: PropTypes.func.isRequired
+};
+
+export default PageJumpBarItem;
diff --git a/frontend/src/Components/Page/PageSectionContent.js b/frontend/src/Components/Page/PageSectionContent.js
new file mode 100644
index 000000000..774b88669
--- /dev/null
+++ b/frontend/src/Components/Page/PageSectionContent.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+
+function PageSectionContent(props) {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ errorMessage,
+ children
+ } = props;
+
+ if (isFetching) {
+ return (
+
+ );
+ } else if (!isFetching && !!error) {
+ return (
+ {errorMessage}
+ );
+ } else if (isPopulated && !error) {
+ return (
+ {children}
+ );
+ }
+
+ return null;
+}
+
+PageSectionContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ errorMessage: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired
+};
+
+export default PageSectionContent;
diff --git a/frontend/src/Components/Page/Sidebar/Messages/Message.css b/frontend/src/Components/Page/Sidebar/Messages/Message.css
new file mode 100644
index 000000000..7d53adb69
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/Message.css
@@ -0,0 +1,42 @@
+.message {
+ display: flex;
+ border-left: 3px solid $infoColor;
+}
+
+.iconContainer,
+.text {
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ padding: 2px 0;
+ color: $sidebarColor;
+}
+
+.iconContainer {
+ flex: 0 0 25px;
+ margin-left: 24px;
+ padding: 10px 0;
+}
+
+.text {
+ margin-right: 24px;
+ font-size: 13px;
+}
+
+/* Types */
+
+.error {
+ border-left-color: $dangerColor;
+}
+
+.info {
+ border-left-color: $infoColor;
+}
+
+.success {
+ border-left-color: $successColor;
+}
+
+.warning {
+ border-left-color: $warningColor;
+}
diff --git a/frontend/src/Components/Page/Sidebar/Messages/Message.js b/frontend/src/Components/Page/Sidebar/Messages/Message.js
new file mode 100644
index 000000000..ed3a8325c
--- /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 'RefreshArtist':
+ return icons.REFRESH;
+ case 'RssSync':
+ return icons.RSS;
+ case 'SeasonSearch':
+ return icons.SEARCH;
+ case 'ArtistSearch':
+ return icons.SEARCH;
+ case 'UpdateSceneMapping':
+ return icons.REFRESH;
+ default:
+ return icons.SPINNER;
+ }
+}
+
+function Message(props) {
+ const {
+ name,
+ message,
+ type
+ } = props;
+
+ return (
+
+
+
+
+
+
+ {message}
+
+
+ );
+}
+
+Message.propTypes = {
+ name: PropTypes.string.isRequired,
+ message: PropTypes.string.isRequired,
+ type: PropTypes.string.isRequired
+};
+
+export default Message;
diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js
new file mode 100644
index 000000000..06c545c27
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/MessageConnector.js
@@ -0,0 +1,67 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { hideMessage } from 'Store/Actions/appActions';
+import Message from './Message';
+
+const mapDispatchToProps = {
+ hideMessage
+};
+
+class MessageConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._hideTimeoutId = null;
+ this.scheduleHideMessage(props.hideAfter);
+ }
+
+ componentDidUpdate() {
+ this.scheduleHideMessage(this.props.hideAfter);
+ }
+
+ //
+ // Control
+
+ scheduleHideMessage = (hideAfter) => {
+ if (this._hideTimeoutId) {
+ clearTimeout(this._hideTimeoutId);
+ }
+
+ if (hideAfter) {
+ this._hideTimeoutId = setTimeout(this.hideMessage, hideAfter * 1000);
+ }
+ }
+
+ hideMessage = () => {
+ this.props.hideMessage({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MessageConnector.propTypes = {
+ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ hideAfter: PropTypes.number.isRequired,
+ hideMessage: PropTypes.func.isRequired
+};
+
+MessageConnector.defaultProps = {
+ // Hide messages after 60 seconds if there is no activity
+ // hideAfter: 60
+};
+
+export default connect(undefined, mapDispatchToProps)(MessageConnector);
diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.css b/frontend/src/Components/Page/Sidebar/Messages/Messages.css
new file mode 100644
index 000000000..ef01ad02c
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.css
@@ -0,0 +1,11 @@
+.messages {
+ margin-top: auto;
+ margin-bottom: 20px;
+ padding-top: 20px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .messages {
+ margin-bottom: 0;
+ }
+}
diff --git a/frontend/src/Components/Page/Sidebar/Messages/Messages.js b/frontend/src/Components/Page/Sidebar/Messages/Messages.js
new file mode 100644
index 000000000..ec8876f6e
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/Messages.js
@@ -0,0 +1,27 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import MessageConnector from './MessageConnector';
+import styles from './Messages.css';
+
+function Messages({ messages }) {
+ return (
+
+ {
+ messages.map((message) => {
+ return (
+
+ );
+ })
+ }
+
+ );
+}
+
+Messages.propTypes = {
+ messages: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Messages;
diff --git a/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js
new file mode 100644
index 000000000..5d20d9194
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/Messages/MessagesConnector.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import Messages from './Messages';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.messages.items,
+ (messages) => {
+ return {
+ messages: messages.slice().reverse()
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(Messages);
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.css b/frontend/src/Components/Page/Sidebar/PageSidebar.css
new file mode 100644
index 000000000..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..50ddc3ae7
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js
@@ -0,0 +1,514 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import locationShape from 'Helpers/Props/Shapes/locationShape';
+import dimensions from 'Styles/Variables/dimensions';
+import OverlayScroller from 'Components/Scroller/OverlayScroller';
+import Scroller from 'Components/Scroller/Scroller';
+import QueueStatusConnector from 'Activity/Queue/Status/QueueStatusConnector';
+import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector';
+import MessagesConnector from './Messages/MessagesConnector';
+import PageSidebarItem from './PageSidebarItem';
+import styles from './PageSidebar.css';
+
+const HEADER_HEIGHT = parseInt(dimensions.headerHeight);
+const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth);
+
+const links = [
+ {
+ iconName: icons.ARTIST_CONTINUING,
+ title: 'Artist',
+ to: '/',
+ alias: '/artist',
+ children: [
+ {
+ title: 'Add New',
+ to: '/add/new'
+ },
+ {
+ title: 'Import',
+ to: '/add/import'
+ },
+ {
+ title: 'Mass Editor',
+ to: '/artisteditor'
+ },
+ {
+ title: 'Album Studio',
+ to: '/albumstudio'
+ }
+ ]
+ },
+
+ {
+ 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: '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 > 30) {
+ return;
+ }
+
+ this._touchStartX = touchStartX;
+ this._touchStartY = touchStartY;
+ }
+
+ onTouchMove = (event) => {
+ const touches = event.touches;
+ const currentTouchX = touches[0].pageX;
+ const currentTouchY = touches[0].pageY;
+
+ if (!this._touchStartX) {
+ return;
+ }
+
+ if (Math.abs(this._touchStartY - currentTouchY) > 20) {
+ this.setState({
+ transition: 'none',
+ transform: 0
+ });
+
+ return;
+ }
+
+ if (Math.abs(this._touchStartX - currentTouchX) < 20) {
+ 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..450161705
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css
@@ -0,0 +1,48 @@
+.item {
+ border-left: 3px solid transparent;
+ color: $sidebarColor;
+ transition: border-left 0.3s ease-in-out;
+}
+
+.isActiveItem {
+ border-left: 3px solid $themeBlue;
+}
+
+.link {
+ display: block;
+ padding: 12px 24px;
+ color: $sidebarColor;
+
+ &:hover,
+ &:focus {
+ color: $themeBlue;
+ text-decoration: none;
+ }
+}
+
+.childLink {
+ composes: link;
+
+ padding: 10px 24px;
+}
+
+.isActiveLink {
+ color: $themeBlue;
+}
+
+.isActiveParentLink {
+ background-color: $sidebarActiveBackgroundColor;
+}
+
+.iconContainer {
+ display: inline-block;
+ width: 25px;
+}
+
+.noIcon {
+ margin-left: 25px;
+}
+
+.status {
+ float: right;
+}
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js
new file mode 100644
index 000000000..cfc694395
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { map } from 'Helpers/elementChildren';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import styles from './PageSidebarItem.css';
+
+class PageSidebarItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ isChildItem,
+ isParentItem,
+ onPress
+ } = this.props;
+
+ if (isChildItem || !isParentItem) {
+ onPress();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ iconName,
+ title,
+ to,
+ isActive,
+ isActiveParent,
+ isChildItem,
+ statusComponent: StatusComponent,
+ children
+ } = this.props;
+
+ return (
+
+
+ {
+ !!iconName &&
+
+
+
+ }
+
+
+ {title}
+
+
+ {
+ !!StatusComponent &&
+
+
+
+ }
+
+
+ {
+ children &&
+ map(children, (child) => {
+ return React.cloneElement(child, { isChildItem: true });
+ })
+ }
+
+ );
+ }
+}
+
+PageSidebarItem.propTypes = {
+ iconName: PropTypes.string,
+ title: PropTypes.string.isRequired,
+ to: PropTypes.string.isRequired,
+ isActive: PropTypes.bool,
+ isActiveParent: PropTypes.bool,
+ isParentItem: PropTypes.bool.isRequired,
+ isChildItem: PropTypes.bool.isRequired,
+ statusComponent: PropTypes.func,
+ children: PropTypes.node,
+ onPress: PropTypes.func
+};
+
+PageSidebarItem.defaultProps = {
+ isChildItem: false
+};
+
+export default PageSidebarItem;
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css
new file mode 100644
index 000000000..4dd0cc678
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.css
@@ -0,0 +1,3 @@
+.status {
+ composes: label from 'Components/Label.css';
+}
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js
new file mode 100644
index 000000000..c1ea615ed
--- /dev/null
+++ b/frontend/src/Components/Page/Sidebar/PageSidebarStatus.js
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import Label from 'Components/Label';
+
+function PageSidebarStatus({ count, errors, warnings }) {
+ if (!count) {
+ return null;
+ }
+
+ let kind = kinds.INFO;
+
+ if (errors) {
+ kind = kinds.DANGER;
+ } else if (warnings) {
+ kind = kinds.WARNING;
+ }
+
+ return (
+
+ {count}
+
+ );
+}
+
+PageSidebarStatus.propTypes = {
+ count: PropTypes.number,
+ errors: PropTypes.bool,
+ warnings: PropTypes.bool
+};
+
+export default PageSidebarStatus;
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.css b/frontend/src/Components/Page/Toolbar/PageToolbar.css
new file mode 100644
index 000000000..e040bc884
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbar.css
@@ -0,0 +1,16 @@
+.toolbar {
+ display: flex;
+ justify-content: space-between;
+ flex: 0 0 auto;
+ padding: 0 20px;
+ height: $toolbarHeight;
+ background-color: $toolbarBackgroundColor;
+ color: $toolbarColor;
+ line-height: 60px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .toolbar {
+ padding: 0 10px;
+ }
+}
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbar.js b/frontend/src/Components/Page/Toolbar/PageToolbar.js
new file mode 100644
index 000000000..728f1b0d9
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbar.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './PageToolbar.css';
+
+class PageToolbar extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+PageToolbar.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+PageToolbar.defaultProps = {
+ className: styles.toolbar
+};
+
+export default PageToolbar;
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.css b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css
new file mode 100644
index 000000000..e788303a2
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css
@@ -0,0 +1,28 @@
+.toolbarButton {
+ composes: link from 'Components/Link/Link.css';
+
+ width: $toolbarButtonWidth;
+ text-align: center;
+
+ &:hover {
+ color: $toobarButtonHoverColor;
+ }
+}
+
+.isDisabled {
+ color: $disabledColor;
+}
+
+.labelContainer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 16px;
+}
+
+.label {
+ padding: 0 3px;
+ color: $toolbarLabelColor;
+ font-size: $extraSmallFontSize;
+ line-height: calc($extraSmallFontSize + 1px);
+}
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js
new file mode 100644
index 000000000..752ca15ac
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js
@@ -0,0 +1,56 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import styles from './PageToolbarButton.css';
+
+function PageToolbarButton(props) {
+ const {
+ label,
+ iconName,
+ spinningName,
+ isDisabled,
+ isSpinning,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+
+
+ );
+}
+
+PageToolbarButton.propTypes = {
+ label: PropTypes.string.isRequired,
+ iconName: PropTypes.string.isRequired,
+ spinningName: PropTypes.string,
+ isSpinning: PropTypes.bool,
+ isDisabled: PropTypes.bool
+};
+
+PageToolbarButton.defaultProps = {
+ spinningName: icons.SPINNER,
+ isDisabled: false,
+ isSpinning: false
+};
+
+export default PageToolbarButton;
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.css b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css
new file mode 100644
index 000000000..2767c163c
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.css
@@ -0,0 +1,26 @@
+.sectionContainer {
+ display: flex;
+ flex: 1 1 10%;
+}
+
+.section {
+ display: flex;
+ align-items: center;
+ flex-grow: 1;
+}
+
+.left {
+ justify-content: flex-start;
+}
+
+.center {
+ justify-content: center;
+}
+
+.right {
+ justify-content: flex-end;
+}
+
+.overflowMenuItemIcon {
+ margin-right: 8px;
+}
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js
new file mode 100644
index 000000000..82e30f6eb
--- /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 Measure from 'react-measure';
+import classNames from 'classnames';
+import { forEach } from 'Helpers/elementChildren';
+import { align, icons } from 'Helpers/Props';
+import dimensions from 'Styles/Variables/dimensions';
+import SpinnerIcon from 'Components/SpinnerIcon';
+import Menu from 'Components/Menu/Menu';
+import MenuContent from 'Components/Menu/MenuContent';
+import MenuItem from 'Components/Menu/MenuItem';
+import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
+import styles from './PageToolbarSection.css';
+
+const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth);
+const SEPARATOR_MARGIN = parseInt(dimensions.toolbarSeparatorMargin);
+const SEPARATOR_WIDTH = 2 * SEPARATOR_MARGIN + 1;
+const SEPARATOR_NAME = 'PageToolbarSeparator';
+
+function calculateOverflowItems(children, isMeasured, width, collapseButtons) {
+ let buttonCount = 0;
+ let separatorCount = 0;
+ const validChildren = [];
+
+ forEach(children, (child) => {
+ const name = child.type.name;
+
+ if (name === SEPARATOR_NAME) {
+ separatorCount++;
+ } else {
+ buttonCount++;
+ }
+
+ validChildren.push(child);
+ });
+
+ const buttonsWidth = buttonCount * BUTTON_WIDTH;
+ const separatorsWidth = separatorCount + SEPARATOR_WIDTH;
+ const totalWidth = buttonsWidth + separatorsWidth;
+
+ // If the width of buttons and separators is less than
+ // the available width return all valid children.
+
+ if (
+ !isMeasured ||
+ !collapseButtons ||
+ totalWidth < width
+ ) {
+ return {
+ buttons: validChildren,
+ buttonCount,
+ overflowItems: []
+ };
+ }
+
+ const maxButtons = Math.max(Math.floor((width - separatorsWidth) / BUTTON_WIDTH), 1);
+ const buttons = [];
+ const overflowItems = [];
+ let actualButtons = 0;
+
+ // Return all buttons if only one is being pushed to the overflow menu.
+ if (buttonCount - 1 === maxButtons) {
+ return {
+ buttons: validChildren,
+ buttonCount,
+ overflowItems: []
+ };
+ }
+
+ validChildren.forEach((child, index) => {
+ if (actualButtons < maxButtons) {
+ if (child.type.name !== SEPARATOR_NAME) {
+ buttons.push(child);
+ actualButtons++;
+ }
+ } else if (child.type.name !== SEPARATOR_NAME) {
+ overflowItems.push(child.props);
+ }
+ });
+
+ return {
+ buttons,
+ buttonCount,
+ overflowItems
+ };
+}
+
+class PageToolbarSection extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMeasured: false,
+ width: 0,
+ buttons: [],
+ overflowItems: []
+ };
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.setState({
+ isMeasured: true,
+ width
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ children,
+ alignContent,
+ collapseButtons
+ } = this.props;
+
+ const {
+ isMeasured,
+ width
+ } = this.state;
+
+ const {
+ buttons,
+ buttonCount,
+ overflowItems
+ } = calculateOverflowItems(children, isMeasured, width, collapseButtons);
+
+ return (
+
+
+ {
+ isMeasured ?
+
+ {
+ buttons.map((button) => {
+ return button;
+ })
+ }
+
+ {
+ !!overflowItems.length &&
+
+
+
+
+ {
+ overflowItems.map((item) => {
+ const {
+ iconName,
+ spinningName,
+ label,
+ isDisabled,
+ isSpinning,
+ ...otherProps
+ } = item;
+
+ return (
+
+
+ {label}
+
+ );
+ })
+ }
+
+
+ }
+ :
+ null
+ }
+
+
+ );
+ }
+
+}
+
+PageToolbarSection.propTypes = {
+ children: PropTypes.node,
+ alignContent: PropTypes.oneOf([align.LEFT, align.CENTER, align.RIGHT]),
+ collapseButtons: PropTypes.bool.isRequired
+};
+
+PageToolbarSection.defaultProps = {
+ alignContent: align.LEFT,
+ collapseButtons: true
+};
+
+export default PageToolbarSection;
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css
new file mode 100644
index 000000000..968673593
--- /dev/null
+++ b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.css
@@ -0,0 +1,12 @@
+.separator {
+ margin: 10px $toolbarSeparatorMargin;
+ height: 40px;
+ border-right: 1px solid #e5e5e5;
+ opacity: 0.35;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .separator {
+ margin: 10px 5px;
+ }
+}
diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js b/frontend/src/Components/Page/Toolbar/PageToolbarSeparator.js
new file mode 100644
index 000000000..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..112bfb575
--- /dev/null
+++ b/frontend/src/Components/ProgressBar.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { kinds, sizes } from 'Helpers/Props';
+import styles from './ProgressBar.css';
+
+function ProgressBar(props) {
+ const {
+ className,
+ containerClassName,
+ title,
+ progress,
+ precision,
+ showText,
+ text,
+ kind,
+ size,
+ width
+ } = props;
+
+ const progressPercent = `${progress.toFixed(precision)}%`;
+ const progressText = text || progressPercent;
+ const actualWidth = width ? `${width}px` : '100%';
+
+ return (
+
+ {
+ showText && !!width &&
+
+ }
+
+
+ {
+ showText &&
+
+ }
+
+ );
+}
+
+ProgressBar.propTypes = {
+ className: PropTypes.string,
+ containerClassName: PropTypes.string,
+ title: PropTypes.string,
+ progress: PropTypes.number.isRequired,
+ precision: PropTypes.number.isRequired,
+ showText: PropTypes.bool.isRequired,
+ text: PropTypes.string,
+ kind: PropTypes.oneOf(kinds.all).isRequired,
+ size: PropTypes.oneOf(sizes.all).isRequired,
+ width: PropTypes.number
+};
+
+ProgressBar.defaultProps = {
+ className: styles.progressBar,
+ containerClassName: styles.container,
+ precision: 1,
+ showText: false,
+ kind: kinds.PRIMARY,
+ size: sizes.MEDIUM
+};
+
+export default ProgressBar;
diff --git a/frontend/src/Components/Router/Switch.js b/frontend/src/Components/Router/Switch.js
new file mode 100644
index 000000000..0c0004a50
--- /dev/null
+++ b/frontend/src/Components/Router/Switch.js
@@ -0,0 +1,44 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Switch as RouterSwitch } from 'react-router-dom';
+import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
+import { map } from 'Helpers/elementChildren';
+
+class Switch extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children
+ } = this.props;
+
+ return (
+
+ {
+ map(children, (child) => {
+ const {
+ path: childPath,
+ addUrlBase = true
+ } = child.props;
+
+ if (!childPath) {
+ return child;
+ }
+
+ const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath;
+
+ return React.cloneElement(child, { path });
+ })
+ }
+
+ );
+ }
+}
+
+Switch.propTypes = {
+ children: PropTypes.node.isRequired
+};
+
+export default Switch;
diff --git a/frontend/src/Components/Scroller/OverlayScroller.css b/frontend/src/Components/Scroller/OverlayScroller.css
new file mode 100644
index 000000000..707a9ac6f
--- /dev/null
+++ b/frontend/src/Components/Scroller/OverlayScroller.css
@@ -0,0 +1,15 @@
+.scroller {
+ /* Placeholder */
+}
+
+.thumb {
+ min-height: 50px;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ background-color: $scrollbarBackgroundColor;
+ background-clip: padding-box;
+
+ &:hover {
+ background-color: $scrollbarHoverBackgroundColor;
+ }
+}
diff --git a/frontend/src/Components/Scroller/OverlayScroller.js b/frontend/src/Components/Scroller/OverlayScroller.js
new file mode 100644
index 000000000..9cc9edec0
--- /dev/null
+++ b/frontend/src/Components/Scroller/OverlayScroller.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { Scrollbars } from 'react-custom-scrollbars';
+import { scrollDirections } from 'Helpers/Props';
+import styles from './OverlayScroller.css';
+
+class OverlayScroller extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._scroller = null;
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ scrollTop
+ } = this.props;
+
+ if (scrollTop != null && scrollTop !== prevProps.scrollTop) {
+ this._scroller.scrollTop(scrollTop);
+ }
+ }
+
+ //
+ // Control
+
+ _setScrollRef = (ref) => {
+ this._scroller = ref;
+ }
+
+ _renderThumb = (props) => {
+ return (
+
+ );
+ }
+
+ _renderView = (props) => {
+ return (
+
+ );
+ }
+
+ //
+ // Listers
+
+ onScroll = (event) => {
+ const {
+ scrollTop,
+ scrollLeft
+ } = event.currentTarget;
+
+ const onScroll = this.props.onScroll;
+
+ if (onScroll) {
+ onScroll({ scrollTop, scrollLeft });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ autoHide,
+ autoScroll,
+ children
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+
+}
+
+OverlayScroller.propTypes = {
+ className: PropTypes.string,
+ trackClassName: PropTypes.string,
+ scrollTop: PropTypes.number,
+ scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired,
+ autoHide: PropTypes.bool.isRequired,
+ autoScroll: PropTypes.bool.isRequired,
+ children: PropTypes.node,
+ onScroll: PropTypes.func
+};
+
+OverlayScroller.defaultProps = {
+ className: styles.scroller,
+ trackClassName: styles.thumb,
+ scrollDirection: scrollDirections.VERTICAL,
+ autoHide: false,
+ autoScroll: true
+};
+
+export default OverlayScroller;
diff --git a/frontend/src/Components/Scroller/Scroller.css b/frontend/src/Components/Scroller/Scroller.css
new file mode 100644
index 000000000..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..9aa8bc691
--- /dev/null
+++ b/frontend/src/Components/SignalRConnector.js
@@ -0,0 +1,369 @@
+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 { 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';
+
+function getState(status) {
+ switch (status) {
+ case 0:
+ return 'connecting';
+ case 1:
+ return 'connected';
+ case 2:
+ return 'reconnecting';
+ case 4:
+ return 'disconnected';
+ default:
+ throw new Error(`invalid status ${status}`);
+ }
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app.isReconnecting,
+ (state) => state.app.isDisconnected,
+ (state) => state.queue.paged.isPopulated,
+ (isReconnecting, isDisconnected, isQueuePopulated) => {
+ return {
+ isReconnecting,
+ isDisconnected,
+ isQueuePopulated
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ updateCommand,
+ finishCommand,
+ setAppValue,
+ setVersion,
+ update,
+ updateItem,
+ removeItem,
+ fetchHealth,
+ fetchQueue,
+ fetchQueueDetails
+};
+
+class SignalRConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.signalRconnectionOptions = { transport: ['webSockets', 'longPolling'] };
+ this.signalRconnection = null;
+ this.retryInterval = 5;
+ this.retryTimeoutId = null;
+ }
+
+ componentDidMount() {
+ console.log('Starting signalR');
+
+ this.signalRconnection = $.connection('/signalr', { apiKey: window.Sonarr.apiKey });
+
+ this.signalRconnection.stateChanged(this.onStateChanged);
+ this.signalRconnection.received(this.onReceived);
+ this.signalRconnection.reconnecting(this.onReconnecting);
+ this.signalRconnection.disconnected(this.onDisconnected);
+
+ this.signalRconnection.start(this.signalRconnectionOptions);
+ }
+
+ componentWillUnmount() {
+ this.signalRconnection.stop();
+ this.signalRconnection = null;
+ }
+
+ //
+ // Control
+
+ retryConnection = () => {
+ if (this.retryInterval >= 30) {
+ this.setState({
+ isDisconnected: true
+ });
+ }
+
+ this.retryTimeoutId = setTimeout(() => {
+ this.signalRconnection.start(this.signalRconnectionOptions);
+ this.retryInterval = Math.min(this.retryInterval + 5, 30);
+ }, this.retryInterval * 1000);
+ }
+
+ handleMessage = (message) => {
+ const {
+ name,
+ body
+ } = message;
+
+ if (name === 'calendar') {
+ this.handleCalendar(body);
+ return;
+ }
+
+ if (name === 'command') {
+ this.handleCommand(body);
+ return;
+ }
+
+ if (name === 'episode') {
+ this.handleEpisode(body);
+ return;
+ }
+
+ if (name === 'track') {
+ this.handleTrack(body);
+ return;
+ }
+
+ if (name === 'episodefile') {
+ this.handleTrackFile(body);
+ return;
+ }
+
+ if (name === 'health') {
+ this.handleHealth(body);
+ return;
+ }
+
+ if (name === 'artist') {
+ this.handleArtist(body);
+ return;
+ }
+
+ if (name === 'queue') {
+ this.handleQueue(body);
+ return;
+ }
+
+ if (name === 'queue/details') {
+ this.handleQueueDetails(body);
+ return;
+ }
+
+ if (name === 'queue/status') {
+ this.handleQueueStatus(body);
+ return;
+ }
+
+ if (name === 'version') {
+ this.handleVersion(body);
+ return;
+ }
+
+ if (name === 'wanted/cutoff') {
+ this.handleWantedCutoff(body);
+ return;
+ }
+
+ if (name === 'wanted/missing') {
+ this.handleWantedMissing(body);
+ return;
+ }
+ }
+
+ handleCalendar = (body) => {
+ if (body.action === 'updated') {
+ this.props.updateItem({
+ section: 'calendar',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ handleCommand = (body) => {
+ const resource = body.resource;
+ const state = resource.state;
+
+ // Both sucessful and failed commands need to be
+ // completed, otherwise they spin until they timeout.
+
+ if (state === 'completed' || state === 'failed') {
+ this.props.finishCommand(resource);
+ } else {
+ this.props.updateCommand(resource);
+ }
+ }
+
+ handleEpisode = (body) => {
+ if (body.action === 'updated') {
+ this.props.updateItem({
+ section: 'episodes',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ handleTrack = (body) => {
+ if (body.action === 'updated') {
+ this.props.updateItem({
+ section: 'tracks',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ handleTrackFile = (body) => {
+ if (body.action === 'updated') {
+ this.props.updateItem({
+ section: 'trackFiles',
+ ...body.resource
+ });
+ }
+ }
+
+ handleHealth = (body) => {
+ this.props.fetchHealth();
+ }
+
+ handleArtist = (body) => {
+ const action = body.action;
+ const section = 'artist';
+
+ if (action === 'updated') {
+ this.props.updateItem({ section, ...body.resource });
+ } else if (action === 'deleted') {
+ this.props.removeItem({ section, id: body.resource.id });
+ }
+ }
+
+ handleQueue = (body) => {
+ if (this.props.isQueuePopulated) {
+ this.props.fetchQueue();
+ }
+ }
+
+ handleQueueDetails = (body) => {
+ this.props.fetchQueueDetails();
+ }
+
+ handleQueueStatus = (body) => {
+ this.props.update({ section: 'queueStatus', data: body.resource });
+ }
+
+ handleVersion = (body) => {
+ const version = body.Version;
+
+ this.props.setVersion({ version });
+ }
+
+ handleWantedCutoff = (body) => {
+ if (body.action === 'updated') {
+ this.props.updateItem({
+ section: 'cutoffUnmet',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ handleWantedMissing = (body) => {
+ if (body.action === 'updated') {
+ this.props.updateItem({
+ section: 'missing',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ }
+
+ //
+ // Listeners
+
+ onStateChanged = (change) => {
+ const state = getState(change.newState);
+ console.log(`SignalR: ${state}`);
+
+ if (state === 'connected') {
+ // Repopulate the page (if a repopulator is set) to ensure things
+ // are in sync after reconnecting.
+
+ if (this.props.isReconnecting || this.props.isDisconnected) {
+ repopulatePage();
+ }
+
+ this.props.setAppValue({
+ isConnected: true,
+ isReconnecting: false,
+ isDisconnected: false
+ });
+
+ this.retryInterval = 5;
+
+ if (this.retryTimeoutId) {
+ clearTimeout(this.retryTimeoutId);
+ }
+ }
+ }
+
+ onReceived = (message) => {
+ console.debug('SignalR: received', message.name, message.body);
+
+ this.handleMessage(message);
+ }
+
+ onReconnecting = () => {
+ if (window.Sonarr.unloading) {
+ return;
+ }
+
+ this.props.setAppValue({
+ isReconnecting: true
+ });
+ }
+
+ onDisconnected = () => {
+ if (window.Sonarr.unloading) {
+ return;
+ }
+
+ this.props.setAppValue({
+ isConnected: false,
+ isReconnecting: true
+ // Don't set isDisconnected yet, it'll be set it if it's disconnected
+ // for ~105 seconds (retry interval reaches 30 seconds)
+ });
+
+ this.retryConnection();
+ }
+
+ //
+ // Render
+
+ render() {
+ return null;
+ }
+}
+
+SignalRConnector.propTypes = {
+ isReconnecting: PropTypes.bool.isRequired,
+ isDisconnected: PropTypes.bool.isRequired,
+ isQueuePopulated: PropTypes.bool.isRequired,
+ updateCommand: PropTypes.func.isRequired,
+ finishCommand: PropTypes.func.isRequired,
+ setAppValue: PropTypes.func.isRequired,
+ setVersion: PropTypes.func.isRequired,
+ update: PropTypes.func.isRequired,
+ updateItem: PropTypes.func.isRequired,
+ removeItem: PropTypes.func.isRequired,
+ fetchHealth: PropTypes.func.isRequired,
+ fetchQueue: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SignalRConnector);
diff --git a/frontend/src/Components/SpinnerIcon.js b/frontend/src/Components/SpinnerIcon.js
new file mode 100644
index 000000000..4c5cbb700
--- /dev/null
+++ b/frontend/src/Components/SpinnerIcon.js
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from './Icon';
+
+function SpinnerIcon(props) {
+ const {
+ name,
+ spinningName,
+ isSpinning,
+ ...otherProps
+ } = props;
+
+ return (
+
+ );
+}
+
+SpinnerIcon.propTypes = {
+ name: PropTypes.string.isRequired,
+ spinningName: PropTypes.string.isRequired,
+ isSpinning: PropTypes.bool.isRequired
+};
+
+SpinnerIcon.defaultProps = {
+ spinningName: icons.SPINNER
+};
+
+export default SpinnerIcon;
diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.css b/frontend/src/Components/Table/Cells/RelativeDateCell.css
new file mode 100644
index 000000000..7be20ce5d
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/RelativeDateCell.css
@@ -0,0 +1,5 @@
+.cell {
+ composes: cell from './TableRowCell.css';
+
+ width: 180px;
+}
diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.js b/frontend/src/Components/Table/Cells/RelativeDateCell.js
new file mode 100644
index 000000000..874ae4aca
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/RelativeDateCell.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import getRelativeDate from 'Utilities/Date/getRelativeDate';
+import TableRowCell from './TableRowCell';
+import styles from './relativeDateCell.css';
+
+function RelativeDateCell(props) {
+ const {
+ className,
+ date,
+ includeSeconds,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat,
+ component: Component,
+ dispatch,
+ ...otherProps
+ } = props;
+
+ if (!date) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds, timeForToday: true })}
+
+ );
+}
+
+RelativeDateCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ date: PropTypes.string,
+ includeSeconds: PropTypes.bool.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ component: PropTypes.func,
+ dispatch: PropTypes.func
+};
+
+RelativeDateCell.defaultProps = {
+ className: styles.cell,
+ includeSeconds: false,
+ component: TableRowCell
+};
+
+export default RelativeDateCell;
diff --git a/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js
new file mode 100644
index 000000000..ed996abbe
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/RelativeDateCellConnector.js
@@ -0,0 +1,21 @@
+import _ from 'lodash';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import RelativeDateCell from './RelativeDateCell';
+
+function createMapStateToProps() {
+ return createSelector(
+ createUISettingsSelector(),
+ (uiSettings) => {
+ return _.pick(uiSettings, [
+ 'showRelativeDates',
+ 'shortDateFormat',
+ 'longDateFormat',
+ 'timeFormat'
+ ]);
+ }
+ );
+}
+
+export default connect(createMapStateToProps, null)(RelativeDateCell);
diff --git a/frontend/src/Components/Table/Cells/TableRowCell.css b/frontend/src/Components/Table/Cells/TableRowCell.css
new file mode 100644
index 000000000..1c3e6fc5a
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableRowCell.css
@@ -0,0 +1,11 @@
+.cell {
+ padding: 8px;
+ border-top: 1px solid #eee;
+ line-height: 1.52857143;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .cell {
+ white-space: nowrap;
+ }
+}
diff --git a/frontend/src/Components/Table/Cells/TableRowCell.js b/frontend/src/Components/Table/Cells/TableRowCell.js
new file mode 100644
index 000000000..f66bbf3aa
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableRowCell.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import styles from './TableRowCell.css';
+
+class TableRowCell extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+TableRowCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
+};
+
+TableRowCell.defaultProps = {
+ className: styles.cell
+};
+
+export default TableRowCell;
diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.css b/frontend/src/Components/Table/Cells/TableRowCellButton.css
new file mode 100644
index 000000000..f01e7cba6
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableRowCellButton.css
@@ -0,0 +1,4 @@
+.cell {
+ composes: cell from './TableRowCell.css';
+ composes: link from 'Components/Link/Link.css';
+}
diff --git a/frontend/src/Components/Table/Cells/TableRowCellButton.js b/frontend/src/Components/Table/Cells/TableRowCellButton.js
new file mode 100644
index 000000000..ff50d3bc9
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableRowCellButton.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Link from 'Components/Link/Link';
+import TableRowCell from './TableRowCell';
+import styles from './TableRowCellButton.css';
+
+function TableRowCellButton({ className, ...otherProps }) {
+ return (
+
+ );
+}
+
+TableRowCellButton.propTypes = {
+ className: PropTypes.string.isRequired
+};
+
+TableRowCellButton.defaultProps = {
+ className: styles.cell
+};
+
+export default TableRowCellButton;
diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.css b/frontend/src/Components/Table/Cells/TableSelectCell.css
new file mode 100644
index 000000000..21ab944d7
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableSelectCell.css
@@ -0,0 +1,11 @@
+.selectCell {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 30px;
+}
+
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin: 0;
+}
diff --git a/frontend/src/Components/Table/Cells/TableSelectCell.js b/frontend/src/Components/Table/Cells/TableSelectCell.js
new file mode 100644
index 000000000..b82cf9168
--- /dev/null
+++ b/frontend/src/Components/Table/Cells/TableSelectCell.js
@@ -0,0 +1,71 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import TableRowCell from './TableRowCell';
+import styles from './TableSelectCell.css';
+
+class TableSelectCell extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ id,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value: isSelected });
+ }
+
+ //
+ // Listeners
+
+ onChange = ({ value, shiftKey }, a, b, c, d) => {
+ const {
+ id,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value, shiftKey });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ id,
+ isSelected,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+TableSelectCell.propTypes = {
+ className: PropTypes.string.isRequired,
+ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+TableSelectCell.defaultProps = {
+ className: styles.selectCell,
+ isSelected: false
+};
+
+export default TableSelectCell;
diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.css b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css
new file mode 100644
index 000000000..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..f66eec49a
--- /dev/null
+++ b/frontend/src/Components/Table/Table.js
@@ -0,0 +1,157 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, scrollDirections } from 'Helpers/Props';
+import IconButton from 'Components/Link/IconButton';
+import Scroller from 'Components/Scroller/Scroller';
+import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal';
+import TableHeader from './TableHeader';
+import TableHeaderCell from './TableHeaderCell';
+import TableSelectAllHeaderCell from './TableSelectAllHeaderCell';
+import styles from './Table.css';
+
+const tableHeaderCellProps = [
+ 'sortKey',
+ 'sortDirection'
+];
+
+function getTableHeaderCellProps(props) {
+ return _.reduce(tableHeaderCellProps, (result, key) => {
+ if (props.hasOwnProperty(key)) {
+ result[key] = props[key];
+ }
+
+ return result;
+ }, {});
+}
+
+class Table extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isTableOptionsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onTableOptionsPress = () => {
+ this.setState({ isTableOptionsModalOpen: true });
+ }
+
+ onTableOptionsModalClose = () => {
+ this.setState({ isTableOptionsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ selectAll,
+ columns,
+ pageSize,
+ canModifyColumns,
+ children,
+ onSortPress,
+ onTableOptionChange,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ {
+ selectAll &&
+
+ }
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if ((name === 'actions' || name === 'details') && onTableOptionChange) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {column.label}
+
+ );
+ })
+ }
+
+ {
+ !!onTableOptionChange &&
+
+ }
+
+
+ {children}
+
+
+ );
+ }
+}
+
+Table.propTypes = {
+ className: PropTypes.string,
+ selectAll: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ pageSize: PropTypes.number,
+ canModifyColumns: PropTypes.bool,
+ children: PropTypes.node,
+ onSortPress: PropTypes.func,
+ onTableOptionChange: PropTypes.func
+};
+
+Table.defaultProps = {
+ className: styles.table,
+ selectAll: false
+};
+
+export default Table;
diff --git a/frontend/src/Components/Table/TableBody.js b/frontend/src/Components/Table/TableBody.js
new file mode 100644
index 000000000..5cc60d6f4
--- /dev/null
+++ b/frontend/src/Components/Table/TableBody.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+
+class TableBody extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children
+ } = this.props;
+
+ return (
+ {children}
+ );
+ }
+
+}
+
+TableBody.propTypes = {
+ children: PropTypes.node
+};
+
+export default TableBody;
diff --git a/frontend/src/Components/Table/TableHeader.js b/frontend/src/Components/Table/TableHeader.js
new file mode 100644
index 000000000..81943e919
--- /dev/null
+++ b/frontend/src/Components/Table/TableHeader.js
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+
+class TableHeader extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ children
+ } = this.props;
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+}
+
+TableHeader.propTypes = {
+ children: PropTypes.node
+};
+
+export default TableHeader;
diff --git a/frontend/src/Components/Table/TableHeaderCell.css b/frontend/src/Components/Table/TableHeaderCell.css
new file mode 100644
index 000000000..c2c4f58c8
--- /dev/null
+++ b/frontend/src/Components/Table/TableHeaderCell.css
@@ -0,0 +1,16 @@
+.headerCell {
+ padding: 8px;
+ border: none !important;
+ text-align: left;
+ font-weight: bold;
+}
+
+.sortIcon {
+ margin-left: 10px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .headerCell {
+ white-space: nowrap;
+ }
+}
diff --git a/frontend/src/Components/Table/TableHeaderCell.js b/frontend/src/Components/Table/TableHeaderCell.js
new file mode 100644
index 000000000..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..6a8e345f8
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumn.js
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './TableOptionsColumn.css';
+
+function TableOptionsColumn(props) {
+ const {
+ name,
+ label,
+ isVisible,
+ isModifiable,
+ isDragging,
+ connectDragSource,
+ onVisibleChange
+ } = props;
+
+ return (
+
+
+
+
+ {label}
+
+
+ {
+ !!connectDragSource &&
+ connectDragSource(
+
+
+
+ )
+ }
+
+
+ );
+}
+
+TableOptionsColumn.propTypes = {
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ isVisible: PropTypes.bool.isRequired,
+ isModifiable: PropTypes.bool.isRequired,
+ index: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ onVisibleChange: PropTypes.func.isRequired
+};
+
+export default TableOptionsColumn;
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css
new file mode 100644
index 000000000..b927d9bce
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.css
@@ -0,0 +1,4 @@
+.dragPreview {
+ width: 380px;
+ opacity: 0.75;
+}
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js
new file mode 100644
index 000000000..03169f00c
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragPreview.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragLayer } from 'react-dnd';
+import dimensions from 'Styles/Variables/dimensions.js';
+import { TABLE_COLUMN } from 'Helpers/dragTypes';
+import DragPreviewLayer from 'Components/DragPreviewLayer';
+import TableOptionsColumn from './TableOptionsColumn';
+import styles from './TableOptionsColumnDragPreview.css';
+
+const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
+const formLabelWidth = parseInt(dimensions.formLabelWidth);
+const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
+const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
+
+function collectDragLayer(monitor) {
+ return {
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset()
+ };
+}
+
+class TableOptionsColumnDragPreview extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ item,
+ itemType,
+ currentOffset
+ } = this.props;
+
+ if (!currentOffset || itemType !== TABLE_COLUMN) {
+ return null;
+ }
+
+ // The offset is shifted because the drag handle is on the right edge of the
+ // list item and the preview is wider than the drag handle.
+
+ const { x, y } = currentOffset;
+ const handleOffset = formGroupSmallWidth - formLabelWidth - formLabelRightMarginWidth - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ return (
+
+
+
+ );
+ }
+}
+
+TableOptionsColumnDragPreview.propTypes = {
+ item: PropTypes.object,
+ itemType: PropTypes.string,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired
+ })
+};
+
+export default DragLayer(collectDragLayer)(TableOptionsColumnDragPreview);
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css
new file mode 100644
index 000000000..9354a35c0
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.css
@@ -0,0 +1,18 @@
+.columnDragSource {
+ padding: 4px 0;
+}
+
+.columnPlaceholder {
+ width: 100%;
+ height: 36px;
+ border: 1px dotted #aaa;
+ border-radius: 4px;
+}
+
+.columnPlaceholderBefore {
+ margin-bottom: 8px;
+}
+
+.columnPlaceholderAfter {
+ margin-top: 8px;
+}
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js
new file mode 100644
index 000000000..80f03e430
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsColumnDragSource.js
@@ -0,0 +1,164 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { findDOMNode } from 'react-dom';
+import { DragSource, DropTarget } from 'react-dnd';
+import classNames from 'classnames';
+import { TABLE_COLUMN } from 'Helpers/dragTypes';
+import TableOptionsColumn from './TableOptionsColumn';
+import styles from './TableOptionsColumnDragSource.css';
+
+const columnDragSource = {
+ beginDrag(column) {
+ return column;
+ },
+
+ endDrag(props, monitor, component) {
+ props.onColumnDragEnd(monitor.getItem(), monitor.didDrop());
+ }
+};
+
+const columnDropTarget = {
+ hover(props, monitor, component) {
+ const dragIndex = monitor.getItem().index;
+ const hoverIndex = props.index;
+
+ const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ if (dragIndex === hoverIndex) {
+ return;
+ }
+
+ // When moving up, only trigger if drag position is above 50% and
+ // when moving down, only trigger if drag position is below 50%.
+ // If we're moving down the hoverIndex needs to be increased
+ // by one so it's ordered properly. Otherwise the hoverIndex will work.
+
+ // Dragging downwards
+ if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
+ return;
+ }
+
+ // Dragging upwards
+ if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
+ return;
+ }
+
+ props.onColumnDragMove(dragIndex, hoverIndex);
+ }
+};
+
+function collectDragSource(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging()
+ };
+}
+
+function collectDropTarget(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver()
+ };
+}
+
+class TableOptionsColumnDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ label,
+ isVisible,
+ isModifiable,
+ index,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOver,
+ connectDragSource,
+ connectDropTarget,
+ onVisibleChange
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOver;
+ const isAfter = !isDragging && isDraggingDown && isOver;
+
+ // if (isDragging && !isOver) {
+ // return null;
+ // }
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+TableOptionsColumnDragSource.propTypes = {
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ isVisible: PropTypes.bool.isRequired,
+ isModifiable: PropTypes.bool.isRequired,
+ index: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool,
+ isDraggingUp: PropTypes.bool,
+ isDraggingDown: PropTypes.bool,
+ isOver: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ connectDropTarget: PropTypes.func,
+ onVisibleChange: PropTypes.func.isRequired,
+ onColumnDragMove: PropTypes.func.isRequired,
+ onColumnDragEnd: PropTypes.func.isRequired
+};
+
+export default DropTarget(
+ TABLE_COLUMN,
+ columnDropTarget,
+ collectDropTarget
+)(DragSource(
+ TABLE_COLUMN,
+ columnDragSource,
+ collectDragSource
+)(TableOptionsColumnDragSource));
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.css b/frontend/src/Components/Table/TableOptions/TableOptionsModal.css
new file mode 100644
index 000000000..35544f32b
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.css
@@ -0,0 +1,5 @@
+.columns {
+ margin-top: 10px;
+ width: 100%;
+ user-select: none;
+}
diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js
new file mode 100644
index 000000000..53d695e31
--- /dev/null
+++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js
@@ -0,0 +1,242 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragDropContext } from 'react-dnd';
+import HTML5Backend from 'react-dnd-html5-backend';
+import { inputTypes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import TableOptionsColumn from './TableOptionsColumn';
+import TableOptionsColumnDragSource from './TableOptionsColumnDragSource';
+import TableOptionsColumnDragPreview from './TableOptionsColumnDragPreview';
+import styles from './TableOptionsModal.css';
+
+class TableOptionsModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasPageSize: !!props.pageSize,
+ pageSize: props.pageSize,
+ pageSizeError: null,
+ dragIndex: null,
+ dropIndex: null
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.pageSize !== this.state.pageSize) {
+ this.setState({ pageSize: this.props.pageSize });
+ }
+ }
+
+ //
+ // Listeners
+
+ onPageSizeChange = ({ value }) => {
+ let pageSizeError = null;
+
+ if (value < 5) {
+ pageSizeError = 'Page size must be at least 5';
+ } else if (value > 250) {
+ pageSizeError = 'Page size must not exceed 250';
+ } else {
+ this.props.onTableOptionChange({ pageSize: value });
+ }
+
+ this.setState({
+ pageSize: value,
+ pageSizeError
+ });
+ }
+
+ onVisibleChange = ({ name, value }) => {
+ const columns = _.cloneDeep(this.props.columns);
+
+ const column = _.find(columns, { name });
+ column.isVisible = value;
+
+ this.props.onTableOptionChange({ columns });
+ }
+
+ onColumnDragMove = (dragIndex, dropIndex) => {
+ if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
+ this.setState({
+ dragIndex,
+ dropIndex
+ });
+ }
+ }
+
+ onColumnDragEnd = ({ id }, didDrop) => {
+ const {
+ dragIndex,
+ dropIndex
+ } = this.state;
+
+ if (didDrop && dropIndex !== null) {
+ const columns = _.cloneDeep(this.props.columns);
+ const items = columns.splice(dragIndex, 1);
+ columns.splice(dropIndex, 0, items[0]);
+
+ this.props.onTableOptionChange({ columns });
+ }
+
+ this.setState({
+ dragIndex: null,
+ dropIndex: null
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ columns,
+ canModifyColumns,
+ onModalClose
+ } = this.props;
+
+ const {
+ hasPageSize,
+ pageSize,
+ pageSizeError,
+ dragIndex,
+ dropIndex
+ } = this.state;
+
+ const isDragging = dropIndex !== null;
+ const isDraggingUp = isDragging && dropIndex < dragIndex;
+ const isDraggingDown = isDragging && dropIndex > dragIndex;
+
+ return (
+
+
+
+ Table Options
+
+
+
+
+
+
+
+ Close
+
+
+
+
+ );
+ }
+}
+
+TableOptionsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ pageSize: PropTypes.number,
+ canModifyColumns: PropTypes.bool.isRequired,
+ onTableOptionChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+TableOptionsModal.defaultProps = {
+ canModifyColumns: true
+};
+
+export default DragDropContext(HTML5Backend)(TableOptionsModal);
diff --git a/frontend/src/Components/Table/TablePager.css b/frontend/src/Components/Table/TablePager.css
new file mode 100644
index 000000000..e3fb645bd
--- /dev/null
+++ b/frontend/src/Components/Table/TablePager.css
@@ -0,0 +1,70 @@
+.pager {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.loadingContainer,
+.controlsContainer,
+.recordsContainer {
+ flex: 0 1 33%;
+}
+
+.controlsContainer {
+ display: flex;
+ justify-content: center;
+}
+
+.recordsContainer {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin: 0;
+ margin-left: 5px;
+ text-align: left;
+}
+
+.controls {
+ display: flex;
+ align-items: center;
+ text-align: center;
+}
+
+.pageNumber {
+ line-height: 30px;
+}
+
+.pageLink {
+ padding: 0;
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+}
+
+.records {
+ color: $disabledColor;
+}
+
+.disabledPageButton {
+ color: $disabledColor;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .pager {
+ flex-wrap: wrap;
+ }
+
+ .loadingContainer,
+ .recordsContainer {
+ flex: 0 1 50%;
+ }
+
+ .controlsContainer {
+ flex: 0 1 100%;
+ order: -1;
+ }
+}
diff --git a/frontend/src/Components/Table/TablePager.js b/frontend/src/Components/Table/TablePager.js
new file mode 100644
index 000000000..e3bc10be7
--- /dev/null
+++ b/frontend/src/Components/Table/TablePager.js
@@ -0,0 +1,173 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import SelectInput from 'Components/Form/SelectInput';
+import styles from './TablePager.css';
+
+class TablePager extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isShowingPageSelect: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onOpenPageSelectClick = () => {
+ this.setState({ isShowingPageSelect: true });
+ }
+
+ onPageSelect = ({ value: page }) => {
+ this.setState({ isShowingPageSelect: false });
+ this.props.onPageSelect(parseInt(page));
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ page,
+ totalPages,
+ totalRecords,
+ isFetching,
+ onFirstPagePress,
+ onPreviousPagePress,
+ onNextPagePress,
+ onLastPagePress
+ } = this.props;
+
+ const isShowingPageSelect = this.state.isShowingPageSelect;
+ const pages = Array.from(new Array(totalPages), (x, i) => {
+ const pageNumber = i + 1;
+
+ return {
+ key: pageNumber,
+ value: pageNumber
+ };
+ });
+
+ if (!page) {
+ return null;
+ }
+
+ const isFirstPage = page === 1;
+ const isLastPage = page === totalPages;
+
+ return (
+
+
+ {
+ isFetching &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ !isShowingPageSelect &&
+
+ {page} / {totalPages}
+
+ }
+
+ {
+ isShowingPageSelect &&
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Total records: {totalRecords}
+
+
+
+ );
+ }
+
+}
+
+TablePager.propTypes = {
+ page: PropTypes.number,
+ totalPages: PropTypes.number,
+ totalRecords: PropTypes.number,
+ isFetching: PropTypes.bool,
+ onFirstPagePress: PropTypes.func.isRequired,
+ onPreviousPagePress: PropTypes.func.isRequired,
+ onNextPagePress: PropTypes.func.isRequired,
+ onLastPagePress: PropTypes.func.isRequired,
+ onPageSelect: PropTypes.func.isRequired
+};
+
+export default TablePager;
diff --git a/frontend/src/Components/Table/TableRow.css b/frontend/src/Components/Table/TableRow.css
new file mode 100644
index 000000000..9664733b4
--- /dev/null
+++ b/frontend/src/Components/Table/TableRow.css
@@ -0,0 +1,7 @@
+.row {
+ transition: background-color 500ms;
+
+ &:hover {
+ background-color: #fafbfc;
+ }
+}
diff --git a/frontend/src/Components/Table/TableRow.js b/frontend/src/Components/Table/TableRow.js
new file mode 100644
index 000000000..06bbbaee9
--- /dev/null
+++ b/frontend/src/Components/Table/TableRow.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './TableRow.css';
+
+function TableRow(props) {
+ const {
+ className,
+ children,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+TableRow.propTypes = {
+ className: PropTypes.string.isRequired,
+ children: PropTypes.node
+};
+
+TableRow.defaultProps = {
+ className: styles.row
+};
+
+export default TableRow;
diff --git a/frontend/src/Components/Table/TableRowButton.css b/frontend/src/Components/Table/TableRowButton.css
new file mode 100644
index 000000000..70a2238ca
--- /dev/null
+++ b/frontend/src/Components/Table/TableRowButton.css
@@ -0,0 +1,4 @@
+.row {
+ composes: link from 'Components/Link/Link.css';
+ composes: row from './TableRow.css';
+}
diff --git a/frontend/src/Components/Table/TableRowButton.js b/frontend/src/Components/Table/TableRowButton.js
new file mode 100644
index 000000000..7ff679673
--- /dev/null
+++ b/frontend/src/Components/Table/TableRowButton.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import Link from 'Components/Link/Link';
+import TableRow from './TableRow';
+import styles from './TableRowButton.css';
+
+function TableRowButton(props) {
+ return (
+
+ );
+}
+
+export default TableRowButton;
diff --git a/frontend/src/Components/Table/TableSelectAllHeaderCell.css b/frontend/src/Components/Table/TableSelectAllHeaderCell.css
new file mode 100644
index 000000000..6090e6e9c
--- /dev/null
+++ b/frontend/src/Components/Table/TableSelectAllHeaderCell.css
@@ -0,0 +1,11 @@
+.selectAllHeaderCell {
+ composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css';
+
+ width: 30px;
+}
+
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin: 0;
+}
diff --git a/frontend/src/Components/Table/TableSelectAllHeaderCell.js b/frontend/src/Components/Table/TableSelectAllHeaderCell.js
new file mode 100644
index 000000000..c889c32ae
--- /dev/null
+++ b/frontend/src/Components/Table/TableSelectAllHeaderCell.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import VirtualTableHeaderCell from './TableHeaderCell';
+import styles from './TableSelectAllHeaderCell.css';
+
+function getValue(allSelected, allUnselected) {
+ if (allSelected) {
+ return true;
+ } else if (allUnselected) {
+ return false;
+ }
+
+ return null;
+}
+
+function TableSelectAllHeaderCell(props) {
+ const {
+ allSelected,
+ allUnselected,
+ onSelectAllChange
+ } = props;
+
+ const value = getValue(allSelected, allUnselected);
+
+ return (
+
+
+
+ );
+}
+
+TableSelectAllHeaderCell.propTypes = {
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired
+};
+
+export default TableSelectAllHeaderCell;
diff --git a/frontend/src/Components/Table/VirtualTable.css b/frontend/src/Components/Table/VirtualTable.css
new file mode 100644
index 000000000..3287c5643
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTable.css
@@ -0,0 +1,3 @@
+.tableContainer {
+ width: 100%;
+}
diff --git a/frontend/src/Components/Table/VirtualTable.js b/frontend/src/Components/Table/VirtualTable.js
new file mode 100644
index 000000000..df23d3ff7
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTable.js
@@ -0,0 +1,173 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactDOM from 'react-dom';
+import Measure from 'react-measure';
+import { WindowScroller } from 'react-virtualized';
+import { scrollDirections } from 'Helpers/Props';
+import Scroller from 'Components/Scroller/Scroller';
+import VirtualTableBody from './VirtualTableBody';
+import styles from './VirtualTable.css';
+
+const ROW_HEIGHT = 38;
+
+function overscanIndicesGetter(options) {
+ const {
+ cellCount,
+ overscanCellsCount,
+ startIndex,
+ stopIndex
+ } = options;
+
+ // The default getter takes the scroll direction into account,
+ // but that can cause issues. Ignore the scroll direction and
+ // always over return more items.
+
+ const overscanStartIndex = startIndex - overscanCellsCount;
+ const overscanStopIndex = stopIndex + overscanCellsCount;
+
+ return {
+ overscanStartIndex: Math.max(0, overscanStartIndex),
+ overscanStopIndex: Math.min(cellCount - 1, overscanStopIndex)
+ };
+}
+
+class VirtualTable extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ width: 0
+ };
+
+ this._isInitialized = false;
+ this._table = null;
+ }
+
+ componentDidMount() {
+ this._contentBodyNode = ReactDOM.findDOMNode(this.props.contentBody);
+ }
+
+ //
+ // Control
+
+ rowGetter = ({ index }) => {
+ return this.props.items[index];
+ }
+
+ setTableRef = (ref) => {
+ this._table = ref;
+ }
+
+ forceUpdateGrid = () => {
+ this._table.recomputeGridSize();
+ }
+
+ scrollToRow = (rowIndex) => {
+ const scrollTop = (rowIndex + 1) * ROW_HEIGHT + 20;
+
+ // this._table.scrollToCell({ columnIndex: 0, rowIndex });
+ this.props.onScroll({ scrollTop });
+ }
+
+ //
+ // Listeners
+
+ onMeasure = ({ width }) => {
+ this.setState({
+ width
+ });
+ }
+
+ onSectionRendered = () => {
+ if (!this._isInitialized && this._contentBodyNode) {
+ this.props.onRender();
+ this._isInitialized = true;
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ items,
+ isSmallScreen,
+ header,
+ headerHeight,
+ scrollTop,
+ rowRenderer,
+ onScroll,
+ ...otherProps
+ } = this.props;
+
+ const {
+ width
+ } = this.state;
+
+ return (
+
+
+ {({ height, isScrolling }) => {
+ return (
+
+ {header}
+
+
+
+ );
+ }
+ }
+
+
+ );
+ }
+}
+
+VirtualTable.propTypes = {
+ className: PropTypes.string.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ scrollTop: PropTypes.number.isRequired,
+ contentBody: PropTypes.object.isRequired,
+ isSmallScreen: PropTypes.bool.isRequired,
+ header: PropTypes.node.isRequired,
+ headerHeight: PropTypes.number.isRequired,
+ rowRenderer: PropTypes.func.isRequired,
+ onRender: PropTypes.func.isRequired,
+ onScroll: PropTypes.func.isRequired
+};
+
+VirtualTable.defaultProps = {
+ className: styles.tableContainer,
+ headerHeight: 38,
+ onRender: () => {}
+};
+
+export default VirtualTable;
diff --git a/frontend/src/Components/Table/VirtualTableBody.css b/frontend/src/Components/Table/VirtualTableBody.css
new file mode 100644
index 000000000..12768646d
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableBody.css
@@ -0,0 +1,3 @@
+.tableBodyContainer {
+ position: relative;
+}
diff --git a/frontend/src/Components/Table/VirtualTableBody.js b/frontend/src/Components/Table/VirtualTableBody.js
new file mode 100644
index 000000000..c73508895
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableBody.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import { Grid } from 'react-virtualized';
+import styles from './VirtualTableBody.css';
+
+class VirtualTableBody extends Grid {
+
+ //
+ // Render
+
+ render() {
+ const {
+ autoContainerWidth,
+ containerStyle
+ } = this.props;
+
+ const { isScrolling } = this.state;
+
+ const totalColumnsWidth = this._columnSizeAndPositionManager.getTotalSize();
+ const totalRowsHeight = this._rowSizeAndPositionManager.getTotalSize();
+ const childrenToDisplay = this._childrenToDisplay;
+
+ if (childrenToDisplay.length > 0) {
+ return (
+
+
+ {childrenToDisplay}
+
+
+ );
+ }
+
+ return (
+
+ );
+ }
+}
+
+export default VirtualTableBody;
diff --git a/frontend/src/Components/Table/VirtualTableHeader.css b/frontend/src/Components/Table/VirtualTableHeader.css
new file mode 100644
index 000000000..4b757c1f8
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableHeader.css
@@ -0,0 +1,3 @@
+.header {
+ display: flex;
+}
diff --git a/frontend/src/Components/Table/VirtualTableHeader.js b/frontend/src/Components/Table/VirtualTableHeader.js
new file mode 100644
index 000000000..cf6a0f47b
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableHeader.js
@@ -0,0 +1,17 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './VirtualTableHeader.css';
+
+function VirtualTableHeader({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+VirtualTableHeader.propTypes = {
+ children: PropTypes.node
+};
+
+export default VirtualTableHeader;
diff --git a/frontend/src/Components/Table/VirtualTableHeaderCell.css b/frontend/src/Components/Table/VirtualTableHeaderCell.css
new file mode 100644
index 000000000..c2c4f58c8
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableHeaderCell.css
@@ -0,0 +1,16 @@
+.headerCell {
+ padding: 8px;
+ border: none !important;
+ text-align: left;
+ font-weight: bold;
+}
+
+.sortIcon {
+ margin-left: 10px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .headerCell {
+ white-space: nowrap;
+ }
+}
diff --git a/frontend/src/Components/Table/VirtualTableHeaderCell.js b/frontend/src/Components/Table/VirtualTableHeaderCell.js
new file mode 100644
index 000000000..bf51062e9
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableHeaderCell.js
@@ -0,0 +1,107 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, sortDirections } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import Icon from 'Components/Icon';
+import styles from './VirtualTableHeaderCell.css';
+
+export function headerRenderer(headerProps) {
+ const {
+ columnData = {},
+ dataKey,
+ label
+ } = headerProps;
+
+ return (
+
+ {label}
+
+ );
+}
+
+class VirtualTableHeaderCell extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ fixedSortDirection
+ } = this.props;
+
+ if (fixedSortDirection) {
+ this.props.onSortPress(name, fixedSortDirection);
+ } else {
+ this.props.onSortPress(name);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ className,
+ name,
+ isSortable,
+ sortKey,
+ sortDirection,
+ fixedSortDirection,
+ children,
+ onSortPress,
+ ...otherProps
+ } = this.props;
+
+ const isSorting = isSortable && sortKey === name;
+ const sortIcon = sortDirection === sortDirections.ASCENDING ?
+ icons.SORT_ASCENDING :
+ icons.SORT_DESCENDING;
+
+ return (
+ isSortable ?
+
+ {children}
+
+ {
+ isSorting &&
+
+ }
+ :
+
+
+ {children}
+
+ );
+ }
+}
+
+VirtualTableHeaderCell.propTypes = {
+ className: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ isSortable: PropTypes.bool,
+ sortKey: PropTypes.string,
+ fixedSortDirection: PropTypes.string,
+ sortDirection: PropTypes.string,
+ children: PropTypes.node,
+ onSortPress: PropTypes.func
+};
+
+VirtualTableHeaderCell.defaultProps = {
+ className: styles.headerCell,
+ isSortable: false
+};
+
+export default VirtualTableHeaderCell;
diff --git a/frontend/src/Components/Table/VirtualTableRow.css b/frontend/src/Components/Table/VirtualTableRow.css
new file mode 100644
index 000000000..f4c825b64
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableRow.css
@@ -0,0 +1,14 @@
+.row {
+ display: flex;
+ transition: background-color 500ms;
+
+ &:hover {
+ background-color: #fafbfc;
+ }
+}
+
+@media only screen and (max-width: $breakpointMedium) {
+ .row {
+ overflow-x: visible !important;
+ }
+}
diff --git a/frontend/src/Components/Table/VirtualTableRow.js b/frontend/src/Components/Table/VirtualTableRow.js
new file mode 100644
index 000000000..0a423902e
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableRow.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import styles from './VirtualTableRow.css';
+
+function VirtualTableRow(props) {
+ const {
+ className,
+ children,
+ style,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+VirtualTableRow.propTypes = {
+ className: PropTypes.string.isRequired,
+ style: PropTypes.object.isRequired,
+ children: PropTypes.node
+};
+
+VirtualTableRow.defaultProps = {
+ className: styles.row
+};
+
+export default VirtualTableRow;
diff --git a/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css
new file mode 100644
index 000000000..1f3f7fb30
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.css
@@ -0,0 +1,11 @@
+.selectAllHeaderCell {
+ composes: headerCell from 'Components/Table/TableHeaderCell.css';
+
+ flex: 0 0 36px;
+}
+
+.input {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin: 0;
+}
diff --git a/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js
new file mode 100644
index 000000000..58b246763
--- /dev/null
+++ b/frontend/src/Components/Table/VirtualTableSelectAllHeaderCell.js
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import CheckInput from 'Components/Form/CheckInput';
+import VirtualTableHeaderCell from './VirtualTableHeaderCell';
+import styles from './VirtualTableSelectAllHeaderCell.css';
+
+function getValue(allSelected, allUnselected) {
+ if (allSelected) {
+ return true;
+ } else if (allUnselected) {
+ return false;
+ }
+
+ return null;
+}
+
+function VirtualTableSelectAllHeaderCell(props) {
+ const {
+ allSelected,
+ allUnselected,
+ onSelectAllChange
+ } = props;
+
+ const value = getValue(allSelected, allUnselected);
+
+ return (
+
+
+
+ );
+}
+
+VirtualTableSelectAllHeaderCell.propTypes = {
+ allSelected: PropTypes.bool.isRequired,
+ allUnselected: PropTypes.bool.isRequired,
+ onSelectAllChange: PropTypes.func.isRequired
+};
+
+export default VirtualTableSelectAllHeaderCell;
diff --git a/frontend/src/Components/TagList.css b/frontend/src/Components/TagList.css
new file mode 100644
index 000000000..c1e5567bd
--- /dev/null
+++ b/frontend/src/Components/TagList.css
@@ -0,0 +1,3 @@
+.tags {
+ flex: 1 0 auto;
+}
diff --git a/frontend/src/Components/TagList.js b/frontend/src/Components/TagList.js
new file mode 100644
index 000000000..485651bdc
--- /dev/null
+++ b/frontend/src/Components/TagList.js
@@ -0,0 +1,38 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Label from './Label';
+import styles from './TagList.css';
+
+function TagList({ tags, tagList }) {
+ return (
+
+ {
+ tags.map((t) => {
+ const tag = _.find(tagList, { id: t });
+
+ if (!tag) {
+ return null;
+ }
+
+ return (
+
+ {tag.label}
+
+ );
+ })
+ }
+
+ );
+}
+
+TagList.propTypes = {
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default TagList;
diff --git a/frontend/src/Components/TagListConnector.js b/frontend/src/Components/TagListConnector.js
new file mode 100644
index 000000000..be7e618e3
--- /dev/null
+++ b/frontend/src/Components/TagListConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import TagList from './TagList';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTagsSelector(),
+ (tagList) => {
+ return {
+ tagList
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(TagList);
diff --git a/frontend/src/Components/Tooltip/Popover.css b/frontend/src/Components/Tooltip/Popover.css
new file mode 100644
index 000000000..cb742eca0
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Popover.css
@@ -0,0 +1,104 @@
+.tether {
+ z-index: 2000;
+}
+
+.popoverContainer {
+ margin: 10px 15px;
+}
+
+.popover {
+ position: relative;
+ background-color: $white;
+ box-shadow: 0 5px 10px $popoverShadowColor;
+}
+
+.arrow,
+.arrow::after {
+ position: absolute;
+ display: block;
+ width: 0;
+ height: 0;
+ border-width: 11px;
+ border-style: solid;
+ border-color: transparent;
+}
+
+.arrow::after {
+ border-width: 10px;
+ content: '';
+}
+
+.top {
+ bottom: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-top-color: $popoverArrowBorderColor;
+ border-bottom-width: 0;
+
+ &::after {
+ bottom: 1px;
+ margin-left: -10px;
+ border-top-color: $white;
+ border-bottom-width: 0;
+ content: ' ';
+ }
+}
+
+.right {
+ top: 50%;
+ left: -11px;
+ margin-top: -11px;
+ border-right-color: $popoverArrowBorderColor;
+ border-left-width: 0;
+
+ &::after {
+ bottom: -10px;
+ left: 1px;
+ border-right-color: $white;
+ border-left-width: 0;
+ content: ' ';
+ }
+}
+
+.bottom {
+ top: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-top-width: 0;
+ border-bottom-color: $popoverArrowBorderColor;
+
+ &::after {
+ top: 1px;
+ margin-left: -10px;
+ border-top-width: 0;
+ border-bottom-color: $white;
+ content: ' ';
+ }
+}
+
+.left {
+ top: 50%;
+ right: -11px;
+ margin-top: -11px;
+ border-right-width: 0;
+ border-left-color: $popoverArrowBorderColor;
+
+ &::after {
+ right: 1px;
+ bottom: -10px;
+ border-right-width: 0;
+ border-left-color: $white;
+ content: ' ';
+ }
+}
+
+.title {
+ padding: 10px 20px;
+ border-bottom: 1px solid $popoverTitleBorderColor;
+ background-color: $popoverTitleBackgroundColor;
+ font-size: 16px;
+}
+
+.body {
+ padding: 20px;
+}
diff --git a/frontend/src/Components/Tooltip/Popover.js b/frontend/src/Components/Tooltip/Popover.js
new file mode 100644
index 000000000..0cb2520e5
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Popover.js
@@ -0,0 +1,136 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TetherComponent from 'react-tether';
+import classNames from 'classnames';
+import { tooltipPositions } from 'Helpers/Props';
+import styles from './Popover.css';
+
+const baseTetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ]
+};
+
+const tetherOptions = {
+ [tooltipPositions.TOP]: {
+ ...baseTetherOptions,
+ attachment: 'bottom center',
+ targetAttachment: 'top center'
+ },
+
+ [tooltipPositions.RIGHT]: {
+ ...baseTetherOptions,
+ attachment: 'middle left',
+ targetAttachment: 'middle right'
+ },
+
+ [tooltipPositions.BOTTOM]: {
+ ...baseTetherOptions,
+ attachment: 'top center',
+ targetAttachment: 'bottom center'
+ },
+
+ [tooltipPositions.LEFT]: {
+ ...baseTetherOptions,
+ attachment: 'middle right',
+ targetAttachment: 'middle left'
+ }
+};
+
+class Popover extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onClick = () => {
+ this.setState({ isOpen: !this.state.isOpen });
+ }
+
+ onMouseEnter = () => {
+ this.setState({ isOpen: true });
+ }
+
+ onMouseLeave = () => {
+ this.setState({ isOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ anchor,
+ title,
+ body,
+ position
+ } = this.props;
+
+ return (
+
+
+ {anchor}
+
+
+ {
+ this.state.isOpen &&
+
+
+
+
+
+ {title}
+
+
+
+ {body}
+
+
+
+ }
+
+ );
+ }
+}
+
+Popover.propTypes = {
+ anchor: PropTypes.node.isRequired,
+ title: PropTypes.string.isRequired,
+ body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
+ position: PropTypes.oneOf(tooltipPositions.all)
+};
+
+Popover.defaultProps = {
+ position: tooltipPositions.TOP
+};
+
+export default Popover;
diff --git a/frontend/src/Components/Tooltip/Tooltip.css b/frontend/src/Components/Tooltip/Tooltip.css
new file mode 100644
index 000000000..d1d798e0f
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Tooltip.css
@@ -0,0 +1,161 @@
+.tether {
+ z-index: 2000;
+}
+
+.tooltipContainer {
+ margin: 10px 15px;
+}
+
+.tooltip {
+ position: relative;
+
+ &.default {
+ background-color: $white;
+ box-shadow: 0 5px 10px $popoverShadowColor;
+ }
+
+ &.inverse {
+ background-color: $themeDarkColor;
+ box-shadow: 0 5px 10px $popoverShadowInverseColor;
+ }
+}
+
+.arrow,
+.arrow::after {
+ position: absolute;
+ display: block;
+ width: 0;
+ height: 0;
+ border-width: 11px;
+ border-style: solid;
+ border-color: transparent;
+}
+
+.arrow::after {
+ border-width: 10px;
+ content: '';
+}
+
+.top {
+ bottom: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-bottom-width: 0;
+
+ &::after {
+ bottom: 1px;
+ margin-left: -10px;
+ border-bottom-width: 0;
+ content: ' ';
+
+ &.default {
+ border-top-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-top-color: $popoverArrowBorderInverseColor;
+ }
+ }
+
+ &.default {
+ border-top-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-top-color: $popoverArrowBorderInverseColor;
+ }
+}
+
+.right {
+ top: 50%;
+ left: -11px;
+ margin-top: -11px;
+ border-left-width: 0;
+
+ &::after {
+ bottom: -10px;
+ left: 1px;
+ border-left-width: 0;
+ content: ' ';
+
+ &.default {
+ border-right-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-right-color: $popoverArrowBorderInverseColor;
+ }
+ }
+
+ &.default {
+ border-right-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-right-color: $popoverArrowBorderInverseColor;
+ }
+}
+
+.bottom {
+ top: -11px;
+ left: 50%;
+ margin-left: -11px;
+ border-top-width: 0;
+
+ &::after {
+ top: 1px;
+ margin-left: -10px;
+ border-top-width: 0;
+ content: ' ';
+
+ &.default {
+ border-bottom-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-bottom-color: $popoverArrowBorderInverseColor;
+ }
+ }
+
+ &.default {
+ border-bottom-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-bottom-color: $popoverArrowBorderInverseColor;
+ }
+}
+
+.left {
+ top: 50%;
+ right: -11px;
+ margin-top: -11px;
+ border-right-width: 0;
+
+ &::after {
+ right: 1px;
+ bottom: -10px;
+ border-right-width: 0;
+ content: ' ';
+
+ &.default {
+ border-left-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-left-color: $popoverArrowBorderInverseColor;
+ }
+ }
+
+ &.default {
+ border-left-color: $popoverArrowBorderColor;
+ }
+
+ &.inverse {
+ border-left-color: $popoverArrowBorderInverseColor;
+ }
+}
+
+.body {
+ padding: 5px;
+}
diff --git a/frontend/src/Components/Tooltip/Tooltip.js b/frontend/src/Components/Tooltip/Tooltip.js
new file mode 100644
index 000000000..5b32c5783
--- /dev/null
+++ b/frontend/src/Components/Tooltip/Tooltip.js
@@ -0,0 +1,151 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TetherComponent from 'react-tether';
+import classNames from 'classnames';
+import { kinds, tooltipPositions } from 'Helpers/Props';
+import styles from './Tooltip.css';
+
+const baseTetherOptions = {
+ skipMoveElement: true,
+ constraints: [
+ {
+ to: 'window',
+ attachment: 'together',
+ pin: true
+ }
+ ]
+};
+
+const tetherOptions = {
+ [tooltipPositions.TOP]: {
+ ...baseTetherOptions,
+ attachment: 'bottom center',
+ targetAttachment: 'top center'
+ },
+
+ [tooltipPositions.RIGHT]: {
+ ...baseTetherOptions,
+ attachment: 'middle left',
+ targetAttachment: 'middle right'
+ },
+
+ [tooltipPositions.BOTTOM]: {
+ ...baseTetherOptions,
+ attachment: 'top center',
+ targetAttachment: 'bottom center'
+ },
+
+ [tooltipPositions.LEFT]: {
+ ...baseTetherOptions,
+ attachment: 'middle right',
+ targetAttachment: 'middle left'
+ }
+};
+
+class Tooltip extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._closeTimeout = null;
+
+ this.state = {
+ isOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onClick = () => {
+ this.setState({ isOpen: !this.state.isOpen });
+ }
+
+ onMouseEnter = () => {
+ if (this._closeTimeout) {
+ clearTimeout(this._closeTimeout);
+ }
+
+ this.setState({ isOpen: true });
+ }
+
+ onMouseLeave = () => {
+ this._closeTimeout = setTimeout(() => {
+ this.setState({ isOpen: false });
+ }, 100);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ anchor,
+ tooltip,
+ kind,
+ position
+ } = this.props;
+
+ return (
+
+
+ {anchor}
+
+
+ {
+ this.state.isOpen &&
+
+ }
+
+ );
+ }
+}
+
+Tooltip.propTypes = {
+ anchor: PropTypes.node.isRequired,
+ tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
+ kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]),
+ position: PropTypes.oneOf(tooltipPositions.all)
+};
+
+Tooltip.defaultProps = {
+ kind: kinds.DEFAULT,
+ position: tooltipPositions.TOP
+};
+
+export default Tooltip;
diff --git a/frontend/src/Components/keyboardShortcuts.js b/frontend/src/Components/keyboardShortcuts.js
new file mode 100644
index 000000000..f9ef0c9e3
--- /dev/null
+++ b/frontend/src/Components/keyboardShortcuts.js
@@ -0,0 +1,100 @@
+import React, { Component } from 'react';
+import Mousetrap from 'mousetrap';
+import getDisplayName from 'Helpers/getDisplayName';
+
+export const shortcuts = {
+ OPEN_KEYBOARD_SHORTCUTS_MODAL: {
+ key: '?',
+ name: 'Open This Modal'
+ },
+
+ ARTIST_SEARCH_INPUT: {
+ key: 's',
+ name: 'Focus Search Box'
+ },
+
+ SAVE_SETTINGS: {
+ key: 'mod+s',
+ name: 'Save Settings'
+ }
+};
+
+function keyboardShortcuts(WrappedComponent) {
+ class KeyboardShortcuts extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+ this._mousetrapBindings = {};
+ this._mousetrap = new Mousetrap();
+ this._mousetrap.stopCallback = this.stopCallback;
+ }
+
+ componentWillUnmount() {
+ this.unbindAllShortcuts();
+ this._mousetrap = null;
+ }
+
+ //
+ // Control
+
+ bindShortcut = (key, callback, options = {}) => {
+ this._mousetrap.bind(key, callback);
+ this._mousetrapBindings[key] = options;
+ }
+
+ unbindShortcut = (key) => {
+ delete this._mousetrapBindings[key];
+ this._mousetrap.unbind(key);
+ }
+
+ unbindAllShortcuts = () => {
+ const keys = Object.keys(this._mousetrapBindings);
+
+ if (!keys.length) {
+ return;
+ }
+
+ keys.forEach((binding) => {
+ this._mousetrap.unbind(binding);
+ });
+
+ this._mousetrapBindings = {};
+ }
+
+ stopCallback = (event, element, combo) => {
+ if (this._mousetrapBindings[combo].isGlobal) {
+ return false;
+ }
+
+ return (
+ element.tagName === 'INPUT' ||
+ element.tagName === 'SELECT' ||
+ element.tagName === 'TEXTAREA' ||
+ (element.contentEditable && element.contentEditable === 'true')
+ );
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+ }
+
+ KeyboardShortcuts.displayName = `KeyboardShortcut(${getDisplayName(WrappedComponent)})`;
+ KeyboardShortcuts.WrappedComponent = WrappedComponent;
+
+ return KeyboardShortcuts;
+}
+
+export default keyboardShortcuts;
diff --git a/frontend/src/Components/withScrollPosition.js b/frontend/src/Components/withScrollPosition.js
new file mode 100644
index 000000000..110da9ab2
--- /dev/null
+++ b/frontend/src/Components/withScrollPosition.js
@@ -0,0 +1,30 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import scrollPositions from 'Store/scrollPositions';
+
+function withScrollPosition(WrappedComponent, scrollPositionKey) {
+ function ScrollPosition(props) {
+ const {
+ history
+ } = props;
+
+ const scrollTop = history.action === 'POP' ?
+ scrollPositions[scrollPositionKey] :
+ 0;
+
+ return (
+
+ );
+ }
+
+ ScrollPosition.propTypes = {
+ history: PropTypes.object.isRequired
+ };
+
+ return ScrollPosition;
+}
+
+export default withScrollPosition;
diff --git a/frontend/src/Content/Fonts/FontAwesome.otf b/frontend/src/Content/Fonts/FontAwesome.otf
new file mode 100644
index 000000000..401ec0f36
Binary files /dev/null and b/frontend/src/Content/Fonts/FontAwesome.otf differ
diff --git a/frontend/src/Content/Fonts/Roboto-Light.ttf b/frontend/src/Content/Fonts/Roboto-Light.ttf
new file mode 100644
index 000000000..94c6bcc67
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.ttf differ
diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff b/frontend/src/Content/Fonts/Roboto-Light.woff
new file mode 100644
index 000000000..ec6bf5749
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.woff differ
diff --git a/frontend/src/Content/Fonts/Roboto-Light.woff2 b/frontend/src/Content/Fonts/Roboto-Light.woff2
new file mode 100644
index 000000000..288201788
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Light.woff2 differ
diff --git a/frontend/src/Content/Fonts/Roboto-Regular.ttf b/frontend/src/Content/Fonts/Roboto-Regular.ttf
new file mode 100644
index 000000000..8c082c8de
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.ttf differ
diff --git a/frontend/src/Content/Fonts/Roboto-Regular.woff b/frontend/src/Content/Fonts/Roboto-Regular.woff
new file mode 100644
index 000000000..464d20623
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.woff differ
diff --git a/frontend/src/Content/Fonts/Roboto-Regular.woff2 b/frontend/src/Content/Fonts/Roboto-Regular.woff2
new file mode 100644
index 000000000..f96619675
Binary files /dev/null and b/frontend/src/Content/Fonts/Roboto-Regular.woff2 differ
diff --git a/src/UI/Content/fonts/ubuntumono-regular.eot b/frontend/src/Content/Fonts/UbuntuMono-Regular.eot
similarity index 100%
rename from src/UI/Content/fonts/ubuntumono-regular.eot
rename to frontend/src/Content/Fonts/UbuntuMono-Regular.eot
diff --git a/src/UI/Content/fonts/UbuntuMono-Regular.ttf b/frontend/src/Content/Fonts/UbuntuMono-Regular.ttf
similarity index 100%
rename from src/UI/Content/fonts/UbuntuMono-Regular.ttf
rename to frontend/src/Content/Fonts/UbuntuMono-Regular.ttf
diff --git a/src/UI/Content/fonts/ubuntumono-regular.woff b/frontend/src/Content/Fonts/UbuntuMono-Regular.woff
similarity index 100%
rename from src/UI/Content/fonts/ubuntumono-regular.woff
rename to frontend/src/Content/Fonts/UbuntuMono-Regular.woff
diff --git a/frontend/src/Content/Fonts/font-awesome.css b/frontend/src/Content/Fonts/font-awesome.css
new file mode 100644
index 000000000..2d70abd3c
--- /dev/null
+++ b/frontend/src/Content/Fonts/font-awesome.css
@@ -0,0 +1,2341 @@
+/* stylelint-disable */
+
+/*!
+ * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome
+ * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
+ */
+/* FONT PATH
+ * -------------------------- */
+@font-face {
+ font-family: 'FontAwesome';
+ src: url('fontawesome-webfont.eot?v=4.7.0');
+ src: url('fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'), url('fontawesome-webfont.woff2?v=4.7.0') format('woff2'), url('fontawesome-webfont.woff?v=4.7.0') format('woff'), url('fontawesome-webfont.ttf?v=4.7.0') format('truetype'), url('fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');
+ font-weight: normal;
+ font-style: normal;
+}
+.fa {
+ display: inline-block;
+ font: normal normal normal 14px/1 FontAwesome;
+ font-size: inherit;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+/* makes the font 33% larger relative to the icon container */
+.fa-lg {
+ font-size: 1.33333333em;
+ line-height: 0.75em;
+ vertical-align: -15%;
+}
+.fa-2x {
+ font-size: 2em;
+}
+.fa-3x {
+ font-size: 3em;
+}
+.fa-4x {
+ font-size: 4em;
+}
+.fa-5x {
+ font-size: 5em;
+}
+.fa-fw {
+ width: 1.28571429em;
+ text-align: center;
+}
+.fa-ul {
+ padding-left: 0;
+ margin-left: 2.14285714em;
+ list-style-type: none;
+}
+.fa-ul > li {
+ position: relative;
+}
+.fa-li {
+ position: absolute;
+ left: -2.14285714em;
+ width: 2.14285714em;
+ top: 0.14285714em;
+ text-align: center;
+}
+.fa-li.fa-lg {
+ left: -1.85714286em;
+}
+.fa-border {
+ padding: .2em .25em .15em;
+ border: solid 0.08em #eeeeee;
+ border-radius: .1em;
+}
+.fa-pull-left {
+ float: left;
+}
+.fa-pull-right {
+ float: right;
+}
+.fa.fa-pull-left {
+ margin-right: .3em;
+}
+.fa.fa-pull-right {
+ margin-left: .3em;
+}
+/* Deprecated as of 4.4.0 */
+.pull-right {
+ float: right;
+}
+.pull-left {
+ float: left;
+}
+.fa.pull-left {
+ margin-right: .3em;
+}
+.fa.pull-right {
+ margin-left: .3em;
+}
+.fa-spin {
+ -webkit-animation: fa-spin 2s infinite linear;
+ animation: fa-spin 2s infinite linear;
+}
+.fa-pulse {
+ -webkit-animation: fa-spin 1s infinite steps(8);
+ animation: fa-spin 1s infinite steps(8);
+}
+@-webkit-keyframes fa-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+ 100% {
+ -webkit-transform: rotate(359deg);
+ transform: rotate(359deg);
+ }
+}
+@keyframes fa-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+ 100% {
+ -webkit-transform: rotate(359deg);
+ transform: rotate(359deg);
+ }
+}
+.fa-rotate-90 {
+ -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";
+ -webkit-transform: rotate(90deg);
+ -ms-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+.fa-rotate-180 {
+ -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";
+ -webkit-transform: rotate(180deg);
+ -ms-transform: rotate(180deg);
+ transform: rotate(180deg);
+}
+.fa-rotate-270 {
+ -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";
+ -webkit-transform: rotate(270deg);
+ -ms-transform: rotate(270deg);
+ transform: rotate(270deg);
+}
+.fa-flip-horizontal {
+ -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";
+ -webkit-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+}
+.fa-flip-vertical {
+ -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";
+ -webkit-transform: scale(1, -1);
+ -ms-transform: scale(1, -1);
+ transform: scale(1, -1);
+}
+:root .fa-rotate-90,
+:root .fa-rotate-180,
+:root .fa-rotate-270,
+:root .fa-flip-horizontal,
+:root .fa-flip-vertical {
+ filter: none;
+}
+.fa-stack {
+ position: relative;
+ display: inline-block;
+ width: 2em;
+ height: 2em;
+ line-height: 2em;
+ vertical-align: middle;
+}
+.fa-stack-1x,
+.fa-stack-2x {
+ position: absolute;
+ left: 0;
+ width: 100%;
+ text-align: center;
+}
+.fa-stack-1x {
+ line-height: inherit;
+}
+.fa-stack-2x {
+ font-size: 2em;
+}
+.fa-inverse {
+ color: #ffffff;
+}
+/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen
+ readers do not read off random characters that represent icons */
+.fa-glass:before {
+ content: "\f000";
+}
+.fa-music:before {
+ content: "\f001";
+}
+.fa-search:before {
+ content: "\f002";
+}
+.fa-envelope-o:before {
+ content: "\f003";
+}
+.fa-heart:before {
+ content: "\f004";
+}
+.fa-star:before {
+ content: "\f005";
+}
+.fa-star-o:before {
+ content: "\f006";
+}
+.fa-user:before {
+ content: "\f007";
+}
+.fa-film:before {
+ content: "\f008";
+}
+.fa-th-large:before {
+ content: "\f009";
+}
+.fa-th:before {
+ content: "\f00a";
+}
+.fa-th-list:before {
+ content: "\f00b";
+}
+.fa-check:before {
+ content: "\f00c";
+}
+.fa-remove:before,
+.fa-close:before,
+.fa-times:before {
+ content: "\f00d";
+}
+.fa-search-plus:before {
+ content: "\f00e";
+}
+.fa-search-minus:before {
+ content: "\f010";
+}
+.fa-power-off:before {
+ content: "\f011";
+}
+.fa-signal:before {
+ content: "\f012";
+}
+.fa-gear:before,
+.fa-cog:before {
+ content: "\f013";
+}
+.fa-trash-o:before {
+ content: "\f014";
+}
+.fa-home:before {
+ content: "\f015";
+}
+.fa-file-o:before {
+ content: "\f016";
+}
+.fa-clock-o:before {
+ content: "\f017";
+}
+.fa-road:before {
+ content: "\f018";
+}
+.fa-download:before {
+ content: "\f019";
+}
+.fa-arrow-circle-o-down:before {
+ content: "\f01a";
+}
+.fa-arrow-circle-o-up:before {
+ content: "\f01b";
+}
+.fa-inbox:before {
+ content: "\f01c";
+}
+.fa-play-circle-o:before {
+ content: "\f01d";
+}
+.fa-rotate-right:before,
+.fa-repeat:before {
+ content: "\f01e";
+}
+.fa-refresh:before {
+ content: "\f021";
+}
+.fa-list-alt:before {
+ content: "\f022";
+}
+.fa-lock:before {
+ content: "\f023";
+}
+.fa-flag:before {
+ content: "\f024";
+}
+.fa-headphones:before {
+ content: "\f025";
+}
+.fa-volume-off:before {
+ content: "\f026";
+}
+.fa-volume-down:before {
+ content: "\f027";
+}
+.fa-volume-up:before {
+ content: "\f028";
+}
+.fa-qrcode:before {
+ content: "\f029";
+}
+.fa-barcode:before {
+ content: "\f02a";
+}
+.fa-tag:before {
+ content: "\f02b";
+}
+.fa-tags:before {
+ content: "\f02c";
+}
+.fa-book:before {
+ content: "\f02d";
+}
+.fa-bookmark:before {
+ content: "\f02e";
+}
+.fa-print:before {
+ content: "\f02f";
+}
+.fa-camera:before {
+ content: "\f030";
+}
+.fa-font:before {
+ content: "\f031";
+}
+.fa-bold:before {
+ content: "\f032";
+}
+.fa-italic:before {
+ content: "\f033";
+}
+.fa-text-height:before {
+ content: "\f034";
+}
+.fa-text-width:before {
+ content: "\f035";
+}
+.fa-align-left:before {
+ content: "\f036";
+}
+.fa-align-center:before {
+ content: "\f037";
+}
+.fa-align-right:before {
+ content: "\f038";
+}
+.fa-align-justify:before {
+ content: "\f039";
+}
+.fa-list:before {
+ content: "\f03a";
+}
+.fa-dedent:before,
+.fa-outdent:before {
+ content: "\f03b";
+}
+.fa-indent:before {
+ content: "\f03c";
+}
+.fa-video-camera:before {
+ content: "\f03d";
+}
+.fa-photo:before,
+.fa-image:before,
+.fa-picture-o:before {
+ content: "\f03e";
+}
+.fa-pencil:before {
+ content: "\f040";
+}
+.fa-map-marker:before {
+ content: "\f041";
+}
+.fa-adjust:before {
+ content: "\f042";
+}
+.fa-tint:before {
+ content: "\f043";
+}
+.fa-edit:before,
+.fa-pencil-square-o:before {
+ content: "\f044";
+}
+.fa-share-square-o:before {
+ content: "\f045";
+}
+.fa-check-square-o:before {
+ content: "\f046";
+}
+.fa-arrows:before {
+ content: "\f047";
+}
+.fa-step-backward:before {
+ content: "\f048";
+}
+.fa-fast-backward:before {
+ content: "\f049";
+}
+.fa-backward:before {
+ content: "\f04a";
+}
+.fa-play:before {
+ content: "\f04b";
+}
+.fa-pause:before {
+ content: "\f04c";
+}
+.fa-stop:before {
+ content: "\f04d";
+}
+.fa-forward:before {
+ content: "\f04e";
+}
+.fa-fast-forward:before {
+ content: "\f050";
+}
+.fa-step-forward:before {
+ content: "\f051";
+}
+.fa-eject:before {
+ content: "\f052";
+}
+.fa-chevron-left:before {
+ content: "\f053";
+}
+.fa-chevron-right:before {
+ content: "\f054";
+}
+.fa-plus-circle:before {
+ content: "\f055";
+}
+.fa-minus-circle:before {
+ content: "\f056";
+}
+.fa-times-circle:before {
+ content: "\f057";
+}
+.fa-check-circle:before {
+ content: "\f058";
+}
+.fa-question-circle:before {
+ content: "\f059";
+}
+.fa-info-circle:before {
+ content: "\f05a";
+}
+.fa-crosshairs:before {
+ content: "\f05b";
+}
+.fa-times-circle-o:before {
+ content: "\f05c";
+}
+.fa-check-circle-o:before {
+ content: "\f05d";
+}
+.fa-ban:before {
+ content: "\f05e";
+}
+.fa-arrow-left:before {
+ content: "\f060";
+}
+.fa-arrow-right:before {
+ content: "\f061";
+}
+.fa-arrow-up:before {
+ content: "\f062";
+}
+.fa-arrow-down:before {
+ content: "\f063";
+}
+.fa-mail-forward:before,
+.fa-share:before {
+ content: "\f064";
+}
+.fa-expand:before {
+ content: "\f065";
+}
+.fa-compress:before {
+ content: "\f066";
+}
+.fa-plus:before {
+ content: "\f067";
+}
+.fa-minus:before {
+ content: "\f068";
+}
+.fa-asterisk:before {
+ content: "\f069";
+}
+.fa-exclamation-circle:before {
+ content: "\f06a";
+}
+.fa-gift:before {
+ content: "\f06b";
+}
+.fa-leaf:before {
+ content: "\f06c";
+}
+.fa-fire:before {
+ content: "\f06d";
+}
+.fa-eye:before {
+ content: "\f06e";
+}
+.fa-eye-slash:before {
+ content: "\f070";
+}
+.fa-warning:before,
+.fa-exclamation-triangle:before {
+ content: "\f071";
+}
+.fa-plane:before {
+ content: "\f072";
+}
+.fa-calendar:before {
+ content: "\f073";
+}
+.fa-random:before {
+ content: "\f074";
+}
+.fa-comment:before {
+ content: "\f075";
+}
+.fa-magnet:before {
+ content: "\f076";
+}
+.fa-chevron-up:before {
+ content: "\f077";
+}
+.fa-chevron-down:before {
+ content: "\f078";
+}
+.fa-retweet:before {
+ content: "\f079";
+}
+.fa-shopping-cart:before {
+ content: "\f07a";
+}
+.fa-folder:before {
+ content: "\f07b";
+}
+.fa-folder-open:before {
+ content: "\f07c";
+}
+.fa-arrows-v:before {
+ content: "\f07d";
+}
+.fa-arrows-h:before {
+ content: "\f07e";
+}
+.fa-bar-chart-o:before,
+.fa-bar-chart:before {
+ content: "\f080";
+}
+.fa-twitter-square:before {
+ content: "\f081";
+}
+.fa-facebook-square:before {
+ content: "\f082";
+}
+.fa-camera-retro:before {
+ content: "\f083";
+}
+.fa-key:before {
+ content: "\f084";
+}
+.fa-gears:before,
+.fa-cogs:before {
+ content: "\f085";
+}
+.fa-comments:before {
+ content: "\f086";
+}
+.fa-thumbs-o-up:before {
+ content: "\f087";
+}
+.fa-thumbs-o-down:before {
+ content: "\f088";
+}
+.fa-star-half:before {
+ content: "\f089";
+}
+.fa-heart-o:before {
+ content: "\f08a";
+}
+.fa-sign-out:before {
+ content: "\f08b";
+}
+.fa-linkedin-square:before {
+ content: "\f08c";
+}
+.fa-thumb-tack:before {
+ content: "\f08d";
+}
+.fa-external-link:before {
+ content: "\f08e";
+}
+.fa-sign-in:before {
+ content: "\f090";
+}
+.fa-trophy:before {
+ content: "\f091";
+}
+.fa-github-square:before {
+ content: "\f092";
+}
+.fa-upload:before {
+ content: "\f093";
+}
+.fa-lemon-o:before {
+ content: "\f094";
+}
+.fa-phone:before {
+ content: "\f095";
+}
+.fa-square-o:before {
+ content: "\f096";
+}
+.fa-bookmark-o:before {
+ content: "\f097";
+}
+.fa-phone-square:before {
+ content: "\f098";
+}
+.fa-twitter:before {
+ content: "\f099";
+}
+.fa-facebook-f:before,
+.fa-facebook:before {
+ content: "\f09a";
+}
+.fa-github:before {
+ content: "\f09b";
+}
+.fa-unlock:before {
+ content: "\f09c";
+}
+.fa-credit-card:before {
+ content: "\f09d";
+}
+.fa-feed:before,
+.fa-rss:before {
+ content: "\f09e";
+}
+.fa-hdd-o:before {
+ content: "\f0a0";
+}
+.fa-bullhorn:before {
+ content: "\f0a1";
+}
+.fa-bell:before {
+ content: "\f0f3";
+}
+.fa-certificate:before {
+ content: "\f0a3";
+}
+.fa-hand-o-right:before {
+ content: "\f0a4";
+}
+.fa-hand-o-left:before {
+ content: "\f0a5";
+}
+.fa-hand-o-up:before {
+ content: "\f0a6";
+}
+.fa-hand-o-down:before {
+ content: "\f0a7";
+}
+.fa-arrow-circle-left:before {
+ content: "\f0a8";
+}
+.fa-arrow-circle-right:before {
+ content: "\f0a9";
+}
+.fa-arrow-circle-up:before {
+ content: "\f0aa";
+}
+.fa-arrow-circle-down:before {
+ content: "\f0ab";
+}
+.fa-globe:before {
+ content: "\f0ac";
+}
+.fa-wrench:before {
+ content: "\f0ad";
+}
+.fa-tasks:before {
+ content: "\f0ae";
+}
+.fa-filter:before {
+ content: "\f0b0";
+}
+.fa-briefcase:before {
+ content: "\f0b1";
+}
+.fa-arrows-alt:before {
+ content: "\f0b2";
+}
+.fa-group:before,
+.fa-users:before {
+ content: "\f0c0";
+}
+.fa-chain:before,
+.fa-link:before {
+ content: "\f0c1";
+}
+.fa-cloud:before {
+ content: "\f0c2";
+}
+.fa-flask:before {
+ content: "\f0c3";
+}
+.fa-cut:before,
+.fa-scissors:before {
+ content: "\f0c4";
+}
+.fa-copy:before,
+.fa-files-o:before {
+ content: "\f0c5";
+}
+.fa-paperclip:before {
+ content: "\f0c6";
+}
+.fa-save:before,
+.fa-floppy-o:before {
+ content: "\f0c7";
+}
+.fa-square:before {
+ content: "\f0c8";
+}
+.fa-navicon:before,
+.fa-reorder:before,
+.fa-bars:before {
+ content: "\f0c9";
+}
+.fa-list-ul:before {
+ content: "\f0ca";
+}
+.fa-list-ol:before {
+ content: "\f0cb";
+}
+.fa-strikethrough:before {
+ content: "\f0cc";
+}
+.fa-underline:before {
+ content: "\f0cd";
+}
+.fa-table:before {
+ content: "\f0ce";
+}
+.fa-magic:before {
+ content: "\f0d0";
+}
+.fa-truck:before {
+ content: "\f0d1";
+}
+.fa-pinterest:before {
+ content: "\f0d2";
+}
+.fa-pinterest-square:before {
+ content: "\f0d3";
+}
+.fa-google-plus-square:before {
+ content: "\f0d4";
+}
+.fa-google-plus:before {
+ content: "\f0d5";
+}
+.fa-money:before {
+ content: "\f0d6";
+}
+.fa-caret-down:before {
+ content: "\f0d7";
+}
+.fa-caret-up:before {
+ content: "\f0d8";
+}
+.fa-caret-left:before {
+ content: "\f0d9";
+}
+.fa-caret-right:before {
+ content: "\f0da";
+}
+.fa-columns:before {
+ content: "\f0db";
+}
+.fa-unsorted:before,
+.fa-sort:before {
+ content: "\f0dc";
+}
+.fa-sort-down:before,
+.fa-sort-desc:before {
+ content: "\f0dd";
+}
+.fa-sort-up:before,
+.fa-sort-asc:before {
+ content: "\f0de";
+}
+.fa-envelope:before {
+ content: "\f0e0";
+}
+.fa-linkedin:before {
+ content: "\f0e1";
+}
+.fa-rotate-left:before,
+.fa-undo:before {
+ content: "\f0e2";
+}
+.fa-legal:before,
+.fa-gavel:before {
+ content: "\f0e3";
+}
+.fa-dashboard:before,
+.fa-tachometer:before {
+ content: "\f0e4";
+}
+.fa-comment-o:before {
+ content: "\f0e5";
+}
+.fa-comments-o:before {
+ content: "\f0e6";
+}
+.fa-flash:before,
+.fa-bolt:before {
+ content: "\f0e7";
+}
+.fa-sitemap:before {
+ content: "\f0e8";
+}
+.fa-umbrella:before {
+ content: "\f0e9";
+}
+.fa-paste:before,
+.fa-clipboard:before {
+ content: "\f0ea";
+}
+.fa-lightbulb-o:before {
+ content: "\f0eb";
+}
+.fa-exchange:before {
+ content: "\f0ec";
+}
+.fa-cloud-download:before {
+ content: "\f0ed";
+}
+.fa-cloud-upload:before {
+ content: "\f0ee";
+}
+.fa-user-md:before {
+ content: "\f0f0";
+}
+.fa-stethoscope:before {
+ content: "\f0f1";
+}
+.fa-suitcase:before {
+ content: "\f0f2";
+}
+.fa-bell-o:before {
+ content: "\f0a2";
+}
+.fa-coffee:before {
+ content: "\f0f4";
+}
+.fa-cutlery:before {
+ content: "\f0f5";
+}
+.fa-file-text-o:before {
+ content: "\f0f6";
+}
+.fa-building-o:before {
+ content: "\f0f7";
+}
+.fa-hospital-o:before {
+ content: "\f0f8";
+}
+.fa-ambulance:before {
+ content: "\f0f9";
+}
+.fa-medkit:before {
+ content: "\f0fa";
+}
+.fa-fighter-jet:before {
+ content: "\f0fb";
+}
+.fa-beer:before {
+ content: "\f0fc";
+}
+.fa-h-square:before {
+ content: "\f0fd";
+}
+.fa-plus-square:before {
+ content: "\f0fe";
+}
+.fa-angle-double-left:before {
+ content: "\f100";
+}
+.fa-angle-double-right:before {
+ content: "\f101";
+}
+.fa-angle-double-up:before {
+ content: "\f102";
+}
+.fa-angle-double-down:before {
+ content: "\f103";
+}
+.fa-angle-left:before {
+ content: "\f104";
+}
+.fa-angle-right:before {
+ content: "\f105";
+}
+.fa-angle-up:before {
+ content: "\f106";
+}
+.fa-angle-down:before {
+ content: "\f107";
+}
+.fa-desktop:before {
+ content: "\f108";
+}
+.fa-laptop:before {
+ content: "\f109";
+}
+.fa-tablet:before {
+ content: "\f10a";
+}
+.fa-mobile-phone:before,
+.fa-mobile:before {
+ content: "\f10b";
+}
+.fa-circle-o:before {
+ content: "\f10c";
+}
+.fa-quote-left:before {
+ content: "\f10d";
+}
+.fa-quote-right:before {
+ content: "\f10e";
+}
+.fa-spinner:before {
+ content: "\f110";
+}
+.fa-circle:before {
+ content: "\f111";
+}
+.fa-mail-reply:before,
+.fa-reply:before {
+ content: "\f112";
+}
+.fa-github-alt:before {
+ content: "\f113";
+}
+.fa-folder-o:before {
+ content: "\f114";
+}
+.fa-folder-open-o:before {
+ content: "\f115";
+}
+.fa-smile-o:before {
+ content: "\f118";
+}
+.fa-frown-o:before {
+ content: "\f119";
+}
+.fa-meh-o:before {
+ content: "\f11a";
+}
+.fa-gamepad:before {
+ content: "\f11b";
+}
+.fa-keyboard-o:before {
+ content: "\f11c";
+}
+.fa-flag-o:before {
+ content: "\f11d";
+}
+.fa-flag-checkered:before {
+ content: "\f11e";
+}
+.fa-terminal:before {
+ content: "\f120";
+}
+.fa-code:before {
+ content: "\f121";
+}
+.fa-mail-reply-all:before,
+.fa-reply-all:before {
+ content: "\f122";
+}
+.fa-star-half-empty:before,
+.fa-star-half-full:before,
+.fa-star-half-o:before {
+ content: "\f123";
+}
+.fa-location-arrow:before {
+ content: "\f124";
+}
+.fa-crop:before {
+ content: "\f125";
+}
+.fa-code-fork:before {
+ content: "\f126";
+}
+.fa-unlink:before,
+.fa-chain-broken:before {
+ content: "\f127";
+}
+.fa-question:before {
+ content: "\f128";
+}
+.fa-info:before {
+ content: "\f129";
+}
+.fa-exclamation:before {
+ content: "\f12a";
+}
+.fa-superscript:before {
+ content: "\f12b";
+}
+.fa-subscript:before {
+ content: "\f12c";
+}
+.fa-eraser:before {
+ content: "\f12d";
+}
+.fa-puzzle-piece:before {
+ content: "\f12e";
+}
+.fa-microphone:before {
+ content: "\f130";
+}
+.fa-microphone-slash:before {
+ content: "\f131";
+}
+.fa-shield:before {
+ content: "\f132";
+}
+.fa-calendar-o:before {
+ content: "\f133";
+}
+.fa-fire-extinguisher:before {
+ content: "\f134";
+}
+.fa-rocket:before {
+ content: "\f135";
+}
+.fa-maxcdn:before {
+ content: "\f136";
+}
+.fa-chevron-circle-left:before {
+ content: "\f137";
+}
+.fa-chevron-circle-right:before {
+ content: "\f138";
+}
+.fa-chevron-circle-up:before {
+ content: "\f139";
+}
+.fa-chevron-circle-down:before {
+ content: "\f13a";
+}
+.fa-html5:before {
+ content: "\f13b";
+}
+.fa-css3:before {
+ content: "\f13c";
+}
+.fa-anchor:before {
+ content: "\f13d";
+}
+.fa-unlock-alt:before {
+ content: "\f13e";
+}
+.fa-bullseye:before {
+ content: "\f140";
+}
+.fa-ellipsis-h:before {
+ content: "\f141";
+}
+.fa-ellipsis-v:before {
+ content: "\f142";
+}
+.fa-rss-square:before {
+ content: "\f143";
+}
+.fa-play-circle:before {
+ content: "\f144";
+}
+.fa-ticket:before {
+ content: "\f145";
+}
+.fa-minus-square:before {
+ content: "\f146";
+}
+.fa-minus-square-o:before {
+ content: "\f147";
+}
+.fa-level-up:before {
+ content: "\f148";
+}
+.fa-level-down:before {
+ content: "\f149";
+}
+.fa-check-square:before {
+ content: "\f14a";
+}
+.fa-pencil-square:before {
+ content: "\f14b";
+}
+.fa-external-link-square:before {
+ content: "\f14c";
+}
+.fa-share-square:before {
+ content: "\f14d";
+}
+.fa-compass:before {
+ content: "\f14e";
+}
+.fa-toggle-down:before,
+.fa-caret-square-o-down:before {
+ content: "\f150";
+}
+.fa-toggle-up:before,
+.fa-caret-square-o-up:before {
+ content: "\f151";
+}
+.fa-toggle-right:before,
+.fa-caret-square-o-right:before {
+ content: "\f152";
+}
+.fa-euro:before,
+.fa-eur:before {
+ content: "\f153";
+}
+.fa-gbp:before {
+ content: "\f154";
+}
+.fa-dollar:before,
+.fa-usd:before {
+ content: "\f155";
+}
+.fa-rupee:before,
+.fa-inr:before {
+ content: "\f156";
+}
+.fa-cny:before,
+.fa-rmb:before,
+.fa-yen:before,
+.fa-jpy:before {
+ content: "\f157";
+}
+.fa-ruble:before,
+.fa-rouble:before,
+.fa-rub:before {
+ content: "\f158";
+}
+.fa-won:before,
+.fa-krw:before {
+ content: "\f159";
+}
+.fa-bitcoin:before,
+.fa-btc:before {
+ content: "\f15a";
+}
+.fa-file:before {
+ content: "\f15b";
+}
+.fa-file-text:before {
+ content: "\f15c";
+}
+.fa-sort-alpha-asc:before {
+ content: "\f15d";
+}
+.fa-sort-alpha-desc:before {
+ content: "\f15e";
+}
+.fa-sort-amount-asc:before {
+ content: "\f160";
+}
+.fa-sort-amount-desc:before {
+ content: "\f161";
+}
+.fa-sort-numeric-asc:before {
+ content: "\f162";
+}
+.fa-sort-numeric-desc:before {
+ content: "\f163";
+}
+.fa-thumbs-up:before {
+ content: "\f164";
+}
+.fa-thumbs-down:before {
+ content: "\f165";
+}
+.fa-youtube-square:before {
+ content: "\f166";
+}
+.fa-youtube:before {
+ content: "\f167";
+}
+.fa-xing:before {
+ content: "\f168";
+}
+.fa-xing-square:before {
+ content: "\f169";
+}
+.fa-youtube-play:before {
+ content: "\f16a";
+}
+.fa-dropbox:before {
+ content: "\f16b";
+}
+.fa-stack-overflow:before {
+ content: "\f16c";
+}
+.fa-instagram:before {
+ content: "\f16d";
+}
+.fa-flickr:before {
+ content: "\f16e";
+}
+.fa-adn:before {
+ content: "\f170";
+}
+.fa-bitbucket:before {
+ content: "\f171";
+}
+.fa-bitbucket-square:before {
+ content: "\f172";
+}
+.fa-tumblr:before {
+ content: "\f173";
+}
+.fa-tumblr-square:before {
+ content: "\f174";
+}
+.fa-long-arrow-down:before {
+ content: "\f175";
+}
+.fa-long-arrow-up:before {
+ content: "\f176";
+}
+.fa-long-arrow-left:before {
+ content: "\f177";
+}
+.fa-long-arrow-right:before {
+ content: "\f178";
+}
+.fa-apple:before {
+ content: "\f179";
+}
+.fa-windows:before {
+ content: "\f17a";
+}
+.fa-android:before {
+ content: "\f17b";
+}
+.fa-linux:before {
+ content: "\f17c";
+}
+.fa-dribbble:before {
+ content: "\f17d";
+}
+.fa-skype:before {
+ content: "\f17e";
+}
+.fa-foursquare:before {
+ content: "\f180";
+}
+.fa-trello:before {
+ content: "\f181";
+}
+.fa-female:before {
+ content: "\f182";
+}
+.fa-male:before {
+ content: "\f183";
+}
+.fa-gittip:before,
+.fa-gratipay:before {
+ content: "\f184";
+}
+.fa-sun-o:before {
+ content: "\f185";
+}
+.fa-moon-o:before {
+ content: "\f186";
+}
+.fa-archive:before {
+ content: "\f187";
+}
+.fa-bug:before {
+ content: "\f188";
+}
+.fa-vk:before {
+ content: "\f189";
+}
+.fa-weibo:before {
+ content: "\f18a";
+}
+.fa-renren:before {
+ content: "\f18b";
+}
+.fa-pagelines:before {
+ content: "\f18c";
+}
+.fa-stack-exchange:before {
+ content: "\f18d";
+}
+.fa-arrow-circle-o-right:before {
+ content: "\f18e";
+}
+.fa-arrow-circle-o-left:before {
+ content: "\f190";
+}
+.fa-toggle-left:before,
+.fa-caret-square-o-left:before {
+ content: "\f191";
+}
+.fa-dot-circle-o:before {
+ content: "\f192";
+}
+.fa-wheelchair:before {
+ content: "\f193";
+}
+.fa-vimeo-square:before {
+ content: "\f194";
+}
+.fa-turkish-lira:before,
+.fa-try:before {
+ content: "\f195";
+}
+.fa-plus-square-o:before {
+ content: "\f196";
+}
+.fa-space-shuttle:before {
+ content: "\f197";
+}
+.fa-slack:before {
+ content: "\f198";
+}
+.fa-envelope-square:before {
+ content: "\f199";
+}
+.fa-wordpress:before {
+ content: "\f19a";
+}
+.fa-openid:before {
+ content: "\f19b";
+}
+.fa-institution:before,
+.fa-bank:before,
+.fa-university:before {
+ content: "\f19c";
+}
+.fa-mortar-board:before,
+.fa-graduation-cap:before {
+ content: "\f19d";
+}
+.fa-yahoo:before {
+ content: "\f19e";
+}
+.fa-google:before {
+ content: "\f1a0";
+}
+.fa-reddit:before {
+ content: "\f1a1";
+}
+.fa-reddit-square:before {
+ content: "\f1a2";
+}
+.fa-stumbleupon-circle:before {
+ content: "\f1a3";
+}
+.fa-stumbleupon:before {
+ content: "\f1a4";
+}
+.fa-delicious:before {
+ content: "\f1a5";
+}
+.fa-digg:before {
+ content: "\f1a6";
+}
+.fa-pied-piper-pp:before {
+ content: "\f1a7";
+}
+.fa-pied-piper-alt:before {
+ content: "\f1a8";
+}
+.fa-drupal:before {
+ content: "\f1a9";
+}
+.fa-joomla:before {
+ content: "\f1aa";
+}
+.fa-language:before {
+ content: "\f1ab";
+}
+.fa-fax:before {
+ content: "\f1ac";
+}
+.fa-building:before {
+ content: "\f1ad";
+}
+.fa-child:before {
+ content: "\f1ae";
+}
+.fa-paw:before {
+ content: "\f1b0";
+}
+.fa-spoon:before {
+ content: "\f1b1";
+}
+.fa-cube:before {
+ content: "\f1b2";
+}
+.fa-cubes:before {
+ content: "\f1b3";
+}
+.fa-behance:before {
+ content: "\f1b4";
+}
+.fa-behance-square:before {
+ content: "\f1b5";
+}
+.fa-steam:before {
+ content: "\f1b6";
+}
+.fa-steam-square:before {
+ content: "\f1b7";
+}
+.fa-recycle:before {
+ content: "\f1b8";
+}
+.fa-automobile:before,
+.fa-car:before {
+ content: "\f1b9";
+}
+.fa-cab:before,
+.fa-taxi:before {
+ content: "\f1ba";
+}
+.fa-tree:before {
+ content: "\f1bb";
+}
+.fa-spotify:before {
+ content: "\f1bc";
+}
+.fa-deviantart:before {
+ content: "\f1bd";
+}
+.fa-soundcloud:before {
+ content: "\f1be";
+}
+.fa-database:before {
+ content: "\f1c0";
+}
+.fa-file-pdf-o:before {
+ content: "\f1c1";
+}
+.fa-file-word-o:before {
+ content: "\f1c2";
+}
+.fa-file-excel-o:before {
+ content: "\f1c3";
+}
+.fa-file-powerpoint-o:before {
+ content: "\f1c4";
+}
+.fa-file-photo-o:before,
+.fa-file-picture-o:before,
+.fa-file-image-o:before {
+ content: "\f1c5";
+}
+.fa-file-zip-o:before,
+.fa-file-archive-o:before {
+ content: "\f1c6";
+}
+.fa-file-sound-o:before,
+.fa-file-audio-o:before {
+ content: "\f1c7";
+}
+.fa-file-movie-o:before,
+.fa-file-video-o:before {
+ content: "\f1c8";
+}
+.fa-file-code-o:before {
+ content: "\f1c9";
+}
+.fa-vine:before {
+ content: "\f1ca";
+}
+.fa-codepen:before {
+ content: "\f1cb";
+}
+.fa-jsfiddle:before {
+ content: "\f1cc";
+}
+.fa-life-bouy:before,
+.fa-life-buoy:before,
+.fa-life-saver:before,
+.fa-support:before,
+.fa-life-ring:before {
+ content: "\f1cd";
+}
+.fa-circle-o-notch:before {
+ content: "\f1ce";
+}
+.fa-ra:before,
+.fa-resistance:before,
+.fa-rebel:before {
+ content: "\f1d0";
+}
+.fa-ge:before,
+.fa-empire:before {
+ content: "\f1d1";
+}
+.fa-git-square:before {
+ content: "\f1d2";
+}
+.fa-git:before {
+ content: "\f1d3";
+}
+.fa-y-combinator-square:before,
+.fa-yc-square:before,
+.fa-hacker-news:before {
+ content: "\f1d4";
+}
+.fa-tencent-weibo:before {
+ content: "\f1d5";
+}
+.fa-qq:before {
+ content: "\f1d6";
+}
+.fa-wechat:before,
+.fa-weixin:before {
+ content: "\f1d7";
+}
+.fa-send:before,
+.fa-paper-plane:before {
+ content: "\f1d8";
+}
+.fa-send-o:before,
+.fa-paper-plane-o:before {
+ content: "\f1d9";
+}
+.fa-history:before {
+ content: "\f1da";
+}
+.fa-circle-thin:before {
+ content: "\f1db";
+}
+.fa-header:before {
+ content: "\f1dc";
+}
+.fa-paragraph:before {
+ content: "\f1dd";
+}
+.fa-sliders:before {
+ content: "\f1de";
+}
+.fa-share-alt:before {
+ content: "\f1e0";
+}
+.fa-share-alt-square:before {
+ content: "\f1e1";
+}
+.fa-bomb:before {
+ content: "\f1e2";
+}
+.fa-soccer-ball-o:before,
+.fa-futbol-o:before {
+ content: "\f1e3";
+}
+.fa-tty:before {
+ content: "\f1e4";
+}
+.fa-binoculars:before {
+ content: "\f1e5";
+}
+.fa-plug:before {
+ content: "\f1e6";
+}
+.fa-slideshare:before {
+ content: "\f1e7";
+}
+.fa-twitch:before {
+ content: "\f1e8";
+}
+.fa-yelp:before {
+ content: "\f1e9";
+}
+.fa-newspaper-o:before {
+ content: "\f1ea";
+}
+.fa-wifi:before {
+ content: "\f1eb";
+}
+.fa-calculator:before {
+ content: "\f1ec";
+}
+.fa-paypal:before {
+ content: "\f1ed";
+}
+.fa-google-wallet:before {
+ content: "\f1ee";
+}
+.fa-cc-visa:before {
+ content: "\f1f0";
+}
+.fa-cc-mastercard:before {
+ content: "\f1f1";
+}
+.fa-cc-discover:before {
+ content: "\f1f2";
+}
+.fa-cc-amex:before {
+ content: "\f1f3";
+}
+.fa-cc-paypal:before {
+ content: "\f1f4";
+}
+.fa-cc-stripe:before {
+ content: "\f1f5";
+}
+.fa-bell-slash:before {
+ content: "\f1f6";
+}
+.fa-bell-slash-o:before {
+ content: "\f1f7";
+}
+.fa-trash:before {
+ content: "\f1f8";
+}
+.fa-copyright:before {
+ content: "\f1f9";
+}
+.fa-at:before {
+ content: "\f1fa";
+}
+.fa-eyedropper:before {
+ content: "\f1fb";
+}
+.fa-paint-brush:before {
+ content: "\f1fc";
+}
+.fa-birthday-cake:before {
+ content: "\f1fd";
+}
+.fa-area-chart:before {
+ content: "\f1fe";
+}
+.fa-pie-chart:before {
+ content: "\f200";
+}
+.fa-line-chart:before {
+ content: "\f201";
+}
+.fa-lastfm:before {
+ content: "\f202";
+}
+.fa-lastfm-square:before {
+ content: "\f203";
+}
+.fa-toggle-off:before {
+ content: "\f204";
+}
+.fa-toggle-on:before {
+ content: "\f205";
+}
+.fa-bicycle:before {
+ content: "\f206";
+}
+.fa-bus:before {
+ content: "\f207";
+}
+.fa-ioxhost:before {
+ content: "\f208";
+}
+.fa-angellist:before {
+ content: "\f209";
+}
+.fa-cc:before {
+ content: "\f20a";
+}
+.fa-shekel:before,
+.fa-sheqel:before,
+.fa-ils:before {
+ content: "\f20b";
+}
+.fa-meanpath:before {
+ content: "\f20c";
+}
+.fa-buysellads:before {
+ content: "\f20d";
+}
+.fa-connectdevelop:before {
+ content: "\f20e";
+}
+.fa-dashcube:before {
+ content: "\f210";
+}
+.fa-forumbee:before {
+ content: "\f211";
+}
+.fa-leanpub:before {
+ content: "\f212";
+}
+.fa-sellsy:before {
+ content: "\f213";
+}
+.fa-shirtsinbulk:before {
+ content: "\f214";
+}
+.fa-simplybuilt:before {
+ content: "\f215";
+}
+.fa-skyatlas:before {
+ content: "\f216";
+}
+.fa-cart-plus:before {
+ content: "\f217";
+}
+.fa-cart-arrow-down:before {
+ content: "\f218";
+}
+.fa-diamond:before {
+ content: "\f219";
+}
+.fa-ship:before {
+ content: "\f21a";
+}
+.fa-user-secret:before {
+ content: "\f21b";
+}
+.fa-motorcycle:before {
+ content: "\f21c";
+}
+.fa-street-view:before {
+ content: "\f21d";
+}
+.fa-heartbeat:before {
+ content: "\f21e";
+}
+.fa-venus:before {
+ content: "\f221";
+}
+.fa-mars:before {
+ content: "\f222";
+}
+.fa-mercury:before {
+ content: "\f223";
+}
+.fa-intersex:before,
+.fa-transgender:before {
+ content: "\f224";
+}
+.fa-transgender-alt:before {
+ content: "\f225";
+}
+.fa-venus-double:before {
+ content: "\f226";
+}
+.fa-mars-double:before {
+ content: "\f227";
+}
+.fa-venus-mars:before {
+ content: "\f228";
+}
+.fa-mars-stroke:before {
+ content: "\f229";
+}
+.fa-mars-stroke-v:before {
+ content: "\f22a";
+}
+.fa-mars-stroke-h:before {
+ content: "\f22b";
+}
+.fa-neuter:before {
+ content: "\f22c";
+}
+.fa-genderless:before {
+ content: "\f22d";
+}
+.fa-facebook-official:before {
+ content: "\f230";
+}
+.fa-pinterest-p:before {
+ content: "\f231";
+}
+.fa-whatsapp:before {
+ content: "\f232";
+}
+.fa-server:before {
+ content: "\f233";
+}
+.fa-user-plus:before {
+ content: "\f234";
+}
+.fa-user-times:before {
+ content: "\f235";
+}
+.fa-hotel:before,
+.fa-bed:before {
+ content: "\f236";
+}
+.fa-viacoin:before {
+ content: "\f237";
+}
+.fa-train:before {
+ content: "\f238";
+}
+.fa-subway:before {
+ content: "\f239";
+}
+.fa-medium:before {
+ content: "\f23a";
+}
+.fa-yc:before,
+.fa-y-combinator:before {
+ content: "\f23b";
+}
+.fa-optin-monster:before {
+ content: "\f23c";
+}
+.fa-opencart:before {
+ content: "\f23d";
+}
+.fa-expeditedssl:before {
+ content: "\f23e";
+}
+.fa-battery-4:before,
+.fa-battery:before,
+.fa-battery-full:before {
+ content: "\f240";
+}
+.fa-battery-3:before,
+.fa-battery-three-quarters:before {
+ content: "\f241";
+}
+.fa-battery-2:before,
+.fa-battery-half:before {
+ content: "\f242";
+}
+.fa-battery-1:before,
+.fa-battery-quarter:before {
+ content: "\f243";
+}
+.fa-battery-0:before,
+.fa-battery-empty:before {
+ content: "\f244";
+}
+.fa-mouse-pointer:before {
+ content: "\f245";
+}
+.fa-i-cursor:before {
+ content: "\f246";
+}
+.fa-object-group:before {
+ content: "\f247";
+}
+.fa-object-ungroup:before {
+ content: "\f248";
+}
+.fa-sticky-note:before {
+ content: "\f249";
+}
+.fa-sticky-note-o:before {
+ content: "\f24a";
+}
+.fa-cc-jcb:before {
+ content: "\f24b";
+}
+.fa-cc-diners-club:before {
+ content: "\f24c";
+}
+.fa-clone:before {
+ content: "\f24d";
+}
+.fa-balance-scale:before {
+ content: "\f24e";
+}
+.fa-hourglass-o:before {
+ content: "\f250";
+}
+.fa-hourglass-1:before,
+.fa-hourglass-start:before {
+ content: "\f251";
+}
+.fa-hourglass-2:before,
+.fa-hourglass-half:before {
+ content: "\f252";
+}
+.fa-hourglass-3:before,
+.fa-hourglass-end:before {
+ content: "\f253";
+}
+.fa-hourglass:before {
+ content: "\f254";
+}
+.fa-hand-grab-o:before,
+.fa-hand-rock-o:before {
+ content: "\f255";
+}
+.fa-hand-stop-o:before,
+.fa-hand-paper-o:before {
+ content: "\f256";
+}
+.fa-hand-scissors-o:before {
+ content: "\f257";
+}
+.fa-hand-lizard-o:before {
+ content: "\f258";
+}
+.fa-hand-spock-o:before {
+ content: "\f259";
+}
+.fa-hand-pointer-o:before {
+ content: "\f25a";
+}
+.fa-hand-peace-o:before {
+ content: "\f25b";
+}
+.fa-trademark:before {
+ content: "\f25c";
+}
+.fa-registered:before {
+ content: "\f25d";
+}
+.fa-creative-commons:before {
+ content: "\f25e";
+}
+.fa-gg:before {
+ content: "\f260";
+}
+.fa-gg-circle:before {
+ content: "\f261";
+}
+.fa-tripadvisor:before {
+ content: "\f262";
+}
+.fa-odnoklassniki:before {
+ content: "\f263";
+}
+.fa-odnoklassniki-square:before {
+ content: "\f264";
+}
+.fa-get-pocket:before {
+ content: "\f265";
+}
+.fa-wikipedia-w:before {
+ content: "\f266";
+}
+.fa-safari:before {
+ content: "\f267";
+}
+.fa-chrome:before {
+ content: "\f268";
+}
+.fa-firefox:before {
+ content: "\f269";
+}
+.fa-opera:before {
+ content: "\f26a";
+}
+.fa-internet-explorer:before {
+ content: "\f26b";
+}
+.fa-tv:before,
+.fa-television:before {
+ content: "\f26c";
+}
+.fa-contao:before {
+ content: "\f26d";
+}
+.fa-500px:before {
+ content: "\f26e";
+}
+.fa-amazon:before {
+ content: "\f270";
+}
+.fa-calendar-plus-o:before {
+ content: "\f271";
+}
+.fa-calendar-minus-o:before {
+ content: "\f272";
+}
+.fa-calendar-times-o:before {
+ content: "\f273";
+}
+.fa-calendar-check-o:before {
+ content: "\f274";
+}
+.fa-industry:before {
+ content: "\f275";
+}
+.fa-map-pin:before {
+ content: "\f276";
+}
+.fa-map-signs:before {
+ content: "\f277";
+}
+.fa-map-o:before {
+ content: "\f278";
+}
+.fa-map:before {
+ content: "\f279";
+}
+.fa-commenting:before {
+ content: "\f27a";
+}
+.fa-commenting-o:before {
+ content: "\f27b";
+}
+.fa-houzz:before {
+ content: "\f27c";
+}
+.fa-vimeo:before {
+ content: "\f27d";
+}
+.fa-black-tie:before {
+ content: "\f27e";
+}
+.fa-fonticons:before {
+ content: "\f280";
+}
+.fa-reddit-alien:before {
+ content: "\f281";
+}
+.fa-edge:before {
+ content: "\f282";
+}
+.fa-credit-card-alt:before {
+ content: "\f283";
+}
+.fa-codiepie:before {
+ content: "\f284";
+}
+.fa-modx:before {
+ content: "\f285";
+}
+.fa-fort-awesome:before {
+ content: "\f286";
+}
+.fa-usb:before {
+ content: "\f287";
+}
+.fa-product-hunt:before {
+ content: "\f288";
+}
+.fa-mixcloud:before {
+ content: "\f289";
+}
+.fa-scribd:before {
+ content: "\f28a";
+}
+.fa-pause-circle:before {
+ content: "\f28b";
+}
+.fa-pause-circle-o:before {
+ content: "\f28c";
+}
+.fa-stop-circle:before {
+ content: "\f28d";
+}
+.fa-stop-circle-o:before {
+ content: "\f28e";
+}
+.fa-shopping-bag:before {
+ content: "\f290";
+}
+.fa-shopping-basket:before {
+ content: "\f291";
+}
+.fa-hashtag:before {
+ content: "\f292";
+}
+.fa-bluetooth:before {
+ content: "\f293";
+}
+.fa-bluetooth-b:before {
+ content: "\f294";
+}
+.fa-percent:before {
+ content: "\f295";
+}
+.fa-gitlab:before {
+ content: "\f296";
+}
+.fa-wpbeginner:before {
+ content: "\f297";
+}
+.fa-wpforms:before {
+ content: "\f298";
+}
+.fa-envira:before {
+ content: "\f299";
+}
+.fa-universal-access:before {
+ content: "\f29a";
+}
+.fa-wheelchair-alt:before {
+ content: "\f29b";
+}
+.fa-question-circle-o:before {
+ content: "\f29c";
+}
+.fa-blind:before {
+ content: "\f29d";
+}
+.fa-audio-description:before {
+ content: "\f29e";
+}
+.fa-volume-control-phone:before {
+ content: "\f2a0";
+}
+.fa-braille:before {
+ content: "\f2a1";
+}
+.fa-assistive-listening-systems:before {
+ content: "\f2a2";
+}
+.fa-asl-interpreting:before,
+.fa-american-sign-language-interpreting:before {
+ content: "\f2a3";
+}
+.fa-deafness:before,
+.fa-hard-of-hearing:before,
+.fa-deaf:before {
+ content: "\f2a4";
+}
+.fa-glide:before {
+ content: "\f2a5";
+}
+.fa-glide-g:before {
+ content: "\f2a6";
+}
+.fa-signing:before,
+.fa-sign-language:before {
+ content: "\f2a7";
+}
+.fa-low-vision:before {
+ content: "\f2a8";
+}
+.fa-viadeo:before {
+ content: "\f2a9";
+}
+.fa-viadeo-square:before {
+ content: "\f2aa";
+}
+.fa-snapchat:before {
+ content: "\f2ab";
+}
+.fa-snapchat-ghost:before {
+ content: "\f2ac";
+}
+.fa-snapchat-square:before {
+ content: "\f2ad";
+}
+.fa-pied-piper:before {
+ content: "\f2ae";
+}
+.fa-first-order:before {
+ content: "\f2b0";
+}
+.fa-yoast:before {
+ content: "\f2b1";
+}
+.fa-themeisle:before {
+ content: "\f2b2";
+}
+.fa-google-plus-circle:before,
+.fa-google-plus-official:before {
+ content: "\f2b3";
+}
+.fa-fa:before,
+.fa-font-awesome:before {
+ content: "\f2b4";
+}
+.fa-handshake-o:before {
+ content: "\f2b5";
+}
+.fa-envelope-open:before {
+ content: "\f2b6";
+}
+.fa-envelope-open-o:before {
+ content: "\f2b7";
+}
+.fa-linode:before {
+ content: "\f2b8";
+}
+.fa-address-book:before {
+ content: "\f2b9";
+}
+.fa-address-book-o:before {
+ content: "\f2ba";
+}
+.fa-vcard:before,
+.fa-address-card:before {
+ content: "\f2bb";
+}
+.fa-vcard-o:before,
+.fa-address-card-o:before {
+ content: "\f2bc";
+}
+.fa-user-circle:before {
+ content: "\f2bd";
+}
+.fa-user-circle-o:before {
+ content: "\f2be";
+}
+.fa-user-o:before {
+ content: "\f2c0";
+}
+.fa-id-badge:before {
+ content: "\f2c1";
+}
+.fa-drivers-license:before,
+.fa-id-card:before {
+ content: "\f2c2";
+}
+.fa-drivers-license-o:before,
+.fa-id-card-o:before {
+ content: "\f2c3";
+}
+.fa-quora:before {
+ content: "\f2c4";
+}
+.fa-free-code-camp:before {
+ content: "\f2c5";
+}
+.fa-telegram:before {
+ content: "\f2c6";
+}
+.fa-thermometer-4:before,
+.fa-thermometer:before,
+.fa-thermometer-full:before {
+ content: "\f2c7";
+}
+.fa-thermometer-3:before,
+.fa-thermometer-three-quarters:before {
+ content: "\f2c8";
+}
+.fa-thermometer-2:before,
+.fa-thermometer-half:before {
+ content: "\f2c9";
+}
+.fa-thermometer-1:before,
+.fa-thermometer-quarter:before {
+ content: "\f2ca";
+}
+.fa-thermometer-0:before,
+.fa-thermometer-empty:before {
+ content: "\f2cb";
+}
+.fa-shower:before {
+ content: "\f2cc";
+}
+.fa-bathtub:before,
+.fa-s15:before,
+.fa-bath:before {
+ content: "\f2cd";
+}
+.fa-podcast:before {
+ content: "\f2ce";
+}
+.fa-window-maximize:before {
+ content: "\f2d0";
+}
+.fa-window-minimize:before {
+ content: "\f2d1";
+}
+.fa-window-restore:before {
+ content: "\f2d2";
+}
+.fa-times-rectangle:before,
+.fa-window-close:before {
+ content: "\f2d3";
+}
+.fa-times-rectangle-o:before,
+.fa-window-close-o:before {
+ content: "\f2d4";
+}
+.fa-bandcamp:before {
+ content: "\f2d5";
+}
+.fa-grav:before {
+ content: "\f2d6";
+}
+.fa-etsy:before {
+ content: "\f2d7";
+}
+.fa-imdb:before {
+ content: "\f2d8";
+}
+.fa-ravelry:before {
+ content: "\f2d9";
+}
+.fa-eercast:before {
+ content: "\f2da";
+}
+.fa-microchip:before {
+ content: "\f2db";
+}
+.fa-snowflake-o:before {
+ content: "\f2dc";
+}
+.fa-superpowers:before {
+ content: "\f2dd";
+}
+.fa-wpexplorer:before {
+ content: "\f2de";
+}
+.fa-meetup:before {
+ content: "\f2e0";
+}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ border: 0;
+}
+.sr-only-focusable:active,
+.sr-only-focusable:focus {
+ position: static;
+ width: auto;
+ height: auto;
+ margin: 0;
+ overflow: visible;
+ clip: auto;
+}
+
+/* stylelint-enable */
\ No newline at end of file
diff --git a/frontend/src/Content/Fonts/fontawesome-webfont.eot b/frontend/src/Content/Fonts/fontawesome-webfont.eot
new file mode 100644
index 000000000..e9f60ca95
Binary files /dev/null and b/frontend/src/Content/Fonts/fontawesome-webfont.eot differ
diff --git a/frontend/src/Content/Fonts/fontawesome-webfont.svg b/frontend/src/Content/Fonts/fontawesome-webfont.svg
new file mode 100644
index 000000000..855c845e5
--- /dev/null
+++ b/frontend/src/Content/Fonts/fontawesome-webfont.svg
@@ -0,0 +1,2671 @@
+
+
+
+
+Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016
+ By ,,,
+Copyright Dave Gandy 2016. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/Content/Fonts/fontawesome-webfont.ttf b/frontend/src/Content/Fonts/fontawesome-webfont.ttf
new file mode 100644
index 000000000..35acda2fa
Binary files /dev/null and b/frontend/src/Content/Fonts/fontawesome-webfont.ttf differ
diff --git a/frontend/src/Content/Fonts/fontawesome-webfont.woff b/frontend/src/Content/Fonts/fontawesome-webfont.woff
new file mode 100644
index 000000000..400014a4b
Binary files /dev/null and b/frontend/src/Content/Fonts/fontawesome-webfont.woff differ
diff --git a/frontend/src/Content/Fonts/fontawesome-webfont.woff2 b/frontend/src/Content/Fonts/fontawesome-webfont.woff2
new file mode 100644
index 000000000..4d13fc604
Binary files /dev/null and b/frontend/src/Content/Fonts/fontawesome-webfont.woff2 differ
diff --git a/frontend/src/Content/Fonts/fonts.css b/frontend/src/Content/Fonts/fonts.css
new file mode 100644
index 000000000..483c61ca8
--- /dev/null
+++ b/frontend/src/Content/Fonts/fonts.css
@@ -0,0 +1,27 @@
+@font-face {
+ font-weight: 300;
+ font-style: normal;
+ font-family: 'Roboto';
+ src: url('Roboto-Light.woff2?v=1.1.0') format('woff2'), url('Roboto-Light.woff?v=1.2.0') format('woff'), url('Roboto-Light.ttf?v=1.1.0') format('truetype');
+}
+
+@font-face {
+ font-weight: 400;
+ font-style: normal;
+ font-family: 'Roboto';
+ src: url('Roboto-Regular.woff2?v=1.2.0') format('woff2'), url('Roboto-Regular.woff?v=1.2.0') format('woff'), url('Roboto-Regular.ttf?v=1.1.0') format('truetype');
+}
+
+@font-face {
+ font-weight: normal;
+ font-style: normal;
+ font-family: 'Roboto';
+ src: url('Roboto-Regular.woff2?v=1.3.0') format('woff2'), url('Roboto-Regular.woff?v=1.2.0') format('woff'), url('Roboto-Regular.ttf?v=1.1.0') format('truetype');
+}
+
+@font-face {
+ font-weight: 400;
+ font-style: normal;
+ font-family: 'Ubuntu Mono';
+ src: url('UbuntuMono-Regular.eot?#iefix') format('embedded-opentype'), url('UbuntuMono-Regular.woff') format('woff'), url('UbuntuMono-Regular.ttf') format('truetype');
+}
diff --git a/src/UI/Content/Images/404.png b/frontend/src/Content/Images/404.png
similarity index 100%
rename from src/UI/Content/Images/404.png
rename to frontend/src/Content/Images/404.png
diff --git a/frontend/src/Content/Images/Icons/android-chrome-192x192.png b/frontend/src/Content/Images/Icons/android-chrome-192x192.png
new file mode 100644
index 000000000..d1485d19d
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..13fd218ba
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..3c05d62e5
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..48028037e
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..9f8422fa4
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..b853a9ef9
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..787fd2b8f
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug-32x32.png differ
diff --git a/frontend/src/Content/Images/Icons/favicon-debug.ico b/frontend/src/Content/Images/Icons/favicon-debug.ico
new file mode 100644
index 000000000..726e812c6
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon-debug.ico differ
diff --git a/frontend/src/Content/Images/Icons/favicon.ico b/frontend/src/Content/Images/Icons/favicon.ico
new file mode 100644
index 000000000..1b0de8423
Binary files /dev/null and b/frontend/src/Content/Images/Icons/favicon.ico differ
diff --git a/frontend/src/Content/Images/Icons/manifest.json b/frontend/src/Content/Images/Icons/manifest.json
new file mode 100644
index 000000000..d14732f60
--- /dev/null
+++ b/frontend/src/Content/Images/Icons/manifest.json
@@ -0,0 +1,18 @@
+{
+ "name": "",
+ "icons": [
+ {
+ "src": "/Content/Images/Icons/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/Content/Images/Icons/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#3a3f51",
+ "background_color": "#3a3f51",
+ "display": "standalone"
+}
\ No newline at end of file
diff --git a/frontend/src/Content/Images/Icons/mstile-144x144.png b/frontend/src/Content/Images/Icons/mstile-144x144.png
new file mode 100644
index 000000000..fdabc8bb7
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..ebf8d9217
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..f670c85df
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..714b3ab7c
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..65cbdb737
Binary files /dev/null and b/frontend/src/Content/Images/Icons/mstile-70x70.png differ
diff --git a/frontend/src/Content/Images/Icons/safari-pinned-tab.svg b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg
new file mode 100644
index 000000000..6fc7fb969
--- /dev/null
+++ b/frontend/src/Content/Images/Icons/safari-pinned-tab.svg
@@ -0,0 +1,38 @@
+
+
+
+
+Created by potrace 1.11, written by Peter Selinger 2001-2013
+
+
+
+
+
diff --git a/frontend/src/Content/Images/logo.svg b/frontend/src/Content/Images/logo.svg
new file mode 100644
index 000000000..ebebe49a9
--- /dev/null
+++ b/frontend/src/Content/Images/logo.svg
@@ -0,0 +1 @@
+ background Layer 1
\ No newline at end of file
diff --git a/frontend/src/Content/Images/poster-dark-square.png b/frontend/src/Content/Images/poster-dark-square.png
new file mode 100644
index 000000000..2d7608f3d
Binary files /dev/null and b/frontend/src/Content/Images/poster-dark-square.png differ
diff --git a/src/UI/Content/Images/poster-dark.png b/frontend/src/Content/Images/poster-dark.png
similarity index 100%
rename from src/UI/Content/Images/poster-dark.png
rename to frontend/src/Content/Images/poster-dark.png
diff --git a/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js
new file mode 100644
index 000000000..11cca7d1b
--- /dev/null
+++ b/frontend/src/Helpers/Props/Shapes/createRouteMatchShape.js
@@ -0,0 +1,11 @@
+import PropTypes from 'prop-types';
+
+function createRouteMatchShape(props) {
+ return PropTypes.shape({
+ params: PropTypes.shape({
+ ...props
+ }).isRequired
+ });
+}
+
+export default createRouteMatchShape;
diff --git a/frontend/src/Helpers/Props/Shapes/locationShape.js b/frontend/src/Helpers/Props/Shapes/locationShape.js
new file mode 100644
index 000000000..80b53eb44
--- /dev/null
+++ b/frontend/src/Helpers/Props/Shapes/locationShape.js
@@ -0,0 +1,11 @@
+import PropTypes from 'prop-types';
+
+const locationShape = PropTypes.shape({
+ pathname: PropTypes.string.isRequired,
+ search: PropTypes.string.isRequired,
+ state: PropTypes.object,
+ action: PropTypes.string,
+ key: PropTypes.string
+});
+
+export default locationShape;
diff --git a/frontend/src/Helpers/Props/Shapes/settingShape.js b/frontend/src/Helpers/Props/Shapes/settingShape.js
new file mode 100644
index 000000000..cd672de27
--- /dev/null
+++ b/frontend/src/Helpers/Props/Shapes/settingShape.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+
+const settingShape = {
+ value: PropTypes.oneOf([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ warnings: PropTypes.arrayOf(PropTypes.string).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.string).isRequired
+};
+
+export const arraySettingShape = {
+ ...settingShape,
+ value: PropTypes.array.isRequired
+};
+
+export const boolSettingShape = {
+ ...settingShape,
+ value: PropTypes.bool.isRequired
+};
+
+export const numberSettingShape = {
+ ...settingShape,
+ value: PropTypes.number.isRequired
+};
+
+export const stringSettingShape = {
+ ...settingShape,
+ value: PropTypes.string
+};
+
+export const tagSettingShape = {
+ ...settingShape,
+ value: PropTypes.arrayOf(PropTypes.number).isRequired
+};
+
+export default settingShape;
diff --git a/frontend/src/Helpers/Props/align.js b/frontend/src/Helpers/Props/align.js
new file mode 100644
index 000000000..f381959c6
--- /dev/null
+++ b/frontend/src/Helpers/Props/align.js
@@ -0,0 +1,5 @@
+export const LEFT = 'left';
+export const CENTER = 'center';
+export const RIGHT = 'right';
+
+export const all = [LEFT, CENTER, RIGHT];
diff --git a/frontend/src/Helpers/Props/filterTypes.js b/frontend/src/Helpers/Props/filterTypes.js
new file mode 100644
index 000000000..68663ebfc
--- /dev/null
+++ b/frontend/src/Helpers/Props/filterTypes.js
@@ -0,0 +1,17 @@
+export const CONTAINS = 'contains';
+export const EQUAL = 'equal';
+export const GREATER_THAN = 'greaterThan';
+export const GREATER_THAN_OR_EQUAL = 'greaterThanOrEqual';
+export const LESS_THAN = 'lessThan';
+export const LESS_THAN_OR_EQUAL = 'lessThanOrEqual';
+export const NOT_EQUAL = 'notEqual';
+
+export const all = [
+ CONTAINS,
+ EQUAL,
+ GREATER_THAN,
+ GREATER_THAN_OR_EQUAL,
+ LESS_THAN,
+ LESS_THAN_OR_EQUAL,
+ NOT_EQUAL
+];
diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js
new file mode 100644
index 000000000..65686cc8d
--- /dev/null
+++ b/frontend/src/Helpers/Props/icons.js
@@ -0,0 +1,90 @@
+export const ACTIONS = 'fa fa-bolt';
+export const ACTIVITY = 'fa fa-clock-o';
+export const ADD = 'fa fa-plus';
+export const ALTERNATE_TITLES = 'fa fa-clone';
+export const ADVANCED_SETTINGS = 'fa fa-cog';
+export const ARROW_LEFT = 'fa fa-arrow-circle-left';
+export const ARROW_RIGHT = 'fa fa-arrow-circle-right';
+export const BACKUP = 'fa fa-file-archive-o';
+export const BUG = 'fa fa-bug';
+export const CALENDAR = 'fa fa-calendar';
+export const CALENDAR_O = 'fa fa-calendar-o';
+export const CARET_DOWN = 'fa fa-caret-down';
+export const CHECK = 'fa fa-check';
+export const CHECK_INDETERMINATE = 'fa fa-minus';
+export const CHECK_CIRCLE = 'fa fa-check-circle';
+export const CIRCLE = 'fa fa-circle';
+export const CIRCLE_OUTLINE = 'fa fa-circle-o';
+export const CLEAR = 'fa fa-trash';
+export const CLIPBOARD = 'fa fa-clipboard';
+export const CLOSE = 'fa fa-times';
+export const COLLAPSE = 'fa fa-chevron-circle-up';
+export const COMPUTER = 'fa fa-desktop';
+export const DANGER = 'fa fa-exclamation-circle';
+export const DELETE = 'fa fa-trash';
+export const DOWNLOAD = 'fa fa-download';
+export const DOWNLOADED = 'fa fa-inbox';
+export const DOWNLOADING = 'fa fa-cloud-download';
+export const DRIVE = 'fa fa-hdd-o';
+export const EDIT = 'fa fa-wrench';
+export const EPISODE_FILE = 'fa fa-file-video-o';
+export const EXPAND = 'fa fa-chevron-circle-down';
+export const EXPAND_INDETERMINATE = 'fa fa-chevron-circle-right';
+export const EXTERNAL_LINK = 'fa fa-external-link';
+export const FATAL = 'fa fa-times-circle';
+export const FILE = 'fa fa-file-o';
+export const FILTER = 'fa fa-filter';
+export const FOLDER = 'fa fa-folder-o';
+export const FOLDER_OPEN = 'fa fa-folder-open';
+export const HEALTH = 'fa fa-medkit';
+export const HEART = 'fa fa-heart';
+export const HOUSEKEEPING = 'fa fa-home';
+export const INFO = 'fa fa-info-circle';
+export const INTERACTIVE = 'fa fa-user';
+export const LOGOUT = 'fa fa-sign-out';
+export const MISSING = 'fa fa-exclamation-triangle';
+export const MONITORED = 'fa fa-bookmark';
+export const NETWORK = 'fa fa-signal';
+export const NAVBAR_COLLAPSE = 'fa fa-bars';
+export const NOT_AIRED = 'fa fa-clock-o';
+export const ORGANIZE = 'fa fa-sitemap';
+export const OVERFLOW = 'fa fa-ellipsis-h';
+export const OVERVIEW = 'fa fa-th-list';
+export const PAGE_FIRST = 'fa fa-fast-backward';
+export const PAGE_PREVIOUS = 'fa fa-backward';
+export const PAGE_NEXT = 'fa fa-forward';
+export const PAGE_LAST = 'fa fa-fast-forward';
+export const PARENT = 'fa fa-level-up';
+export const PAUSED = 'fa fa-pause';
+export const PENDING = 'fa fa-clock-o';
+export const PROFILE = 'fa fa-user';
+export const POSTER = 'fa fa-th';
+export const QUEUED = 'fa fa-cloud';
+export const QUICK = 'fa fa-rocket';
+export const REFRESH = 'fa fa-refresh';
+export const REMOVE = 'fa fa-remove';
+export const RESTART = 'fa fa-repeat';
+export const REORDER = 'fa fa-bars';
+export const RSS = 'fa fa-rss';
+export const SAVE = 'fa fa-floppy-o';
+export const SCHEDULED = 'fa fa-clock-o';
+export const SEARCH = 'fa fa-search';
+export const ARTIST_CONTINUING = 'fa fa-play';
+export const ARTIST_ENDED = 'fa fa-stop';
+export const SETTINGS = 'fa fa-cogs';
+export const SHUTDOWN = 'fa fa-power-off';
+export const SORT = 'fa fa-sort';
+export const SORT_ASCENDING = 'fa fa-sort-asc';
+export const SORT_DESCENDING = 'fa fa-sort-desc';
+export const SPIN = 'fa-spin';
+export const SPINNER = 'fa fa-spinner';
+export const SUBTRACT = 'fa fa-minus';
+export const SYSTEM = 'fa fa-laptop';
+export const TAGS = 'fa fa-tags';
+export const TBA = 'fa fa-question-circle';
+export const UNKNOWN = 'fa fa-question';
+export const UNMONITORED = 'fa fa-bookmark-o';
+export const UPDATE = 'fa fa-retweet';
+export const UNSAVED_SETTING = 'fa fa-dot-circle-o';
+export const VIEW = 'fa fa-eye';
+export const WARNING = 'fa fa-exclamation-triangle';
diff --git a/frontend/src/Helpers/Props/index.js b/frontend/src/Helpers/Props/index.js
new file mode 100644
index 000000000..0a989a26f
--- /dev/null
+++ b/frontend/src/Helpers/Props/index.js
@@ -0,0 +1,23 @@
+import * as align from './align';
+import * as inputTypes from './inputTypes';
+import * as filterTypes from './filterTypes';
+import * as icons from './icons';
+import * as kinds from './kinds';
+import * as messageTypes from './messageTypes';
+import * as sizes from './sizes';
+import * as scrollDirections from './scrollDirections';
+import * as sortDirections from './sortDirections';
+import * as tooltipPositions from './tooltipPositions';
+
+export {
+ align,
+ inputTypes,
+ filterTypes,
+ icons,
+ kinds,
+ messageTypes,
+ sizes,
+ scrollDirections,
+ sortDirections,
+ tooltipPositions
+};
diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js
new file mode 100644
index 000000000..124f5b88b
--- /dev/null
+++ b/frontend/src/Helpers/Props/inputTypes.js
@@ -0,0 +1,33 @@
+export const CAPTCHA = 'captcha';
+export const CHECK = 'check';
+export const MONITOR_ALBUMS_SELECT = 'monitorAlbumsSelect';
+export const NUMBER = 'number';
+export const OAUTH = 'oauth';
+export const PASSWORD = 'password';
+export const PATH = 'path';
+export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect';
+export const LANGUAGE_PROFILE_SELECT = 'languageProfileSelect';
+export const ROOT_FOLDER_SELECT = 'rootFolderSelect';
+export const SELECT = 'select';
+export const SERIES_TYPE_SELECT = 'artistTypeSelect';
+export const TAG = 'tag';
+export const TEXT = 'text';
+export const TEXT_TAG = 'textTag';
+
+export const all = [
+ CAPTCHA,
+ CHECK,
+ MONITOR_ALBUMS_SELECT,
+ NUMBER,
+ OAUTH,
+ PASSWORD,
+ PATH,
+ QUALITY_PROFILE_SELECT,
+ LANGUAGE_PROFILE_SELECT,
+ ROOT_FOLDER_SELECT,
+ SELECT,
+ SERIES_TYPE_SELECT,
+ TAG,
+ TEXT,
+ TEXT_TAG
+];
diff --git a/frontend/src/Helpers/Props/kinds.js b/frontend/src/Helpers/Props/kinds.js
new file mode 100644
index 000000000..cb2d5fabe
--- /dev/null
+++ b/frontend/src/Helpers/Props/kinds.js
@@ -0,0 +1,19 @@
+export const DANGER = 'danger';
+export const DEFAULT = 'default';
+export const INFO = 'info';
+export const INVERSE = 'inverse';
+export const PRIMARY = 'primary';
+export const PURPLE = 'purple';
+export const SUCCESS = 'success';
+export const WARNING = 'warning';
+
+export const all = [
+ DANGER,
+ DEFAULT,
+ INFO,
+ INVERSE,
+ PRIMARY,
+ PURPLE,
+ SUCCESS,
+ WARNING
+];
diff --git a/frontend/src/Helpers/Props/messageTypes.js b/frontend/src/Helpers/Props/messageTypes.js
new file mode 100644
index 000000000..997354f9d
--- /dev/null
+++ b/frontend/src/Helpers/Props/messageTypes.js
@@ -0,0 +1,11 @@
+export const ERROR = 'error';
+export const INFO = 'info';
+export const SUCCESS = 'success';
+export const WARNING = 'warning';
+
+export const all = [
+ ERROR,
+ INFO,
+ SUCCESS,
+ WARNING
+];
diff --git a/frontend/src/Helpers/Props/scrollDirections.js b/frontend/src/Helpers/Props/scrollDirections.js
new file mode 100644
index 000000000..5e4a4fe08
--- /dev/null
+++ b/frontend/src/Helpers/Props/scrollDirections.js
@@ -0,0 +1,5 @@
+export const NONE = 'none';
+export const HORIZONTAL = 'horizontal';
+export const VERTICAL = 'vertical';
+
+export const all = [NONE, HORIZONTAL, VERTICAL];
diff --git a/frontend/src/Helpers/Props/sizes.js b/frontend/src/Helpers/Props/sizes.js
new file mode 100644
index 000000000..e572e8fc8
--- /dev/null
+++ b/frontend/src/Helpers/Props/sizes.js
@@ -0,0 +1,5 @@
+export const SMALL = 'small';
+export const MEDIUM = 'medium';
+export const LARGE = 'large';
+
+export const all = [SMALL, MEDIUM, LARGE];
diff --git a/frontend/src/Helpers/Props/sortDirections.js b/frontend/src/Helpers/Props/sortDirections.js
new file mode 100644
index 000000000..ff3b17bb6
--- /dev/null
+++ b/frontend/src/Helpers/Props/sortDirections.js
@@ -0,0 +1,4 @@
+export const ASCENDING = 'ascending';
+export const DESCENDING = 'descending';
+
+export const all = [ASCENDING, DESCENDING];
diff --git a/frontend/src/Helpers/Props/tooltipPositions.js b/frontend/src/Helpers/Props/tooltipPositions.js
new file mode 100644
index 000000000..bca3c4ed4
--- /dev/null
+++ b/frontend/src/Helpers/Props/tooltipPositions.js
@@ -0,0 +1,11 @@
+export const TOP = 'top';
+export const RIGHT = 'right';
+export const BOTTOM = 'bottom';
+export const LEFT = 'left';
+
+export const all = [
+ TOP,
+ RIGHT,
+ BOTTOM,
+ LEFT
+];
diff --git a/frontend/src/Helpers/dragTypes.js b/frontend/src/Helpers/dragTypes.js
new file mode 100644
index 000000000..ed6ba080d
--- /dev/null
+++ b/frontend/src/Helpers/dragTypes.js
@@ -0,0 +1,3 @@
+export const QUALITY_PROFILE_ITEM = 'qualityProfileItem';
+export const DELAY_PROFILE = 'delayProfile';
+export const TABLE_COLUMN = 'tableColumn';
diff --git a/frontend/src/Helpers/elementChildren.js b/frontend/src/Helpers/elementChildren.js
new file mode 100644
index 000000000..1c10b2f0e
--- /dev/null
+++ b/frontend/src/Helpers/elementChildren.js
@@ -0,0 +1,149 @@
+// https://github.com/react-bootstrap/react-element-children
+
+import React from 'react';
+
+/**
+ * Iterates through children that are typically specified as `props.children`,
+ * but only maps over children that are "valid components".
+ *
+ * The mapFunction provided index will be normalised to the components mapped,
+ * so an invalid component would not increase the index.
+ *
+ * @param {?*} children Children tree container.
+ * @param {function(*, int)} func.
+ * @param {*} context Context for func.
+ * @return {object} Object containing the ordered map of results.
+ */
+export function map(children, func, context) {
+ let index = 0;
+
+ return React.Children.map(children, (child) => {
+ if (!React.isValidElement(child)) {
+ return child;
+ }
+
+ return func.call(context, child, index++);
+ });
+}
+
+/**
+ * Iterates through children that are "valid components".
+ *
+ * The provided forEachFunc(child, index) will be called for each
+ * leaf child with the index reflecting the position relative to "valid components".
+ *
+ * @param {?*} children Children tree container.
+ * @param {function(*, int)} func.
+ * @param {*} context Context for context.
+ */
+export function forEach(children, func, context) {
+ let index = 0;
+
+ React.Children.forEach(children, (child) => {
+ if (!React.isValidElement(child)) {
+ return;
+ }
+
+ func.call(context, child, index++);
+ });
+}
+
+/**
+ * Count the number of "valid components" in the Children container.
+ *
+ * @param {?*} children Children tree container.
+ * @returns {number}
+ */
+export function count(children) {
+ let result = 0;
+
+ React.Children.forEach(children, (child) => {
+ if (!React.isValidElement(child)) {
+ return;
+ }
+
+ ++result;
+ });
+
+ return result;
+}
+
+/**
+ * Finds children that are typically specified as `props.children`,
+ * but only iterates over children that are "valid components".
+ *
+ * The provided forEachFunc(child, index) will be called for each
+ * leaf child with the index reflecting the position relative to "valid components".
+ *
+ * @param {?*} children Children tree container.
+ * @param {function(*, int)} func.
+ * @param {*} context Context for func.
+ * @returns {array} of children that meet the func return statement
+ */
+export function filter(children, func, context) {
+ const result = [];
+
+ forEach(children, (child, index) => {
+ if (func.call(context, child, index)) {
+ result.push(child);
+ }
+ });
+
+ return result;
+}
+
+export function find(children, func, context) {
+ let result = null;
+
+ forEach(children, (child, index) => {
+ if (result) {
+ return;
+ }
+ if (func.call(context, child, index)) {
+ result = child;
+ }
+ });
+
+ return result;
+}
+
+export function every(children, func, context) {
+ let result = true;
+
+ forEach(children, (child, index) => {
+ if (!result) {
+ return;
+ }
+ if (!func.call(context, child, index)) {
+ result = false;
+ }
+ });
+
+ return result;
+}
+
+export function some(children, func, context) {
+ let result = false;
+
+ forEach(children, (child, index) => {
+ if (result) {
+ return;
+ }
+
+ if (func.call(context, child, index)) {
+ result = true;
+ }
+ });
+
+ return result;
+}
+
+export function toArray(children) {
+ const result = [];
+
+ forEach(children, (child) => {
+ result.push(child);
+ });
+
+ return result;
+}
diff --git a/frontend/src/Helpers/getDisplayName.js b/frontend/src/Helpers/getDisplayName.js
new file mode 100644
index 000000000..512702c87
--- /dev/null
+++ b/frontend/src/Helpers/getDisplayName.js
@@ -0,0 +1,3 @@
+export default function getDisplayName(Component) {
+ return Component.displayName || Component.name || 'Component';
+}
diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModal.js b/frontend/src/InteractiveImport/Album/SelectAlbumModal.js
new file mode 100644
index 000000000..d4f26f4ff
--- /dev/null
+++ b/frontend/src/InteractiveImport/Album/SelectAlbumModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectAlbumModalContentConnector from './SelectAlbumModalContentConnector';
+
+class SelectAlbumModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectAlbumModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectAlbumModal;
diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js b/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js
new file mode 100644
index 000000000..c10abb588
--- /dev/null
+++ b/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js
@@ -0,0 +1,66 @@
+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 LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import SelectAlbumRow from './SelectAlbumRow';
+
+class SelectAlbumModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onAlbumSelect,
+ onModalClose,
+ isFetching
+ } = this.props;
+
+ return (
+
+
+ Manual Import - Select Album
+
+
+
+ {
+ isFetching &&
+
+ }
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+ Cancel
+
+
+
+ );
+ }
+}
+
+SelectAlbumModalContent.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ onAlbumSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectAlbumModalContent;
diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js
new file mode 100644
index 000000000..8694a5485
--- /dev/null
+++ b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js
@@ -0,0 +1,101 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import {
+ updateInteractiveImportItem,
+ fetchInteractiveImportAlbums,
+ setInteractiveImportAlbumsSort,
+ clearInteractiveImportAlbums
+} from 'Store/Actions/interactiveImportActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import SelectAlbumModalContent from './SelectAlbumModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector(),
+ (episodes) => {
+ return episodes;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchInteractiveImportAlbums,
+ setInteractiveImportAlbumsSort,
+ clearInteractiveImportAlbums,
+ updateInteractiveImportItem
+};
+
+class SelectAlbumModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ artistId
+ } = this.props;
+
+ this.props.fetchInteractiveImportAlbums({ artistId });
+ }
+
+ componentWillUnmount() {
+ // This clears the albums for the queue and hides the queue
+ // We'll need another place to store albums for manual import
+ this.props.clearInteractiveImportAlbums();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey, sortDirection) => {
+ this.props.setInteractiveImportAlbumsSort({ sortKey, sortDirection });
+ }
+
+ onAlbumSelect = (albumId) => {
+ const album = _.find(this.props.items, { id: albumId });
+
+ this.props.ids.forEach((id) => {
+ this.props.updateInteractiveImportItem({
+ id,
+ album,
+ tracks: []
+ });
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectAlbumModalContentConnector.propTypes = {
+ ids: PropTypes.arrayOf(PropTypes.number).isRequired,
+ artistId: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchInteractiveImportAlbums: PropTypes.func.isRequired,
+ setInteractiveImportAlbumsSort: PropTypes.func.isRequired,
+ clearInteractiveImportAlbums: PropTypes.func.isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'interactiveImport.interactiveImportAlbums' }
+)(SelectAlbumModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumRow.css b/frontend/src/InteractiveImport/Album/SelectAlbumRow.css
new file mode 100644
index 000000000..c43d879f4
--- /dev/null
+++ b/frontend/src/InteractiveImport/Album/SelectAlbumRow.css
@@ -0,0 +1,4 @@
+.season {
+ padding: 8px;
+ border-bottom: 1px solid $borderColor;
+}
diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumRow.js b/frontend/src/InteractiveImport/Album/SelectAlbumRow.js
new file mode 100644
index 000000000..eb163a7c2
--- /dev/null
+++ b/frontend/src/InteractiveImport/Album/SelectAlbumRow.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './SelectAlbumRow.css';
+
+class SelectAlbumRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onAlbumSelect(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ {this.props.title}
+
+ );
+ }
+}
+
+SelectAlbumRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ onAlbumSelect: PropTypes.func.isRequired
+};
+
+export default SelectAlbumRow;
diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistModal.js b/frontend/src/InteractiveImport/Artist/SelectArtistModal.js
new file mode 100644
index 000000000..39dd67300
--- /dev/null
+++ b/frontend/src/InteractiveImport/Artist/SelectArtistModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectArtistModalContentConnector from './SelectArtistModalContentConnector';
+
+class SelectArtistModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectArtistModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectArtistModal;
diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.css b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.css
new file mode 100644
index 000000000..d297be072
--- /dev/null
+++ b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.css
@@ -0,0 +1,18 @@
+.modalBody {
+ composes: modalBody from 'Components/Modal/ModalBody.css';
+
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+}
+
+.filterInput {
+ composes: text from 'Components/Form/TextInput.css';
+
+ flex: 0 0 auto;
+ margin-bottom: 20px;
+}
+
+.scroller {
+ flex: 1 1 auto;
+}
diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.js b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.js
new file mode 100644
index 000000000..b180d319b
--- /dev/null
+++ b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.js
@@ -0,0 +1,99 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { scrollDirections } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Scroller from 'Components/Scroller/Scroller';
+import TextInput from 'Components/Form/TextInput';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import SelectArtistRow from './SelectArtistRow';
+import styles from './SelectArtistModalContent.css';
+
+class SelectArtistModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ filter: ''
+ };
+ }
+
+ //
+ // Listeners
+
+ onFilterChange = ({ value }) => {
+ this.setState({ filter: value.toLowerCase() });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onArtistSelect,
+ onModalClose
+ } = this.props;
+
+ const filter = this.state.filter;
+
+ return (
+
+
+ Manual Import - Select Artist
+
+
+
+
+
+
+ {
+ items.map((item) => {
+ return item.artistName.toLowerCase().includes(filter) ?
+ (
+
+ ) :
+ null;
+ })
+ }
+
+
+
+
+
+ Cancel
+
+
+
+ );
+ }
+}
+
+SelectArtistModalContent.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onArtistSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectArtistModalContent;
diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistModalContentConnector.js b/frontend/src/InteractiveImport/Artist/SelectArtistModalContentConnector.js
new file mode 100644
index 000000000..78c5631f0
--- /dev/null
+++ b/frontend/src/InteractiveImport/Artist/SelectArtistModalContentConnector.js
@@ -0,0 +1,65 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
+import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
+import SelectArtistModalContent from './SelectArtistModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createAllArtistSelector(),
+ (items) => {
+ return {
+ items
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ updateInteractiveImportItem
+};
+
+class SelectArtistModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onArtistSelect = (artistId) => {
+ const artist = _.find(this.props.items, { id: artistId });
+
+ this.props.ids.forEach((id) => {
+ this.props.updateInteractiveImportItem({
+ id,
+ artist,
+ album: undefined,
+ tracks: []
+ });
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectArtistModalContentConnector.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)(SelectArtistModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistRow.css b/frontend/src/InteractiveImport/Artist/SelectArtistRow.css
new file mode 100644
index 000000000..376c3fe84
--- /dev/null
+++ b/frontend/src/InteractiveImport/Artist/SelectArtistRow.css
@@ -0,0 +1,4 @@
+.artist {
+ padding: 8px;
+ border-bottom: 1px solid $borderColor;
+}
diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistRow.js b/frontend/src/InteractiveImport/Artist/SelectArtistRow.js
new file mode 100644
index 000000000..dcf252bb6
--- /dev/null
+++ b/frontend/src/InteractiveImport/Artist/SelectArtistRow.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import styles from './SelectArtistRow.css';
+
+class SelectArtistRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onArtistSelect(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ {this.props.artistName}
+
+ );
+ }
+}
+
+SelectArtistRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ artistName: PropTypes.string.isRequired,
+ onArtistSelect: PropTypes.func.isRequired
+};
+
+export default SelectArtistRow;
diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css
new file mode 100644
index 000000000..86418a2dd
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.css
@@ -0,0 +1,24 @@
+.recentFoldersContainer {
+ margin-top: 15px;
+}
+
+.buttonsContainer {
+ margin-top: 30px;
+}
+
+.buttonContainer {
+ display: flex;
+ justify-content: center;
+
+ margin-top: 10px;
+}
+
+.button {
+ composes: button from 'Components/Link/Button.css';
+
+ width: 300px;
+}
+
+.buttonIcon {
+ margin-right: 5px;
+}
diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js
new file mode 100644
index 000000000..cabd33d7c
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContent.js
@@ -0,0 +1,161 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import PathInputConnector from 'Components/Form/PathInputConnector';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import RecentFolderRow from './RecentFolderRow';
+import styles from './InteractiveImportSelectFolderModalContent.css';
+
+const recentFoldersColumns = [
+ {
+ name: 'folder',
+ label: 'Folder'
+ },
+ {
+ name: 'lastUsed',
+ label: 'Last Used'
+ }
+];
+
+class InteractiveImportSelectFolderModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ folder: ''
+ };
+ }
+
+ //
+ // Listeners
+
+ onPathChange = ({ value }) => {
+ this.setState({ folder: value });
+ }
+
+ onRecentPathPress = (folder) => {
+ this.setState({ folder });
+ }
+
+ onQuickImportPress = () => {
+ this.props.onQuickImportPress(this.state.folder);
+ }
+
+ onInteractiveImportPress = () => {
+ this.props.onInteractiveImportPress(this.state.folder);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ recentFolders,
+ onModalClose
+ } = this.props;
+
+ const folder = this.state.folder;
+
+ return (
+
+
+ Manual Import - Select Folder
+
+
+
+
+
+ {
+ !!recentFolders.length &&
+
+
+
+ {
+ recentFolders.map((recentFolder) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+
+
+
+
+ Quick Import
+
+
+
+
+
+
+
+ Interactive Import
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ );
+ }
+}
+
+InteractiveImportSelectFolderModalContent.propTypes = {
+ recentFolders: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onQuickImportPress: PropTypes.func.isRequired,
+ onInteractiveImportPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default InteractiveImportSelectFolderModalContent;
diff --git a/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js
new file mode 100644
index 000000000..c838f3bab
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/InteractiveImportSelectFolderModalContentConnector.js
@@ -0,0 +1,73 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { addRecentFolder } from 'Store/Actions/interactiveImportActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import InteractiveImportSelectFolderModalContent from './InteractiveImportSelectFolderModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.interactiveImport.recentFolders,
+ (recentFolders) => {
+ return {
+ recentFolders
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ addRecentFolder,
+ executeCommand
+};
+
+class InteractiveImportSelectFolderModalContentConnector extends Component {
+
+ //
+ // Listeners
+
+ onQuickImportPress = (folder) => {
+ this.props.addRecentFolder({ folder });
+
+ this.props.executeCommand({
+ name: commandNames.DOWNLOADED_ALBUMS_SCAN,
+ path: folder
+ });
+
+ this.props.onModalClose();
+ }
+
+ onInteractiveImportPress = (folder) => {
+ this.props.addRecentFolder({ folder });
+ this.props.onFolderSelect(folder);
+ }
+
+ //
+ // Render
+
+ render() {
+ if (this.path) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+InteractiveImportSelectFolderModalContentConnector.propTypes = {
+ path: PropTypes.string,
+ onFolderSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ addRecentFolder: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(InteractiveImportSelectFolderModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Folder/RecentFolderRow.js b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js
new file mode 100644
index 000000000..bc32f5749
--- /dev/null
+++ b/frontend/src/InteractiveImport/Folder/RecentFolderRow.js
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import TableRowButton from 'Components/Table/TableRowButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+
+class RecentFolderRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.props.onPress(this.props.folder);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ folder,
+ lastUsed
+ } = this.props;
+
+ return (
+
+ {folder}
+
+
+
+ );
+ }
+}
+
+RecentFolderRow.propTypes = {
+ folder: PropTypes.string.isRequired,
+ lastUsed: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default RecentFolderRow;
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css
new file mode 100644
index 000000000..f85538fdb
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css
@@ -0,0 +1,63 @@
+.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..7c44f9963
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js
@@ -0,0 +1,326 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { icons, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Icon from 'Components/Icon';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import SelectInput from 'Components/Form/SelectInput';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal';
+import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal';
+import InteractiveImportRow from './InteractiveImportRow';
+import styles from './InteractiveImportModalContent.css';
+
+const columns = [
+ {
+ name: 'relativePath',
+ label: 'Relative Path',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'artist',
+ label: 'Artist',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'album',
+ label: 'Album',
+ isVisible: true
+ },
+ {
+ name: 'tracks',
+ label: 'Track(s)',
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: '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
+ }
+];
+
+class InteractiveImportModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ invalidRowsSelected: [],
+ isSelectArtistModalOpen: false,
+ isSelectAlbumModalOpen: false
+ };
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onValidRowChange = (id, isValid) => {
+ this.setState((state) => {
+ if (isValid) {
+ return {
+ invalidRowsSelected: _.without(state.invalidRowsSelected, id)
+ };
+ }
+
+ return {
+ invalidRowsSelected: [...state.invalidRowsSelected, id]
+ };
+ });
+ }
+
+ onImportSelectedPress = () => {
+ const selected = this.getSelectedIds();
+
+ this.props.onImportSelectedPress(selected, this.state.importMode);
+ }
+
+ onImportModeChange = ({ value }) => {
+ this.props.onImportModeChange(value);
+ }
+
+ onSelectArtistPress = () => {
+ this.setState({ isSelectArtistModalOpen: true });
+ }
+
+ onSelectAlbumPress = () => {
+ this.setState({ isSelectAlbumModalOpen: true });
+ }
+
+ onSelectArtistModalClose = () => {
+ this.setState({ isSelectArtistModalOpen: false });
+ }
+
+ onSelectAlbumModalClose = () => {
+ this.setState({ isSelectAlbumModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ downloadId,
+ title,
+ folder,
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ sortKey,
+ sortDirection,
+ importMode,
+ interactiveImportErrorMessage,
+ onSortPress,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ invalidRowsSelected,
+ isSelectArtistModalOpen,
+ isSelectAlbumModalOpen
+ } = this.state;
+
+ const selectedIds = this.getSelectedIds();
+ const selectedItem = selectedIds.length ? _.find(items, { id: selectedIds[0] }) : null;
+ const errorMessage = error && error.message || 'Unable to load manual import items';
+
+ const importModeOptions = [
+ { key: 'move', value: 'Move Files' },
+ { key: 'copy', value: 'Copy Files' }
+ ];
+
+ return (
+
+
+ Manual Import - {title || folder}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ error &&
+ {errorMessage}
+ }
+
+ {
+ isPopulated && !!items.length &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ {
+ isPopulated && !items.length &&
+ 'No audio files were found in the selected folder'
+ }
+
+
+
+ {
+ !downloadId &&
+
+
+
+ }
+
+
+
+ Select Artist
+
+
+
+ Select Album
+
+
+
+
+
+ Cancel
+
+
+ {
+ interactiveImportErrorMessage &&
+ {interactiveImportErrorMessage}
+ }
+
+
+ Import
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+InteractiveImportModalContent.propTypes = {
+ downloadId: PropTypes.string,
+ importMode: PropTypes.string.isRequired,
+ title: PropTypes.string,
+ folder: PropTypes.string,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.string,
+ interactiveImportErrorMessage: PropTypes.string,
+ onSortPress: PropTypes.func.isRequired,
+ onImportModeChange: PropTypes.func.isRequired,
+ onImportSelectedPress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+InteractiveImportModalContent.defaultProps = {
+ importMode: 'move'
+};
+
+export default InteractiveImportModalContent;
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js
new file mode 100644
index 000000000..fd07437f1
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js
@@ -0,0 +1,155 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import { fetchInteractiveImportItems, setInteractiveImportSort, clearInteractiveImport, setInteractiveImportMode } from 'Store/Actions/interactiveImportActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import InteractiveImportModalContent from './InteractiveImportModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector(),
+ (interactiveImport) => {
+ return interactiveImport;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchInteractiveImportItems,
+ setInteractiveImportSort,
+ setInteractiveImportMode,
+ clearInteractiveImport,
+ executeCommand
+};
+
+class InteractiveImportModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ interactiveImportErrorMessage: null
+ };
+ }
+
+ componentDidMount() {
+ const {
+ downloadId,
+ folder
+ } = this.props;
+
+ this.props.fetchInteractiveImportItems({ downloadId, folder });
+ }
+
+ componentWillUnmount() {
+ this.props.clearInteractiveImport();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey, sortDirection) => {
+ this.props.setInteractiveImportSort({ sortKey, sortDirection });
+ }
+
+ onImportModeChange = (importMode) => {
+ this.props.setInteractiveImportMode({ importMode });
+ }
+
+ onImportSelectedPress = (selected, importMode) => {
+ const files = [];
+
+ _.forEach(this.props.items, (item) => {
+ const isSelected = selected.indexOf(item.id) > -1;
+
+ if (isSelected) {
+ const {
+ artist,
+ album,
+ tracks,
+ quality,
+ language
+ } = item;
+
+ if (!artist) {
+ this.setState({ interactiveImportErrorMessage: 'Artist must be chosen for each selected file' });
+ return false;
+ }
+
+ if (!album) {
+ this.setState({ interactiveImportErrorMessage: 'Album must be chosen for each selected file' });
+ return false;
+ }
+
+ if (!tracks || !tracks.length) {
+ this.setState({ interactiveImportErrorMessage: 'One or more tracks must be chosen for each selected file' });
+ return false;
+ }
+
+ files.push({
+ path: item.path,
+ artistId: artist.id,
+ albumId: album.id,
+ trackIds: _.map(tracks, '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() {
+ return (
+
+ );
+ }
+}
+
+InteractiveImportModalContentConnector.propTypes = {
+ downloadId: PropTypes.string,
+ folder: PropTypes.string,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchInteractiveImportItems: PropTypes.func.isRequired,
+ setInteractiveImportSort: PropTypes.func.isRequired,
+ clearInteractiveImport: PropTypes.func.isRequired,
+ setInteractiveImportMode: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'interactiveImport' }
+)(InteractiveImportModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css
new file mode 100644
index 000000000..22234718f
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.css
@@ -0,0 +1,11 @@
+.relativePath {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ word-break: break-all;
+}
+
+.quality {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ text-align: center;
+}
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js
new file mode 100644
index 000000000..366c72aa8
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js
@@ -0,0 +1,348 @@
+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 'Album/EpisodeQuality';
+import EpisodeLanguage from 'Album/EpisodeLanguage';
+import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal';
+import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal';
+import SelectTrackModal from 'InteractiveImport/Track/SelectTrackModal';
+import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal';
+import 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 = {
+ isSelectArtistModalOpen: false,
+ isSelectAlbumModalOpen: false,
+ isSelectTrackModalOpen: false,
+ isSelectQualityModalOpen: false,
+ isSelectLanguageModalOpen: false
+ };
+ }
+
+ componentDidMount() {
+ const {
+ id,
+ artist,
+ album,
+ tracks,
+ quality,
+ language
+ } = this.props;
+
+ if (
+ artist &&
+ album != null &&
+ tracks.length &&
+ quality &&
+ language
+ ) {
+ this.props.onSelectedChange({ id, value: true });
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ id,
+ artist,
+ album,
+ tracks,
+ quality,
+ language,
+ isSelected,
+ onValidRowChange
+ } = this.props;
+
+ if (
+ prevProps.artist === artist &&
+ prevProps.album === album &&
+ !hasDifferentItems(prevProps.tracks, tracks) &&
+ prevProps.quality === quality &&
+ prevProps.language === language &&
+ prevProps.isSelected === isSelected
+ ) {
+ return;
+ }
+
+ const isValid = !!(
+ artist &&
+ album &&
+ tracks.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
+
+ onSelectArtistPress = () => {
+ this.setState({ isSelectArtistModalOpen: true });
+ }
+
+ onSelectAlbumPress = () => {
+ this.setState({ isSelectAlbumModalOpen: true });
+ }
+
+ onSelectTrackPress = () => {
+ this.setState({ isSelectTrackModalOpen: true });
+ }
+
+ onSelectQualityPress = () => {
+ this.setState({ isSelectQualityModalOpen: true });
+ }
+
+ onSelectLanguagePress = () => {
+ this.setState({ isSelectLanguageModalOpen: true });
+ }
+
+ onSelectArtistModalClose = (changed) => {
+ this.setState({ isSelectArtistModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ onSelectAlbumModalClose = (changed) => {
+ this.setState({ isSelectAlbumModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ onSelectTrackModalClose = (changed) => {
+ this.setState({ isSelectTrackModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ onSelectQualityModalClose = (changed) => {
+ this.setState({ isSelectQualityModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ onSelectLanguageModalClose = (changed) => {
+ this.setState({ isSelectLanguageModalOpen: false });
+ this.selectRowAfterChange(changed);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ relativePath,
+ artist,
+ album,
+ tracks,
+ quality,
+ language,
+ size,
+ rejections,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ const {
+ isSelectArtistModalOpen,
+ isSelectAlbumModalOpen,
+ isSelectTrackModalOpen,
+ isSelectQualityModalOpen,
+ isSelectLanguageModalOpen
+ } = this.state;
+
+ const artistName = artist ? artist.artistName : '';
+ const albumTitle = album ? album.title : '';
+ const trackNumbers = tracks.map((track) => track.trackNumber)
+ .join(', ');
+
+ const showArtistPlaceholder = isSelected && !artist;
+ const showAlbumNumberPlaceholder = isSelected && !!artist && !album;
+ const showTrackNumbersPlaceholder = isSelected && !!album && !tracks.length;
+
+ return (
+
+
+
+
+ {relativePath}
+
+
+
+ {
+ showArtistPlaceholder ? : artistName
+ }
+
+
+
+ {
+ showAlbumNumberPlaceholder ? : albumTitle
+ }
+
+
+
+ {
+ showTrackNumbersPlaceholder ? : trackNumbers
+ }
+
+
+
+
+
+
+
+
+
+
+
+ {formatBytes(size)}
+
+
+
+ {
+ !!rejections.length &&
+
+ }
+ title="Release Rejected"
+ body={
+
+ {
+ rejections.map((rejection, index) => {
+ return (
+
+ {rejection.reason}
+
+ );
+ })
+ }
+
+ }
+ position={tooltipPositions.LEFT}
+ />
+ }
+
+
+
+
+
+
+
+
+ 1}
+ real={quality.revision.real > 0}
+ onModalClose={this.onSelectQualityModalClose}
+ />
+
+
+
+ );
+ }
+
+}
+
+InteractiveImportRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ relativePath: PropTypes.string.isRequired,
+ artist: PropTypes.object,
+ album: PropTypes.object,
+ tracks: 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 = {
+ tracks: []
+};
+
+export default InteractiveImportRow;
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css
new file mode 100644
index 000000000..941988144
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.css
@@ -0,0 +1,7 @@
+.placeholder {
+ display: inline-block;
+ margin: -8px 0;
+ width: 100%;
+ height: 25px;
+ border: 2px dashed $dangerColor;
+}
diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js
new file mode 100644
index 000000000..b6744d156
--- /dev/null
+++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRowCellPlaceholder.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import styles from './InteractiveImportRowCellPlaceholder.css';
+
+function InteractiveImportRowCellPlaceholder() {
+ return (
+
+ );
+}
+
+export default InteractiveImportRowCellPlaceholder;
diff --git a/frontend/src/InteractiveImport/InteractiveImportModal.js b/frontend/src/InteractiveImport/InteractiveImportModal.js
new file mode 100644
index 000000000..0ea6fd9cb
--- /dev/null
+++ b/frontend/src/InteractiveImport/InteractiveImportModal.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import InteractiveImportSelectFolderModalContentConnector from './Folder/InteractiveImportSelectFolderModalContentConnector';
+import InteractiveImportModalContentConnector from './Interactive/InteractiveImportModalContentConnector';
+
+class InteractiveImportModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ folder: null
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.isOpen && !this.props.isOpen) {
+ this.setState({ folder: null });
+ }
+ }
+
+ //
+ // Listeners
+
+ onFolderSelect = (folder) => {
+ this.setState({ folder });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ folder,
+ downloadId,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ const folderPath = folder || this.state.folder;
+
+ return (
+
+ {
+ folderPath || downloadId ?
+ :
+
+ }
+
+ );
+ }
+}
+
+InteractiveImportModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ folder: PropTypes.string,
+ downloadId: PropTypes.string,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default InteractiveImportModal;
diff --git a/frontend/src/InteractiveImport/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 &&
+
+ }
+
+
+
+
+ Cancel
+
+
+
+ );
+}
+
+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..4a9988b68
--- /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 {
+ isFetchingSchema: isFetching,
+ schemaPopulated: isPopulated,
+ schemaError: error,
+ schema
+ } = languageProfiles;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ items: schema.languages || []
+ };
+ }
+ );
+}
+
+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..8649763a9
--- /dev/null
+++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContent.js
@@ -0,0 +1,166 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+class SelectQualityModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ const {
+ qualityId,
+ proper,
+ real
+ } = props;
+
+ this.state = {
+ qualityId,
+ proper,
+ real
+ };
+ }
+
+ //
+ // Listeners
+
+ onQualityChange = ({ value }) => {
+ this.setState({ qualityId: parseInt(value) });
+ }
+
+ onProperChange = ({ value }) => {
+ this.setState({ proper: value });
+ }
+
+ onRealChange = ({ value }) => {
+ this.setState({ real: value });
+ }
+
+ onQualitySelect = () => {
+ this.props.onQualitySelect(this.state);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ onModalClose
+ } = this.props;
+
+ const {
+ qualityId,
+ proper,
+ real
+ } = this.state;
+
+ const qualityOptions = items.map(({ quality }) => {
+ return {
+ key: quality.id,
+ value: quality.name
+ };
+ });
+
+ return (
+
+
+ Manual Import - Select Quality
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to load qualities
+ }
+
+ {
+ isPopulated && !error &&
+
+ }
+
+
+
+
+ Cancel
+
+
+
+ Select Quality
+
+
+
+ );
+ }
+}
+
+SelectQualityModalContent.propTypes = {
+ qualityId: PropTypes.number.isRequired,
+ proper: PropTypes.bool.isRequired,
+ real: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onQualitySelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectQualityModalContent;
diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js
new file mode 100644
index 000000000..ac5b42a7b
--- /dev/null
+++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js
@@ -0,0 +1,94 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
+import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
+import SelectQualityModalContent from './SelectQualityModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityProfiles,
+ (qualityProfiles) => {
+ const {
+ isFetchingSchema: isFetching,
+ schemaPopulated: isPopulated,
+ schemaError: error,
+ schema
+ } = qualityProfiles;
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ items: schema.items || []
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQualityProfileSchema,
+ updateInteractiveImportItem
+};
+
+class SelectQualityModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount = () => {
+ if (!this.props.isPopulated) {
+ this.props.fetchQualityProfileSchema();
+ }
+ }
+
+ //
+ // Listeners
+
+ onQualitySelect = ({ qualityId, proper, real }) => {
+ const quality = _.find(this.props.items,
+ (item) => item.quality.id === qualityId).quality;
+
+ const revision = {
+ version: proper ? 2 : 1,
+ real: real ? 1 : 0
+ };
+
+ this.props.updateInteractiveImportItem({
+ id: this.props.id,
+ quality: {
+ quality,
+ revision
+ }
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectQualityModalContentConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchQualityProfileSchema: PropTypes.func.isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(SelectQualityModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Track/SelectTrackModal.js b/frontend/src/InteractiveImport/Track/SelectTrackModal.js
new file mode 100644
index 000000000..f8c9c4160
--- /dev/null
+++ b/frontend/src/InteractiveImport/Track/SelectTrackModal.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Modal from 'Components/Modal/Modal';
+import SelectTrackModalContentConnector from './SelectTrackModalContentConnector';
+
+class SelectTrackModal extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ );
+ }
+}
+
+SelectTrackModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectTrackModal;
diff --git a/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js b/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js
new file mode 100644
index 000000000..519ea930d
--- /dev/null
+++ b/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js
@@ -0,0 +1,177 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import SelectTrackRow from './SelectTrackRow';
+
+const columns = [
+ {
+ name: 'trackNumber',
+ label: '#',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'title',
+ label: 'Title',
+ isVisible: true
+ }
+];
+
+class SelectTrackModalContent 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);
+ });
+ }
+
+ onTracksSelect = () => {
+ this.props.onTracksSelect(this.getSelectedIds());
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ sortKey,
+ sortDirection,
+ onSortPress,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState
+ } = this.state;
+
+ const errorMessage = error && error.message || 'Unable to load tracks';
+
+ return (
+
+
+ Manual Import - Select Track(s)
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ error &&
+ {errorMessage}
+ }
+
+ {
+ isPopulated && !!items.length &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+ {
+ isPopulated && !items.length &&
+ 'No tracks were found for the selected album'
+ }
+
+
+
+
+ Cancel
+
+
+
+ Select Tracks
+
+
+
+ );
+ }
+}
+
+SelectTrackModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortKey: PropTypes.string,
+ sortDirection: PropTypes.string,
+ onSortPress: PropTypes.func.isRequired,
+ onTracksSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default SelectTrackModalContent;
diff --git a/frontend/src/InteractiveImport/Track/SelectTrackModalContentConnector.js b/frontend/src/InteractiveImport/Track/SelectTrackModalContentConnector.js
new file mode 100644
index 000000000..52cf7c705
--- /dev/null
+++ b/frontend/src/InteractiveImport/Track/SelectTrackModalContentConnector.js
@@ -0,0 +1,103 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import connectSection from 'Store/connectSection';
+import { fetchTracks, setTracksSort, clearTracks } from 'Store/Actions/trackActions';
+import { updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions';
+import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
+import SelectTrackModalContent from './SelectTrackModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ createClientSideCollectionSelector(),
+ (tracks) => {
+ return tracks;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchTracks,
+ setTracksSort,
+ clearTracks,
+ updateInteractiveImportItem
+};
+
+class SelectTrackModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ artistId,
+ albumId
+ } = this.props;
+
+ this.props.fetchTracks({ artistId, albumId });
+ }
+
+ componentWillUnmount() {
+ // This clears the tracks for the queue and hides the queue
+ // We'll need another place to store tracks for manual import
+ this.props.clearTracks();
+ }
+
+ //
+ // Listeners
+
+ onSortPress = (sortKey, sortDirection) => {
+ this.props.setTracksSort({ sortKey, sortDirection });
+ }
+
+ onTracksSelect = (trackIds) => {
+ const tracks = _.reduce(this.props.items, (acc, item) => {
+ if (trackIds.indexOf(item.id) > -1) {
+ acc.push(item);
+ }
+
+ return acc;
+ }, []);
+
+ this.props.updateInteractiveImportItem({
+ id: this.props.id,
+ tracks: _.sortBy(tracks, 'trackNumber')
+ });
+
+ this.props.onModalClose(true);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+SelectTrackModalContentConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ artistId: PropTypes.number.isRequired,
+ albumId: PropTypes.number.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchTracks: PropTypes.func.isRequired,
+ setTracksSort: PropTypes.func.isRequired,
+ clearTracks: PropTypes.func.isRequired,
+ updateInteractiveImportItem: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'tracks' }
+ )(SelectTrackModalContentConnector);
diff --git a/frontend/src/InteractiveImport/Track/SelectTrackRow.js b/frontend/src/InteractiveImport/Track/SelectTrackRow.js
new file mode 100644
index 000000000..fda48c917
--- /dev/null
+++ b/frontend/src/InteractiveImport/Track/SelectTrackRow.js
@@ -0,0 +1,62 @@
+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 SelectTrackRow extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ id,
+ isSelected
+ } = this.props;
+
+ this.props.onSelectedChange({ id, value: !isSelected });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ trackNumber,
+ title,
+ isSelected,
+ onSelectedChange
+ } = this.props;
+
+ return (
+
+
+
+
+ {trackNumber}
+
+
+
+ {title}
+
+
+
+ );
+ }
+}
+
+SelectTrackRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ trackNumber: PropTypes.number.isRequired,
+ title: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default SelectTrackRow;
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..eb1711522
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModalContent.css
@@ -0,0 +1,24 @@
+.path {
+ margin-left: 5px;
+ font-weight: bold;
+}
+
+.trackFormat {
+ margin-left: 5px;
+ font-family: $monoSpaceFontFamily;
+}
+
+.previews {
+ margin-top: 10px;
+}
+
+.selectAllInputContainer {
+ margin-right: auto;
+ line-height: 30px;
+}
+
+.selectAllInput {
+ composes: input from 'Components/Form/CheckInput.css';
+
+ margin: 0;
+}
diff --git a/frontend/src/Organize/OrganizePreviewModalContent.js b/frontend/src/Organize/OrganizePreviewModalContent.js
new file mode 100644
index 000000000..3bb724cfa
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModalContent.js
@@ -0,0 +1,200 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import CheckInput from 'Components/Form/CheckInput';
+import OrganizePreviewRow from './OrganizePreviewRow';
+import styles from './OrganizePreviewModalContent.css';
+
+function getValue(allSelected, allUnselected) {
+ if (allSelected) {
+ return true;
+ } else if (allUnselected) {
+ return false;
+ }
+
+ return null;
+}
+
+class OrganizePreviewModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {}
+ };
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onOrganizePress = () => {
+ this.props.onOrganizePress(this.getSelectedIds());
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ renameTracks,
+ trackFormat,
+ path,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState
+ } = this.state;
+
+ const selectAllValue = getValue(allSelected, allUnselected);
+
+ return (
+
+
+ Organize & Rename
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Error loading previews
+ }
+
+ {
+ !isFetching && isPopulated && !items.length &&
+
+ {
+ renameTracks ?
+
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:
+
+ {trackFormat}
+
+
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+ {
+ isPopulated && !!items.length &&
+
+ }
+
+
+ Cancel
+
+
+
+ Organize
+
+
+
+ );
+ }
+}
+
+OrganizePreviewModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ path: PropTypes.string.isRequired,
+ renameTracks: PropTypes.bool,
+ trackFormat: PropTypes.string,
+ onOrganizePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default OrganizePreviewModalContent;
diff --git a/frontend/src/Organize/OrganizePreviewModalContentConnector.js b/frontend/src/Organize/OrganizePreviewModalContentConnector.js
new file mode 100644
index 000000000..edaf425ee
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewModalContentConnector.js
@@ -0,0 +1,91 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { fetchOrganizePreview } from 'Store/Actions/organizePreviewActions';
+import { fetchNamingSettings } from 'Store/Actions/settingsActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import OrganizePreviewModalContent from './OrganizePreviewModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.organizePreview,
+ (state) => state.settings.naming,
+ createArtistSelector(),
+ (organizePreview, naming, artist) => {
+ const props = { ...organizePreview };
+ props.isFetching = organizePreview.isFetching || naming.isFetching;
+ props.isPopulated = organizePreview.isPopulated && naming.isPopulated;
+ props.error = organizePreview.error || naming.error;
+ props.renameTracks = naming.item.renameTracks;
+ props.trackFormat = naming.item['standardTrackFormat'];
+ props.path = artist.path;
+
+ return props;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchOrganizePreview,
+ fetchNamingSettings,
+ executeCommand
+};
+
+class OrganizePreviewModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ artistId,
+ albumId
+ } = this.props;
+
+ this.props.fetchOrganizePreview({
+ artistId,
+ albumId
+ });
+
+ this.props.fetchNamingSettings();
+ }
+
+ //
+ // Listeners
+
+ onOrganizePress = (files) => {
+ this.props.executeCommand({
+ name: commandNames.RENAME_FILES,
+ artistId: this.props.artistId,
+ files
+ });
+
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+OrganizePreviewModalContentConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ albumId: PropTypes.number,
+ fetchOrganizePreview: PropTypes.func.isRequired,
+ fetchNamingSettings: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(OrganizePreviewModalContentConnector);
diff --git a/frontend/src/Organize/OrganizePreviewRow.css b/frontend/src/Organize/OrganizePreviewRow.css
new file mode 100644
index 000000000..1b3c8ca47
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewRow.css
@@ -0,0 +1,20 @@
+.row {
+ display: flex;
+ margin-bottom: 5px;
+ padding: 5px 0;
+ border-bottom: 1px solid $borderColor;
+
+ &:last-of-type {
+ margin-bottom: 0;
+ padding-bottom: 0;
+ border-bottom: none;
+ }
+}
+
+.selectedContainer {
+ margin-right: 30px;
+}
+
+.path {
+ margin-left: 10px;
+}
diff --git a/frontend/src/Organize/OrganizePreviewRow.js b/frontend/src/Organize/OrganizePreviewRow.js
new file mode 100644
index 000000000..340232a98
--- /dev/null
+++ b/frontend/src/Organize/OrganizePreviewRow.js
@@ -0,0 +1,90 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './OrganizePreviewRow.css';
+
+class OrganizePreviewRow extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ id,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value: true });
+ }
+
+ //
+ // Listeners
+
+ onSelectedChange = ({ value, shiftKey }) => {
+ const {
+ id,
+ onSelectedChange
+ } = this.props;
+
+ onSelectedChange({ id, value, shiftKey });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ existingPath,
+ newPath,
+ isSelected
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+ {existingPath}
+
+
+
+
+
+
+
+ {newPath}
+
+
+
+
+ );
+ }
+}
+
+OrganizePreviewRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ existingPath: PropTypes.string.isRequired,
+ newPath: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default OrganizePreviewRow;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js
new file mode 100644
index 000000000..414887fd5
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js
@@ -0,0 +1,65 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
+import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector';
+import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector';
+
+class DownloadClientSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ setDownloadClientOptionsRef = (ref) => {
+ this._downloadClientOptions = ref;
+ }
+
+ onHasPendingChange = (hasPendingChanges) => {
+ this.setState({
+ hasPendingChanges
+ });
+ }
+
+ onSavePress = () => {
+ this._downloadClientOptions.getWrappedInstance().save();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default DownloadClientSettings;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css
new file mode 100644
index 000000000..e1032ddef
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.css
@@ -0,0 +1,44 @@
+.downloadClient {
+ composes: card from 'Components/Card.css';
+
+ position: relative;
+ width: 300px;
+ height: 100px;
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ padding: 10px;
+}
+
+.name {
+ text-align: center;
+ font-weight: lighter;
+ font-size: 24px;
+}
+
+.actions {
+ margin-top: 20px;
+ text-align: right;
+}
+
+.presetsMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ display: inline-block;
+ margin: 0 5px;
+}
+
+.presetsMenuButton {
+ composes: button from 'Components/Link/Button.css';
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js
new file mode 100644
index 000000000..3a2265d28
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientItem.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import Menu from 'Components/Menu/Menu';
+import MenuContent from 'Components/Menu/MenuContent';
+import AddDownloadClientPresetMenuItem from './AddDownloadClientPresetMenuItem';
+import styles from './AddDownloadClientItem.css';
+
+class AddDownloadClientItem extends Component {
+
+ //
+ // Listeners
+
+ onDownloadClientSelect = () => {
+ const {
+ implementation
+ } = this.props;
+
+ this.props.onDownloadClientSelect({ implementation });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ implementation,
+ implementationName,
+ infoLink,
+ presets,
+ onDownloadClientSelect
+ } = this.props;
+
+ const hasPresets = !!presets && !!presets.length;
+
+ return (
+
+
+
+
+
+ {implementationName}
+
+
+
+ {
+ hasPresets &&
+
+
+ Custom
+
+
+
+
+ Presets
+
+
+
+ {
+ presets.map((preset) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+ More info
+
+
+
+
+ );
+ }
+}
+
+AddDownloadClientItem.propTypes = {
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ infoLink: PropTypes.string.isRequired,
+ presets: PropTypes.arrayOf(PropTypes.object),
+ onDownloadClientSelect: PropTypes.func.isRequired
+};
+
+export default AddDownloadClientItem;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js
new file mode 100644
index 000000000..0c21e7dbd
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddDownloadClientModalContentConnector from './AddDownloadClientModalContentConnector';
+
+function AddDownloadClientModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+AddDownloadClientModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddDownloadClientModal;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css
new file mode 100644
index 000000000..b4d5c6787
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.css
@@ -0,0 +1,5 @@
+.downloadClients {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js
new file mode 100644
index 000000000..6ba496c45
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContent.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import AddDownloadClientItem from './AddDownloadClientItem';
+import styles from './AddDownloadClientModalContent.css';
+
+class AddDownloadClientModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ error,
+ isPopulated,
+ usenetDownloadClients,
+ torrentDownloadClients,
+ onDownloadClientSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add DownloadClient
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new downloadClient, please try again.
+ }
+
+ {
+ isPopulated && !error &&
+
+
+
+ Lidarr supports any downloadClient that uses the Newznab standard, as well as other downloadClients listed below.
+ For more information on the individual downloadClients, clink on the info buttons.
+
+
+
+
+ {
+ usenetDownloadClients.map((downloadClient) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ {
+ torrentDownloadClients.map((downloadClient) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+AddDownloadClientModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isPopulated: PropTypes.bool.isRequired,
+ usenetDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
+ torrentDownloadClients: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onDownloadClientSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddDownloadClientModalContent;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js
new file mode 100644
index 000000000..d6015b934
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientModalContentConnector.js
@@ -0,0 +1,75 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDownloadClientSchema, selectDownloadClientSchema } from 'Store/Actions/settingsActions';
+import AddDownloadClientModalContent from './AddDownloadClientModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.downloadClients,
+ (downloadClients) => {
+ const {
+ isFetching,
+ error,
+ isPopulated,
+ schema
+ } = downloadClients;
+
+ const usenetDownloadClients = _.filter(schema, { protocol: 'usenet' });
+ const torrentDownloadClients = _.filter(schema, { protocol: 'torrent' });
+
+ return {
+ isFetching,
+ error,
+ isPopulated,
+ usenetDownloadClients,
+ torrentDownloadClients
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDownloadClientSchema,
+ selectDownloadClientSchema
+};
+
+class AddDownloadClientModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchDownloadClientSchema();
+ }
+
+ //
+ // Listeners
+
+ onDownloadClientSelect = ({ implementation }) => {
+ this.props.selectDownloadClientSchema({ implementation });
+ this.props.onModalClose({ downloadClientSelected: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddDownloadClientModalContentConnector.propTypes = {
+ fetchDownloadClientSchema: PropTypes.func.isRequired,
+ selectDownloadClientSchema: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddDownloadClientModalContentConnector);
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js
new file mode 100644
index 000000000..f356f8140
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/AddDownloadClientPresetMenuItem.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class AddDownloadClientPresetMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ implementation
+ } = this.props;
+
+ this.props.onPress({
+ name,
+ implementation
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ implementation,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {name}
+
+ );
+ }
+}
+
+AddDownloadClientPresetMenuItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ implementation: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default AddDownloadClientPresetMenuItem;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.css
new file mode 100644
index 000000000..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..b4a725303
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClient.js
@@ -0,0 +1,106 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditDownloadClientModalConnector from './EditDownloadClientModalConnector';
+import styles from './DownloadClient.css';
+
+class DownloadClient extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditDownloadClientModalOpen: false,
+ isDeleteDownloadClientModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditDownloadClientPress = () => {
+ this.setState({ isEditDownloadClientModalOpen: true });
+ }
+
+ onEditDownloadClientModalClose = () => {
+ this.setState({ isEditDownloadClientModalOpen: false });
+ }
+
+ onDeleteDownloadClientPress = () => {
+ this.setState({
+ isEditDownloadClientModalOpen: false,
+ isDeleteDownloadClientModalOpen: true
+ });
+ }
+
+ onDeleteDownloadClientModalClose= () => {
+ this.setState({ isDeleteDownloadClientModalOpen: false });
+ }
+
+ onConfirmDeleteDownloadClient = () => {
+ this.props.onConfirmDeleteDownloadClient(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ enable
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+
+ Enabled
+
+
+
+
+
+
+
+ );
+ }
+}
+
+DownloadClient.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ enable: PropTypes.bool.isRequired,
+ onConfirmDeleteDownloadClient: PropTypes.func.isRequired
+};
+
+export default DownloadClient;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css
new file mode 100644
index 000000000..ad53e6311
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.css
@@ -0,0 +1,20 @@
+.downloadClients {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addDownloadClient {
+ composes: downloadClient from './DownloadClient.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js
new file mode 100644
index 000000000..fe5371e4f
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js
@@ -0,0 +1,117 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import DownloadClient from './DownloadClient';
+import AddDownloadClientModal from './AddDownloadClientModal';
+import EditDownloadClientModalConnector from './EditDownloadClientModalConnector';
+import styles from './DownloadClients.css';
+
+class DownloadClients extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddDownloadClientModalOpen: false,
+ isEditDownloadClientModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddDownloadClientPress = () => {
+ this.setState({ isAddDownloadClientModalOpen: true });
+ }
+
+ onAddDownloadClientModalClose = ({ downloadClientSelected = false } = {}) => {
+ this.setState({
+ isAddDownloadClientModalOpen: false,
+ isEditDownloadClientModalOpen: downloadClientSelected
+ });
+ }
+
+ onEditDownloadClientModalClose = () => {
+ this.setState({ isEditDownloadClientModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteDownloadClient,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddDownloadClientModalOpen,
+ isEditDownloadClientModalOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+DownloadClients.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteDownloadClient: PropTypes.func.isRequired
+};
+
+export default DownloadClients;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js
new file mode 100644
index 000000000..d318bc163
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDownloadClients, deleteDownloadClient } from 'Store/Actions/settingsActions';
+import DownloadClients from './DownloadClients';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.downloadClients,
+ (downloadClients) => {
+ return {
+ ...downloadClients
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDownloadClients,
+ deleteDownloadClient
+};
+
+class DownloadClientsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchDownloadClients();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteDownloadClient = (id) => {
+ this.props.deleteDownloadClient({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DownloadClientsConnector.propTypes = {
+ fetchDownloadClients: PropTypes.func.isRequired,
+ deleteDownloadClient: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientsConnector);
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js
new file mode 100644
index 000000000..f82e7eea1
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditDownloadClientModalContentConnector from './EditDownloadClientModalContentConnector';
+
+function EditDownloadClientModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditDownloadClientModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditDownloadClientModal;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalConnector.js
new file mode 100644
index 000000000..982b675b8
--- /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 = '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..7475084c8
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js
@@ -0,0 +1,177 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import styles from './EditDownloadClientModalContent.css';
+
+class EditDownloadClientModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ isTesting,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ onTestPress,
+ onDeleteDownloadClientPress,
+ ...otherProps
+ } = this.props;
+
+ const {
+ id,
+ name,
+ enable,
+ fields,
+ message
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit DownloadClient' : 'Add DownloadClient'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new downloadClient, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Test
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+ }
+}
+
+EditDownloadClientModalContent.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ isTesting: PropTypes.bool.isRequired,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onTestPress: PropTypes.func.isRequired,
+ onDeleteDownloadClientPress: PropTypes.func
+};
+
+export default EditDownloadClientModalContent;
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js
new file mode 100644
index 000000000..bf9acb98c
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContentConnector.js
@@ -0,0 +1,94 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setDownloadClientValue, setDownloadClientFieldValue, saveDownloadClient, testDownloadClient } from 'Store/Actions/settingsActions';
+import connectSection from 'Store/connectSection';
+import EditDownloadClientModalContent from './EditDownloadClientModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector(),
+ (advancedSettings, downloadClient) => {
+ return {
+ advancedSettings,
+ ...downloadClient
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setDownloadClientValue,
+ setDownloadClientFieldValue,
+ saveDownloadClient,
+ testDownloadClient
+};
+
+class EditDownloadClientModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setDownloadClientValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setDownloadClientFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveDownloadClient({ id: this.props.id });
+ }
+
+ onTestPress = () => {
+ this.props.testDownloadClient({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditDownloadClientModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setDownloadClientValue: PropTypes.func.isRequired,
+ setDownloadClientFieldValue: PropTypes.func.isRequired,
+ saveDownloadClient: PropTypes.func.isRequired,
+ testDownloadClient: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'downloadClients' }
+)(EditDownloadClientModalContentConnector);
diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js
new file mode 100644
index 000000000..2ec9b417d
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js
@@ -0,0 +1,118 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function DownloadClientOptions(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange
+ } = props;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+
Unable to load download client options
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+
+
+
+
+
+
+
+
+ }
+
+ );
+}
+
+DownloadClientOptions.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default DownloadClientOptions;
diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js
new file mode 100644
index 000000000..cee9a544e
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptionsConnector.js
@@ -0,0 +1,92 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import { fetchDownloadClientOptions, setDownloadClientOptionsValue, saveDownloadClientOptions } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import connectSection from 'Store/connectSection';
+import DownloadClientOptions from './DownloadClientOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDownloadClientOptions,
+ setDownloadClientOptionsValue,
+ saveDownloadClientOptions,
+ clearPendingChanges
+};
+
+class DownloadClientOptionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchDownloadClientOptions();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.hasPendingChanges !== prevProps.hasPendingChanges) {
+ this.props.onHasPendingChange(this.props.hasPendingChanges);
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: this.props.section });
+ }
+
+ //
+ // Control
+
+ save = () => {
+ this.props.saveDownloadClientOptions();
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setDownloadClientOptionsValue({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DownloadClientOptionsConnector.propTypes = {
+ section: PropTypes.string.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ fetchDownloadClientOptions: PropTypes.func.isRequired,
+ setDownloadClientOptionsValue: PropTypes.func.isRequired,
+ saveDownloadClientOptions: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired,
+ onHasPendingChange: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ { withRef: true },
+ { section: 'downloadClientOptions' }
+)(DownloadClientOptionsConnector);
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js
new file mode 100644
index 000000000..5ba30d614
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditRemotePathMappingModalContentConnector from './EditRemotePathMappingModalContentConnector';
+
+function EditRemotePathMappingModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditRemotePathMappingModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditRemotePathMappingModal;
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js
new file mode 100644
index 000000000..bd8bca75f
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditRemotePathMappingModal from './EditRemotePathMappingModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditRemotePathMappingModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'remotePathMappings' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditRemotePathMappingModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalConnector);
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css
new file mode 100644
index 000000000..0071acc4e
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.css
@@ -0,0 +1,12 @@
+.body {
+ composes: modalBody from 'Components/Modal/ModalBody.css';
+
+ flex: 1 1 430px;
+}
+
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
+
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js
new file mode 100644
index 000000000..2e21ea87b
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContent.js
@@ -0,0 +1,149 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import { stringSettingShape } from 'Helpers/Props/Shapes/settingShape';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import styles from './EditRemotePathMappingModalContent.css';
+
+function EditRemotePathMappingModalContent(props) {
+ const {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item,
+ onInputChange,
+ onSavePress,
+ onModalClose,
+ onDeleteRemotePathMappingPress,
+ ...otherProps
+ } = props;
+
+ const {
+ host,
+ remotePath,
+ localPath
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Remote Path Mapping' : 'Add Remote Path Mapping'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new remote path mapping, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+const remotePathMappingShape = {
+ host: PropTypes.shape(stringSettingShape).isRequired,
+ remotePath: PropTypes.shape(stringSettingShape).isRequired,
+ localPath: PropTypes.shape(stringSettingShape).isRequired
+};
+
+EditRemotePathMappingModalContent.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.shape(remotePathMappingShape).isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteRemotePathMappingPress: PropTypes.func
+};
+
+export default EditRemotePathMappingModalContent;
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js
new file mode 100644
index 000000000..00aa7b8ac
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/EditRemotePathMappingModalContentConnector.js
@@ -0,0 +1,119 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import { setRemotePathMappingValue, saveRemotePathMapping } from 'Store/Actions/settingsActions';
+import EditRemotePathMappingModalContent from './EditRemotePathMappingModalContent';
+
+const newRemotePathMapping = {
+ host: '',
+ remotePath: '',
+ localPath: ''
+};
+
+function createRemotePathMappingSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.remotePathMappings,
+ (id, remotePathMappings) => {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = remotePathMappings;
+
+ const mapping = id ? _.find(items, { id }) : newRemotePathMapping;
+ const settings = selectSettings(mapping, pendingChanges, saveError);
+
+ return {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createRemotePathMappingSelector(),
+ (remotePathMapping) => {
+ return {
+ ...remotePathMapping
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setRemotePathMappingValue,
+ saveRemotePathMapping
+};
+
+class EditRemotePathMappingModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newRemotePathMapping).forEach((name) => {
+ this.props.setRemotePathMappingValue({
+ name,
+ value: newRemotePathMapping[name]
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setRemotePathMappingValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveRemotePathMapping({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditRemotePathMappingModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setRemotePathMappingValue: PropTypes.func.isRequired,
+ saveRemotePathMapping: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditRemotePathMappingModalContentConnector);
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css
new file mode 100644
index 000000000..a79efda26
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.css
@@ -0,0 +1,23 @@
+.remotePathMapping {
+ display: flex;
+ align-items: stretch;
+ margin-bottom: 10px;
+ height: 30px;
+ border-bottom: 1px solid $borderColor;
+ line-height: 30px;
+}
+
+.host {
+ flex: 0 0 300px;
+}
+
+.path {
+ flex: 0 0 400px;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ flex: 1 0 auto;
+ padding-right: 10px;
+}
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js
new file mode 100644
index 000000000..c0c0a988f
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMapping.js
@@ -0,0 +1,114 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector';
+import styles from './RemotePathMapping.css';
+
+class RemotePathMapping extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditRemotePathMappingModalOpen: false,
+ isDeleteRemotePathMappingModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditRemotePathMappingPress = () => {
+ this.setState({ isEditRemotePathMappingModalOpen: true });
+ }
+
+ onEditRemotePathMappingModalClose = () => {
+ this.setState({ isEditRemotePathMappingModalOpen: false });
+ }
+
+ onDeleteRemotePathMappingPress = () => {
+ this.setState({
+ isEditRemotePathMappingModalOpen: false,
+ isDeleteRemotePathMappingModalOpen: true
+ });
+ }
+
+ onDeleteRemotePathMappingModalClose = () => {
+ this.setState({ isDeleteRemotePathMappingModalOpen: false });
+ }
+
+ onConfirmDeleteRemotePathMapping = () => {
+ this.props.onConfirmDeleteRemotePathMapping(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ host,
+ remotePath,
+ localPath
+ } = this.props;
+
+ return (
+
+
{host}
+
{remotePath}
+
{localPath}
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+RemotePathMapping.propTypes = {
+ id: PropTypes.number.isRequired,
+ host: PropTypes.string.isRequired,
+ remotePath: PropTypes.string.isRequired,
+ localPath: PropTypes.string.isRequired,
+ onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired
+};
+
+RemotePathMapping.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default RemotePathMapping;
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css
new file mode 100644
index 000000000..4ef9dcb0f
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.css
@@ -0,0 +1,23 @@
+.remotePathMappingsHeader {
+ display: flex;
+ margin-bottom: 10px;
+ font-weight: bold;
+}
+
+.host {
+ flex: 0 0 300px;
+}
+
+.path {
+ flex: 0 0 400px;
+}
+
+.addRemotePathMapping {
+ display: flex;
+ justify-content: flex-end;
+ padding-right: 10px;
+}
+
+.addButton {
+ text-align: center;
+}
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js
new file mode 100644
index 000000000..93d022e02
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappings.js
@@ -0,0 +1,102 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import RemotePathMapping from './RemotePathMapping';
+import EditRemotePathMappingModalConnector from './EditRemotePathMappingModalConnector';
+import styles from './RemotePathMappings.css';
+
+class RemotePathMappings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddRemotePathMappingModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddRemotePathMappingPress = () => {
+ this.setState({ isAddRemotePathMappingModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isAddRemotePathMappingModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteRemotePathMapping,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
Host
+
Remote Path
+
Local Path
+
+
+
+ {
+ items.map((item, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+RemotePathMappings.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteRemotePathMapping: PropTypes.func.isRequired
+};
+
+export default RemotePathMappings;
diff --git a/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js
new file mode 100644
index 000000000..4900119a3
--- /dev/null
+++ b/frontend/src/Settings/DownloadClients/RemotePathMappings/RemotePathMappingsConnector.js
@@ -0,0 +1,59 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchRemotePathMappings, deleteRemotePathMapping } from 'Store/Actions/settingsActions';
+import RemotePathMappings from './RemotePathMappings';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.remotePathMappings,
+ (remotePathMappings) => {
+ return {
+ ...remotePathMappings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchRemotePathMappings,
+ deleteRemotePathMapping
+};
+
+class RemotePathMappingsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchRemotePathMappings();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteRemotePathMapping = (id) => {
+ this.props.deleteRemotePathMapping({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+RemotePathMappingsConnector.propTypes = {
+ fetchRemotePathMappings: PropTypes.func.isRequired,
+ deleteRemotePathMapping: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(RemotePathMappingsConnector);
diff --git a/frontend/src/Settings/General/GeneralSettings.js b/frontend/src/Settings/General/GeneralSettings.js
new file mode 100644
index 000000000..ba71837a5
--- /dev/null
+++ b/frontend/src/Settings/General/GeneralSettings.js
@@ -0,0 +1,656 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import ClipboardButton from 'Components/Link/ClipboardButton';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import FormInputButton from 'Components/Form/FormInputButton';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+
+class GeneralSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isConfirmApiKeyResetModalOpen: false,
+ isRestartRequiredModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ settings,
+ isSaving,
+ saveError
+ } = this.props;
+
+ if (isSaving || saveError || !prevProps.isSaving) {
+ return;
+ }
+
+ const prevSettings = prevProps.settings;
+
+ const keys = [
+ 'bindAddress',
+ 'port',
+ 'urlBase',
+ 'enableSsl',
+ 'sslPort',
+ 'sslCertHash',
+ 'authenticationMethod',
+ 'username',
+ 'password',
+ 'apiKey'
+ ];
+
+ const pendingRestart = _.some(keys, (key) => {
+ const setting = settings[key];
+ const prevSetting = prevSettings[key];
+
+ if (!setting || !prevSetting) {
+ return false;
+ }
+
+ const previousValue = prevSetting.previousValue;
+ const value = setting.value;
+
+ return previousValue != null && previousValue !== value;
+ });
+
+ this.setState({ isRestartRequiredModalOpen: pendingRestart });
+ }
+
+ //
+ // Listeners
+
+ onApikeyFocus = (event) => {
+ event.target.select();
+ }
+
+ onResetApiKeyPress = () => {
+ this.setState({ isConfirmApiKeyResetModalOpen: true });
+ }
+
+ onConfirmResetApiKey = () => {
+ this.setState({ isConfirmApiKeyResetModalOpen: false });
+ this.props.onConfirmResetApiKey();
+ }
+
+ onCloseResetApiKeyModal = () => {
+ this.setState({ isConfirmApiKeyResetModalOpen: false });
+ }
+
+ onConfirmRestart = () => {
+ this.setState({ isRestartRequiredModalOpen: false });
+ this.props.onConfirmRestart();
+ }
+
+ onCloseRestartRequiredModalOpen = () => {
+ this.setState({ isRestartRequiredModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ isPopulated,
+ error,
+ settings,
+ hasSettings,
+ isResettingApiKey,
+ isMono,
+ isWindows,
+ mode,
+ onInputChange,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isConfirmApiKeyResetModalOpen,
+ isRestartRequiredModalOpen
+ } = this.state;
+
+ const {
+ bindAddress,
+ port,
+ urlBase,
+ enableSsl,
+ sslPort,
+ sslCertHash,
+ launchBrowser,
+ authenticationMethod,
+ username,
+ password,
+ apiKey,
+ proxyEnabled,
+ proxyType,
+ proxyHostname,
+ proxyPort,
+ proxyUsername,
+ proxyPassword,
+ proxyBypassFilter,
+ proxyBypassLocalAddresses,
+ logLevel,
+ analyticsEnabled,
+ branch,
+ updateAutomatically,
+ updateMechanism,
+ updateScriptPath
+ } = settings;
+
+ const authenticationMethodOptions = [
+ { key: 'none', value: 'None' },
+ { key: 'basic', value: 'Basic (Browser Popup)' },
+ { key: 'forms', value: 'Forms (Login Page)' }
+ ];
+
+ const proxyTypeOptions = [
+ { key: 'http', value: 'HTTP(S)' },
+ { key: 'socks4', value: 'Socks4' },
+ { key: 'socks5', value: 'Socks5 (Support TOR)' }
+ ];
+
+ const logLevelOptions = [
+ { key: 'info', value: 'Info' },
+ { key: 'debug', value: 'Debug' },
+ { key: 'trace', value: 'Trace' }
+ ];
+
+ const updateOptions = [
+ { key: 'builtIn', value: 'Built-In' },
+ { key: 'script', value: 'Script' }
+ ];
+
+ const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
+
+ return (
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load General settings
+ }
+
+ {
+ hasSettings && isPopulated && !error &&
+
+ }
+
+
+
+
+
+
+ );
+ }
+
+}
+
+GeneralSettings.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ isResettingApiKey: PropTypes.bool.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ isMono: PropTypes.bool.isRequired,
+ isWindows: PropTypes.bool.isRequired,
+ mode: PropTypes.string.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onConfirmResetApiKey: PropTypes.func.isRequired,
+ onConfirmRestart: PropTypes.func.isRequired
+};
+
+export default GeneralSettings;
diff --git a/frontend/src/Settings/General/GeneralSettingsConnector.js b/frontend/src/Settings/General/GeneralSettingsConnector.js
new file mode 100644
index 000000000..fd0a11743
--- /dev/null
+++ b/frontend/src/Settings/General/GeneralSettingsConnector.js
@@ -0,0 +1,117 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import { setGeneralSettingsValue, saveGeneralSettings, fetchGeneralSettings } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { restart } from 'Store/Actions/systemActions';
+import connectSection from 'Store/connectSection';
+import * as commandNames from 'Commands/commandNames';
+import GeneralSettings from './GeneralSettings';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(),
+ createCommandsSelector(),
+ createSystemStatusSelector(),
+ (advancedSettings, sectionSettings, commands, systemStatus) => {
+ const isResettingApiKey = _.some(commands, { name: commandNames.RESET_API_KEY });
+
+ return {
+ advancedSettings,
+ isResettingApiKey,
+ isMono: systemStatus.isMono,
+ isWindows: systemStatus.isWindows,
+ mode: systemStatus.mode,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setGeneralSettingsValue,
+ saveGeneralSettings,
+ fetchGeneralSettings,
+ executeCommand,
+ restart,
+ clearPendingChanges
+};
+
+class GeneralSettingsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchGeneralSettings();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!this.props.isResettingApiKey && prevProps.isResettingApiKey) {
+ this.props.fetchGeneralSettings();
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: this.props.section });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setGeneralSettingsValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveGeneralSettings();
+ }
+
+ onConfirmResetApiKey = () => {
+ this.props.executeCommand({ name: commandNames.RESET_API_KEY });
+ }
+
+ onConfirmRestart = () => {
+ this.props.restart();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+GeneralSettingsConnector.propTypes = {
+ section: PropTypes.string.isRequired,
+ isResettingApiKey: PropTypes.bool.isRequired,
+ setGeneralSettingsValue: PropTypes.func.isRequired,
+ saveGeneralSettings: PropTypes.func.isRequired,
+ fetchGeneralSettings: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ restart: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'general' }
+)(GeneralSettingsConnector);
diff --git a/frontend/src/Settings/Indexers/IndexerSettings.js b/frontend/src/Settings/Indexers/IndexerSettings.js
new file mode 100644
index 000000000..2e526c080
--- /dev/null
+++ b/frontend/src/Settings/Indexers/IndexerSettings.js
@@ -0,0 +1,65 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import IndexersConnector from './Indexers/IndexersConnector';
+import IndexerOptionsConnector from './Options/IndexerOptionsConnector';
+import RestrictionsConnector from './Restrictions/RestrictionsConnector';
+
+class IndexerSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ setIndexerOptionsRef = (ref) => {
+ this._indexerOptions = ref;
+ }
+
+ onHasPendingChange = (hasPendingChanges) => {
+ this.setState({
+ hasPendingChanges
+ });
+ }
+
+ onSavePress = () => {
+ this._indexerOptions.getWrappedInstance().save();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default IndexerSettings;
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css
new file mode 100644
index 000000000..d228b842b
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.css
@@ -0,0 +1,44 @@
+.indexer {
+ composes: card from 'Components/Card.css';
+
+ position: relative;
+ width: 300px;
+ height: 100px;
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ padding: 10px;
+}
+
+.name {
+ text-align: center;
+ font-weight: lighter;
+ font-size: 24px;
+}
+
+.actions {
+ margin-top: 20px;
+ text-align: right;
+}
+
+.presetsMenu {
+ composes: menu from 'Components/Menu/Menu.css';
+
+ display: inline-block;
+ margin: 0 5px;
+}
+
+.presetsMenuButton {
+ composes: button from 'Components/Link/Button.css';
+
+ &::after {
+ margin-left: 5px;
+ content: '\25BE';
+ }
+}
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js
new file mode 100644
index 000000000..21db4ecf1
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerItem.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Link from 'Components/Link/Link';
+import Menu from 'Components/Menu/Menu';
+import MenuContent from 'Components/Menu/MenuContent';
+import AddIndexerPresetMenuItem from './AddIndexerPresetMenuItem';
+import styles from './AddIndexerItem.css';
+
+class AddIndexerItem extends Component {
+
+ //
+ // Listeners
+
+ onIndexerSelect = () => {
+ const {
+ implementation
+ } = this.props;
+
+ this.props.onIndexerSelect({ implementation });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ implementation,
+ implementationName,
+ infoLink,
+ presets,
+ onIndexerSelect
+ } = this.props;
+
+ const hasPresets = !!presets && !!presets.length;
+
+ return (
+
+
+
+
+
+ {implementationName}
+
+
+
+ {
+ hasPresets &&
+
+
+ Custom
+
+
+
+
+ Presets
+
+
+
+ {
+ presets.map((preset) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+ More info
+
+
+
+
+ );
+ }
+}
+
+AddIndexerItem.propTypes = {
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ infoLink: PropTypes.string.isRequired,
+ presets: PropTypes.arrayOf(PropTypes.object),
+ onIndexerSelect: PropTypes.func.isRequired
+};
+
+export default AddIndexerItem;
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js
new file mode 100644
index 000000000..d05e8eb9a
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddIndexerModalContentConnector from './AddIndexerModalContentConnector';
+
+function AddIndexerModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+AddIndexerModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddIndexerModal;
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css
new file mode 100644
index 000000000..946305dff
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.css
@@ -0,0 +1,5 @@
+.indexers {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js
new file mode 100644
index 000000000..ea956f0a6
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContent.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import FieldSet from 'Components/FieldSet';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import AddIndexerItem from './AddIndexerItem';
+import styles from './AddIndexerModalContent.css';
+
+class AddIndexerModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ usenetIndexers,
+ torrentIndexers,
+ onIndexerSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add Indexer
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new indexer, please try again.
+ }
+
+ {
+ isPopulated && !error &&
+
+
+
+ Lidarr supports any indexer that uses the Newznab standard, as well as other indexers listed below.
+ For more information on the individual indexers, clink on the info buttons.
+
+
+
+
+ {
+ usenetIndexers.map((indexer) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ {
+ torrentIndexers.map((indexer) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+AddIndexerModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ usenetIndexers: PropTypes.arrayOf(PropTypes.object).isRequired,
+ torrentIndexers: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onIndexerSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddIndexerModalContent;
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js
new file mode 100644
index 000000000..986466c09
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js
@@ -0,0 +1,75 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchIndexerSchema, selectIndexerSchema } from 'Store/Actions/settingsActions';
+import AddIndexerModalContent from './AddIndexerModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.indexers,
+ (indexers) => {
+ const {
+ isFetching,
+ error,
+ isPopulated,
+ schema
+ } = indexers;
+
+ const usenetIndexers = _.filter(schema, { protocol: 'usenet' });
+ const torrentIndexers = _.filter(schema, { protocol: 'torrent' });
+
+ return {
+ isFetching,
+ error,
+ isPopulated,
+ usenetIndexers,
+ torrentIndexers
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchIndexerSchema,
+ selectIndexerSchema
+};
+
+class AddIndexerModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchIndexerSchema();
+ }
+
+ //
+ // Listeners
+
+ onIndexerSelect = ({ implementation, name }) => {
+ this.props.selectIndexerSchema({ implementation, presetName: name });
+ this.props.onModalClose({ indexerSelected: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddIndexerModalContentConnector.propTypes = {
+ fetchIndexerSchema: PropTypes.func.isRequired,
+ selectIndexerSchema: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddIndexerModalContentConnector);
diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js
new file mode 100644
index 000000000..ddea8b043
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerPresetMenuItem.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class AddIndexerPresetMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ implementation
+ } = this.props;
+
+ this.props.onPress({
+ name,
+ implementation
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ implementation,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {name}
+
+ );
+ }
+}
+
+AddIndexerPresetMenuItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ implementation: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default AddIndexerPresetMenuItem;
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js
new file mode 100644
index 000000000..fef70e29f
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditIndexerModalContentConnector from './EditIndexerModalContentConnector';
+
+function EditIndexerModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditIndexerModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditIndexerModal;
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js
new file mode 100644
index 000000000..c308f004f
--- /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 = '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..00b41730c
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js
@@ -0,0 +1,177 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import styles from './EditIndexerModalContent.css';
+
+function EditIndexerModalContent(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ isTesting,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ onTestPress,
+ onDeleteIndexerPress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ name,
+ enableRss,
+ enableSearch,
+ supportsRss,
+ supportsSearch,
+ fields
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Indexer' : 'Add Indexer'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new indexer, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Test
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditIndexerModalContent.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ isTesting: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onTestPress: PropTypes.func.isRequired,
+ onDeleteIndexerPress: PropTypes.func
+};
+
+export default EditIndexerModalContent;
diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js
new file mode 100644
index 000000000..c4d9e597e
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js
@@ -0,0 +1,94 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setIndexerValue, setIndexerFieldValue, saveIndexer, testIndexer } from 'Store/Actions/settingsActions';
+import connectSection from 'Store/connectSection';
+import EditIndexerModalContent from './EditIndexerModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector(),
+ (advancedSettings, indexer) => {
+ return {
+ advancedSettings,
+ ...indexer
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setIndexerValue,
+ setIndexerFieldValue,
+ saveIndexer,
+ testIndexer
+};
+
+class EditIndexerModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setIndexerValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setIndexerFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveIndexer({ id: this.props.id });
+ }
+
+ onTestPress = () => {
+ this.props.testIndexer({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditIndexerModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setIndexerValue: PropTypes.func.isRequired,
+ setIndexerFieldValue: PropTypes.func.isRequired,
+ saveIndexer: PropTypes.func.isRequired,
+ testIndexer: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'indexers' }
+)(EditIndexerModalContentConnector);
diff --git a/frontend/src/Settings/Indexers/Indexers/Indexer.css b/frontend/src/Settings/Indexers/Indexers/Indexer.css
new file mode 100644
index 000000000..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..221a6a239
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexer.js
@@ -0,0 +1,131 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditIndexerModalConnector from './EditIndexerModalConnector';
+import styles from './Indexer.css';
+
+function getLabelKind(supports, enabled) {
+ if (!supports) {
+ return kinds.DEFAULT;
+ }
+
+ if (!enabled) {
+ return kinds.DANGER;
+ }
+
+ return kinds.SUCCESS;
+}
+
+class Indexer extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditIndexerModalOpen: false,
+ isDeleteIndexerModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditIndexerPress = () => {
+ this.setState({ isEditIndexerModalOpen: true });
+ }
+
+ onEditIndexerModalClose = () => {
+ this.setState({ isEditIndexerModalOpen: false });
+ }
+
+ onDeleteIndexerPress = () => {
+ this.setState({
+ isEditIndexerModalOpen: false,
+ isDeleteIndexerModalOpen: true
+ });
+ }
+
+ onDeleteIndexerModalClose= () => {
+ this.setState({ isDeleteIndexerModalOpen: false });
+ }
+
+ onConfirmDeleteIndexer = () => {
+ this.props.onConfirmDeleteIndexer(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ enableRss,
+ enableSearch,
+ supportsRss,
+ supportsSearch
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+
+ RSS
+
+
+
+ Search
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Indexer.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ enableRss: PropTypes.bool.isRequired,
+ enableSearch: PropTypes.bool.isRequired,
+ supportsRss: PropTypes.bool.isRequired,
+ supportsSearch: PropTypes.bool.isRequired,
+ onConfirmDeleteIndexer: PropTypes.func.isRequired
+};
+
+export default Indexer;
diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.css b/frontend/src/Settings/Indexers/Indexers/Indexers.css
new file mode 100644
index 000000000..ec8cb2891
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexers.css
@@ -0,0 +1,20 @@
+.indexers {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addIndexer {
+ composes: indexer from './Indexer.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/Indexers/Indexers/Indexers.js b/frontend/src/Settings/Indexers/Indexers/Indexers.js
new file mode 100644
index 000000000..8b7d37a84
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/Indexers.js
@@ -0,0 +1,117 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import Indexer from './Indexer';
+import AddIndexerModal from './AddIndexerModal';
+import EditIndexerModalConnector from './EditIndexerModalConnector';
+import styles from './Indexers.css';
+
+class Indexers extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddIndexerModalOpen: false,
+ isEditIndexerModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddIndexerPress = () => {
+ this.setState({ isAddIndexerModalOpen: true });
+ }
+
+ onAddIndexerModalClose = ({ indexerSelected = false } = {}) => {
+ this.setState({
+ isAddIndexerModalOpen: false,
+ isEditIndexerModalOpen: indexerSelected
+ });
+ }
+
+ onEditIndexerModalClose = () => {
+ this.setState({ isEditIndexerModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteIndexer,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddIndexerModalOpen,
+ isEditIndexerModalOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Indexers.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteIndexer: PropTypes.func.isRequired
+};
+
+export default Indexers;
diff --git a/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js
new file mode 100644
index 000000000..415dae32b
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchIndexers, deleteIndexer } from 'Store/Actions/settingsActions';
+import Indexers from './Indexers';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.indexers,
+ (indexers) => {
+ return {
+ ...indexers
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchIndexers,
+ deleteIndexer
+};
+
+class IndexersConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchIndexers();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteIndexer = (id) => {
+ this.props.deleteIndexer({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+IndexersConnector.propTypes = {
+ fetchIndexers: PropTypes.func.isRequired,
+ deleteIndexer: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(IndexersConnector);
diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptions.js b/frontend/src/Settings/Indexers/Options/IndexerOptions.js
new file mode 100644
index 000000000..0a39ec7b7
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Options/IndexerOptions.js
@@ -0,0 +1,93 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+
+function IndexerOptions(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange
+ } = props;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load indexer options
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+ );
+}
+
+IndexerOptions.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default IndexerOptions;
diff --git a/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js
new file mode 100644
index 000000000..e6d39edf9
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Options/IndexerOptionsConnector.js
@@ -0,0 +1,92 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import { fetchIndexerOptions, setIndexerOptionsValue, saveIndexerOptions } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import connectSection from 'Store/connectSection';
+import IndexerOptions from './IndexerOptions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchIndexerOptions,
+ setIndexerOptionsValue,
+ saveIndexerOptions,
+ clearPendingChanges
+};
+
+class IndexerOptionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchIndexerOptions();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.hasPendingChanges !== prevProps.hasPendingChanges) {
+ this.props.onHasPendingChange(this.props.hasPendingChanges);
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: this.props.section });
+ }
+
+ //
+ // Control
+
+ save = () => {
+ this.props.saveIndexerOptions();
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setIndexerOptionsValue({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+IndexerOptionsConnector.propTypes = {
+ section: PropTypes.string.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ fetchIndexerOptions: PropTypes.func.isRequired,
+ setIndexerOptionsValue: PropTypes.func.isRequired,
+ saveIndexerOptions: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired,
+ onHasPendingChange: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ { withRef: true },
+ { section: 'indexerOptions' }
+)(IndexerOptionsConnector);
diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js
new file mode 100644
index 000000000..e9f42df98
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditRestrictionModalContentConnector from './EditRestrictionModalContentConnector';
+
+function EditRestrictionModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditRestrictionModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditRestrictionModal;
diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js
new file mode 100644
index 000000000..4483f7894
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalConnector.js
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditRestrictionModal from './EditRestrictionModal';
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditRestrictionModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'restrictions' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditRestrictionModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, mapDispatchToProps)(EditRestrictionModalConnector);
diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css
new file mode 100644
index 000000000..a3c7f464c
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js
new file mode 100644
index 000000000..eea3abad0
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContent.js
@@ -0,0 +1,126 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import styles from './EditRestrictionModalContent.css';
+
+function EditRestrictionModalContent(props) {
+ const {
+ isSaving,
+ saveError,
+ item,
+ onInputChange,
+ onModalClose,
+ onSavePress,
+ onDeleteRestrictionPress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ required,
+ ignored,
+ tags
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Restriction' : 'Add Restriction'}
+
+
+
+
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditRestrictionModalContent.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onDeleteRestrictionPress: PropTypes.func
+};
+
+export default EditRestrictionModalContent;
diff --git a/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js
new file mode 100644
index 000000000..322b0a8d9
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/EditRestrictionModalContentConnector.js
@@ -0,0 +1,111 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import { setRestrictionValue, saveRestriction } from 'Store/Actions/settingsActions';
+import EditRestrictionModalContent from './EditRestrictionModalContent';
+
+const newRestriction = {
+ required: '',
+ ignored: '',
+ tags: []
+};
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.restrictions,
+ (id, restrictions) => {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = restrictions;
+
+ const profile = id ? _.find(items, { id }) : newRestriction;
+ const settings = selectSettings(profile, pendingChanges, saveError);
+
+ return {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setRestrictionValue,
+ saveRestriction
+};
+
+class EditRestrictionModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newRestriction).forEach((name) => {
+ this.props.setRestrictionValue({
+ name,
+ value: newRestriction[name]
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setRestrictionValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveRestriction({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditRestrictionModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setRestrictionValue: PropTypes.func.isRequired,
+ saveRestriction: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditRestrictionModalContentConnector);
diff --git a/frontend/src/Settings/Indexers/Restrictions/Restriction.css b/frontend/src/Settings/Indexers/Restrictions/Restriction.css
new file mode 100644
index 000000000..0e84466f9
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/Restriction.css
@@ -0,0 +1,11 @@
+.restriction {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.enabled {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Indexers/Restrictions/Restriction.js b/frontend/src/Settings/Indexers/Restrictions/Restriction.js
new file mode 100644
index 000000000..bdd457aca
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/Restriction.js
@@ -0,0 +1,147 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import split from 'Utilities/String/split';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import TagList from 'Components/TagList';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditRestrictionModalConnector from './EditRestrictionModalConnector';
+import styles from './Restriction.css';
+
+class Restriction extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditRestrictionModalOpen: false,
+ isDeleteRestrictionModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditRestrictionPress = () => {
+ this.setState({ isEditRestrictionModalOpen: true });
+ }
+
+ onEditRestrictionModalClose = () => {
+ this.setState({ isEditRestrictionModalOpen: false });
+ }
+
+ onDeleteRestrictionPress = () => {
+ this.setState({
+ isEditRestrictionModalOpen: false,
+ isDeleteRestrictionModalOpen: true
+ });
+ }
+
+ onDeleteRestrictionModalClose= () => {
+ this.setState({ isDeleteRestrictionModalOpen: false });
+ }
+
+ onConfirmDeleteRestriction = () => {
+ this.props.onConfirmDeleteRestriction(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ required,
+ ignored,
+ tags,
+ tagList
+ } = this.props;
+
+ return (
+
+
+ {
+ split(required).map((item) => {
+ if (!item) {
+ return null;
+ }
+
+ return (
+
+ {item}
+
+ );
+ })
+ }
+
+
+
+ {
+ split(ignored).map((item) => {
+ if (!item) {
+ return null;
+ }
+
+ return (
+
+ {item}
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Restriction.propTypes = {
+ id: PropTypes.number.isRequired,
+ required: PropTypes.string.isRequired,
+ ignored: PropTypes.string.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteRestriction: PropTypes.func.isRequired
+};
+
+Restriction.defaultProps = {
+ required: '',
+ ignored: ''
+};
+
+export default Restriction;
diff --git a/frontend/src/Settings/Indexers/Restrictions/Restrictions.css b/frontend/src/Settings/Indexers/Restrictions/Restrictions.css
new file mode 100644
index 000000000..904a66a57
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/Restrictions.css
@@ -0,0 +1,20 @@
+.restrictions {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addRestriction {
+ composes: restriction from './Restriction.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/Indexers/Restrictions/Restrictions.js b/frontend/src/Settings/Indexers/Restrictions/Restrictions.js
new file mode 100644
index 000000000..411b95ea8
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/Restrictions.js
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import Restriction from './Restriction';
+import EditRestrictionModalConnector from './EditRestrictionModalConnector';
+import styles from './Restrictions.css';
+
+class Restrictions extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddRestrictionModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddRestrictionPress = () => {
+ this.setState({ isAddRestrictionModalOpen: true });
+ }
+
+ onAddRestrictionModalClose = () => {
+ this.setState({ isAddRestrictionModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ tagList,
+ onConfirmDeleteRestriction,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ );
+ }
+}
+
+Restrictions.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteRestriction: PropTypes.func.isRequired
+};
+
+export default Restrictions;
diff --git a/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js b/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js
new file mode 100644
index 000000000..c53c05de2
--- /dev/null
+++ b/frontend/src/Settings/Indexers/Restrictions/RestrictionsConnector.js
@@ -0,0 +1,61 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchRestrictions, deleteRestriction } from 'Store/Actions/settingsActions';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import Restrictions from './Restrictions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.restrictions,
+ createTagsSelector(),
+ (restrictions, tagList) => {
+ return {
+ ...restrictions,
+ tagList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchRestrictions,
+ deleteRestriction
+};
+
+class RestrictionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchRestrictions();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteRestriction = (id) => {
+ this.props.deleteRestriction({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+RestrictionsConnector.propTypes = {
+ fetchRestrictions: PropTypes.func.isRequired,
+ deleteRestriction: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(RestrictionsConnector);
diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js
new file mode 100644
index 000000000..21d33bea6
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/MediaManagement.js
@@ -0,0 +1,347 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import NamingConnector from './Naming/NamingConnector';
+
+class MediaManagement extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ isMono,
+ onInputChange,
+ onSavePress,
+ ...otherProps
+ } = this.props;
+
+ const fileDateOptions = [
+ { key: 'none', value: 'None' },
+ { key: 'localAirDate', value: 'Local Air Date' },
+ { key: 'utcAirDate', value: 'UTC Air Date' }
+ ];
+
+ return (
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load Media Management settings
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+
+ );
+ }
+
+}
+
+MediaManagement.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ isMono: PropTypes.bool.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default MediaManagement;
diff --git a/frontend/src/Settings/MediaManagement/MediaManagementConnector.js b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js
new file mode 100644
index 000000000..5f7ed7141
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/MediaManagementConnector.js
@@ -0,0 +1,91 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
+import { fetchMediaManagementSettings, setMediaManagementSettingsValue, saveMediaManagementSettings, saveNamingSettings } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import connectSection from 'Store/connectSection';
+import MediaManagement from './MediaManagement';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.naming,
+ createSettingsSectionSelector(),
+ createSystemStatusSelector(),
+ (advancedSettings, namingSettings, sectionSettings, systemStatus) => {
+ return {
+ advancedSettings,
+ ...sectionSettings,
+ hasPendingChanges: !_.isEmpty(namingSettings.pendingChanges) || sectionSettings.hasPendingChanges,
+ isMono: systemStatus.isMono
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchMediaManagementSettings,
+ setMediaManagementSettingsValue,
+ saveMediaManagementSettings,
+ saveNamingSettings,
+ clearPendingChanges
+};
+
+class MediaManagementConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchMediaManagementSettings();
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: this.props.section });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setMediaManagementSettingsValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveMediaManagementSettings();
+ this.props.saveNamingSettings();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MediaManagementConnector.propTypes = {
+ section: PropTypes.string.isRequired,
+ fetchMediaManagementSettings: PropTypes.func.isRequired,
+ setMediaManagementSettingsValue: PropTypes.func.isRequired,
+ saveMediaManagementSettings: PropTypes.func.isRequired,
+ saveNamingSettings: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'mediaManagement' }
+)(MediaManagementConnector);
diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.css b/frontend/src/Settings/MediaManagement/Naming/Naming.css
new file mode 100644
index 000000000..a0fbf7f19
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/Naming.css
@@ -0,0 +1,5 @@
+.namingInput {
+ composes: text from 'Components/Form/TextInput.css';
+
+ font-family: $monoSpaceFontFamily;
+}
diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js
new file mode 100644
index 000000000..8e060aea0
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js
@@ -0,0 +1,264 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { inputTypes, sizes } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FormInputButton from 'Components/Form/FormInputButton';
+import FieldSet from 'Components/FieldSet';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import NamingModal from './NamingModal';
+import styles from './Naming.css';
+
+class Naming extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isNamingModalOpen: false,
+ namingModalOptions: null
+ };
+ }
+
+ //
+ // Listeners
+
+ onStandardNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'standardTrackFormat',
+ album: true,
+ track: true,
+ additional: true
+ }
+ });
+ }
+
+ onArtistFolderNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'artistFolderFormat'
+ }
+ });
+ }
+
+ onAlbumFolderNamingModalOpenClick = () => {
+ this.setState({
+ isNamingModalOpen: true,
+ namingModalOptions: {
+ name: 'albumFolderFormat',
+ album: true
+ }
+ });
+ }
+
+ onNamingModalClose = () => {
+ this.setState({ isNamingModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ examples,
+ examplesPopulated,
+ onInputChange
+ } = this.props;
+
+ const {
+ isNamingModalOpen,
+ namingModalOptions
+ } = this.state;
+
+ const renameTracks = hasSettings && settings.renameTracks.value;
+
+ const standardTrackFormatHelpTexts = [];
+ const standardTrackFormatErrors = [];
+ const artistFolderFormatHelpTexts = [];
+ const artistFolderFormatErrors = [];
+ const albumFolderFormatHelpTexts = [];
+ const albumFolderFormatErrors = [];
+
+ if (examplesPopulated) {
+ if (examples.singleTrackExample) {
+ standardTrackFormatHelpTexts.push(`Single Track: ${examples.singleTrackExample}`);
+ } else {
+ standardTrackFormatErrors.push('Single Track: Invalid Format');
+ }
+
+ // if (examples.multiEpisodeExample) {
+ // standardTrackFormatHelpTexts.push(`Multi Episode: ${examples.multiEpisodeExample}`);
+ // } else {
+ // standardTrackFormatErrors.push('Multi Episode: Invalid Format');
+ // }
+
+ // if (examples.dailyEpisodeExample) {
+ // dailyEpisodeFormatHelpTexts.push(`Example: ${examples.dailyEpisodeExample}`);
+ // } else {
+ // dailyEpisodeFormatErrors.push('Invalid Format');
+ // }
+
+ // if (examples.animeEpisodeExample) {
+ // animeEpisodeFormatHelpTexts.push(`Single Episode: ${examples.animeEpisodeExample}`);
+ // } else {
+ // animeEpisodeFormatErrors.push('Single Episode: Invalid Format');
+ // }
+
+ // if (examples.animeMultiEpisodeExample) {
+ // animeEpisodeFormatHelpTexts.push(`Multi Episode: ${examples.animeMultiEpisodeExample}`);
+ // } else {
+ // animeEpisodeFormatErrors.push('Multi Episode: Invalid Format');
+ // }
+
+ if (examples.artistFolderExample) {
+ artistFolderFormatHelpTexts.push(`Example: ${examples.artistFolderExample}`);
+ } else {
+ artistFolderFormatErrors.push('Invalid Format');
+ }
+
+ if (examples.albumFolderExample) {
+ albumFolderFormatHelpTexts.push(`Example: ${examples.albumFolderExample}`);
+ } else {
+ albumFolderFormatErrors.push('Invalid Format');
+ }
+ }
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load Naming settings
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+ );
+ }
+
+}
+
+Naming.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ examples: PropTypes.object.isRequired,
+ examplesPopulated: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default Naming;
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js
new file mode 100644
index 000000000..04d200eb1
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js
@@ -0,0 +1,102 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import { fetchNamingSettings, setNamingSettingsValue, fetchNamingExamples } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import connectSection from 'Store/connectSection';
+import Naming from './Naming';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.namingExamples,
+ createSettingsSectionSelector(),
+ (advancedSettings, examples, sectionSettings) => {
+ return {
+ advancedSettings,
+ examples: examples.item,
+ examplesPopulated: !_.isEmpty(examples.item),
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNamingSettings,
+ setNamingSettingsValue,
+ fetchNamingExamples,
+ clearPendingChanges
+};
+
+class NamingConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this._namingExampleTimeout = null;
+ }
+
+ componentDidMount() {
+ this.props.fetchNamingSettings();
+ this.props.fetchNamingExamples();
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: this.props.section });
+ }
+
+ //
+ // Control
+
+ _fetchNamingExamples = () => {
+ this.props.fetchNamingExamples();
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setNamingSettingsValue({ name, value });
+
+ if (this._namingExampleTimeout) {
+ clearTimeout(this._namingExampleTimeout);
+ }
+
+ this._namingExampleTimeout = setTimeout(this._fetchNamingExamples, 1000);
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+NamingConnector.propTypes = {
+ section: PropTypes.string.isRequired,
+ fetchNamingSettings: PropTypes.func.isRequired,
+ setNamingSettingsValue: PropTypes.func.isRequired,
+ fetchNamingExamples: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'naming' }
+)(NamingConnector);
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.css b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css
new file mode 100644
index 000000000..708a763eb
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.css
@@ -0,0 +1,17 @@
+.groups {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ margin-bottom: 20px;
+}
+
+.namingSelectContainer {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.namingSelect {
+ composes: select from 'Components/Form/SelectInput.css';
+
+ width: 200px;
+}
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js
new file mode 100644
index 000000000..89b5a53a1
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js
@@ -0,0 +1,433 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { sizes } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Button from 'Components/Link/Button';
+import SelectInput from 'Components/Form/SelectInput';
+import TextInput from 'Components/Form/TextInput';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import NamingOption from './NamingOption';
+import styles from './NamingModal.css';
+
+class NamingModal extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ case: 'title'
+ };
+ }
+
+ //
+ // Listeners
+
+ onNamingCaseChange = (event) => {
+ this.setState({ case: event.value });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ value,
+ isOpen,
+ advancedSettings,
+ album,
+ track,
+ additional,
+ onInputChange,
+ onModalClose
+ } = this.props;
+
+ const namingOptions = [
+ { key: 'title', value: 'Default Case' },
+ { key: 'lower', value: 'Lower Case' },
+ { key: 'upper', value: 'Upper Case' }
+ ];
+
+ const fileNameTokens = [
+ {
+ token: '{Artist Name} - {Album Title} - {track:00} - {Track Title} {Quality Full}',
+ example: 'Artist Name - Album Title - 01 - Track Title MP3-320 Proper'
+ },
+ {
+ token: '{Artist.Name}.{Album.Title}.{track:00}.{TrackClean.Title}.{Quality.Full}',
+ example: 'Artist.Name.Album.Title.01.Track.Title.MP3-320'
+ }
+ ];
+
+ const artistTokens = [
+ { token: '{Artist Name}', example: 'Artist Name' },
+ { token: '{Artist.Name}', example: 'Artist.Name' },
+ { token: '{Artist_Name}', example: 'Artist_Name' },
+
+ { token: '{Artist NameThe}', example: 'Artist Name, The' },
+
+ { token: '{Artist CleanName}', example: 'Artist Name' },
+ { token: '{Artist.CleanName}', example: 'Artist.Name' },
+ { token: '{Artist_CleanName}', example: 'Artist_Name' }
+ ];
+
+ const albumTokens = [
+ { token: '{Album Title}', example: 'Album Title' },
+ { token: '{Album.Title}', example: 'Album.Title' },
+ { token: '{Album_Name}', example: 'Album_Name' },
+
+ { token: '{Album TitleThe}', example: 'Album Title, The' },
+
+ { token: '{Album CleanTitle}', example: 'Album Title' },
+ { token: '{Album.CleanTitle}', example: 'Album.Title' },
+ { token: '{Album_CleanTitle}', example: 'Album_Title' }
+ ];
+
+ const trackTokens = [
+ { token: '{track:0}', example: '1' },
+ { token: '{track:00}', example: '01' }
+ ];
+
+ const releaseDateTokens = [
+ { token: '{Release Year}', example: '2016' }
+ ];
+
+ const trackTitleTokens = [
+ { token: '{Track Title}', example: 'Track Title' },
+ { token: '{Track.Title}', example: 'Track.Title' },
+ { token: '{Track_Title}', example: 'Track_Title' },
+ { token: '{Track CleanTitle}', example: 'Track Title' },
+ { token: '{Track.CleanTitle}', example: 'Track.Title' },
+ { token: '{Track_CleanTitle}', example: 'Track_Title' }
+ ];
+
+ const qualityTokens = [
+ { token: '{Quality Full}', example: 'HDTV 720p Proper' },
+ { token: '{Quality-Full}', example: 'HDTV-720p-Proper' },
+ { token: '{Quality.Full}', example: 'HDTV.720p.Proper' },
+ { token: '{Quality_Full}', example: 'HDTV_720p_Proper' },
+ { token: '{Quality Title}', example: 'HDTV 720p' },
+ { token: '{Quality-Title}', example: 'HDTV-720p' },
+ { token: '{Quality.Title}', example: 'HDTV.720p' },
+ { token: '{Quality_Title}', example: 'HDTV_720p' }
+ ];
+
+ const mediaInfoTokens = [
+ { token: '{MediaInfo Simple}', example: 'x264 DTS' },
+ { token: '{MediaInfo.Simple}', example: 'x264.DTS' },
+ { token: '{MediaInfo_Simple}', example: 'x264_DTS' },
+ { token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]' },
+ { token: '{MediaInfo.Full}', example: 'x264.DTS.[EN+DE]' },
+ { token: '{MediaInfo_Full}', example: 'x264_DTS_[EN+DE]' },
+ { token: '{MediaInfo VideoCodec}', example: 'x264' },
+ { token: '{MediaInfo AudioFormat}', example: 'DTS' },
+ { token: '{MediaInfo AudioChannels}', example: '5.1' }
+ ];
+
+ const releaseGroupTokens = [
+ { token: '{Release Group}', example: 'Rls Grp' },
+ { token: '{Release.Group}', example: 'Rls.Grp' },
+ { token: '{Release_Group}', example: 'Rls_Grp' }
+ ];
+
+ const originalTokens = [
+ { token: '{Original Title}', example: 'Artist.Name.S01E01.HDTV.x264-EVOLVE' },
+ { token: '{Original Filename}', example: 'artist.name.s01e01.hdtv.x264-EVOLVE' }
+ ];
+
+ return (
+
+
+
+ File Name Tokens
+
+
+
+
+
+
+
+ {
+ !advancedSettings &&
+
+
+ {
+ fileNameTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+ }
+
+
+
+ {
+ artistTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ {
+ album &&
+
+
+
+ {
+ albumTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ releaseDateTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ }
+
+ {
+ track &&
+
+
+
+ {
+ trackTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+ }
+
+ {
+ additional &&
+
+
+
+ {
+ trackTitleTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ qualityTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ mediaInfoTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ releaseGroupTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+
+
+ {
+ originalTokens.map(({ token, example }) => {
+ return (
+
+ );
+ }
+ )
+ }
+
+
+
+ }
+
+
+
+
+
+ Close
+
+
+
+
+ );
+ }
+}
+
+NamingModal.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ isOpen: PropTypes.bool.isRequired,
+ advancedSettings: PropTypes.bool.isRequired,
+ album: PropTypes.bool.isRequired,
+ track: PropTypes.bool.isRequired,
+ additional: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+NamingModal.defaultProps = {
+ album: false,
+ track: false,
+ additional: false
+};
+
+export default NamingModal;
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css
new file mode 100644
index 000000000..299c98936
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css
@@ -0,0 +1,66 @@
+.option {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ margin: 3px;
+ border: 1px solid $borderColor;
+
+ &:hover {
+ .token {
+ background-color: #ddd;
+ }
+
+ .example {
+ background-color: #ccc;
+ }
+ }
+}
+
+.small {
+ width: 420px;
+}
+
+.large {
+ width: 100%;
+}
+
+.token {
+ flex: 0 0 50%;
+ padding: 6px 16px;
+ background-color: #eee;
+ font-family: $monoSpaceFontFamily;
+}
+
+.example {
+ flex: 0 0 50%;
+ padding: 6px 16px;
+ background-color: #ddd;
+}
+
+.lower {
+ text-transform: lowercase;
+}
+
+.upper {
+ text-transform: uppercase;
+}
+
+.isFullFilename {
+ .token,
+ .example {
+ flex: 1 0 auto;
+ }
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .option.small {
+ width: 100%;
+ }
+}
+
+@media only screen and (max-width: $breakpointExtraSmall) {
+ .token,
+ .example {
+ flex: 1 0 auto;
+ }
+}
diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.js b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js
new file mode 100644
index 000000000..ee8361a14
--- /dev/null
+++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js
@@ -0,0 +1,85 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { sizes } from 'Helpers/Props';
+import Link from 'Components/Link/Link';
+import styles from './NamingOption.css';
+
+class NamingOption extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ value,
+ token,
+ tokenCase,
+ isFullFilename,
+ onInputChange
+ } = this.props;
+
+ let newValue = token;
+
+ if (tokenCase === 'lower') {
+ newValue = token.toLowerCase();
+ } else if (tokenCase === 'upper') {
+ newValue = token.toUpperCase();
+ }
+
+ if (isFullFilename) {
+ onInputChange({ name, value: newValue });
+ } else {
+ onInputChange({
+ name,
+ value: `${value}${newValue}`
+ });
+ }
+ }
+
+ //
+ // Render
+ render() {
+ const {
+ token,
+ example,
+ tokenCase,
+ isFullFilename,
+ size
+ } = this.props;
+
+ return (
+
+ {token}
+ {example}
+
+ );
+ }
+}
+
+NamingOption.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ token: PropTypes.string.isRequired,
+ example: PropTypes.string.isRequired,
+ tokenCase: PropTypes.string.isRequired,
+ isFullFilename: PropTypes.bool.isRequired,
+ size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]),
+ onInputChange: PropTypes.func.isRequired
+};
+
+NamingOption.defaultProps = {
+ size: sizes.SMALL,
+ isFullFilename: false
+};
+
+export default NamingOption;
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js
new file mode 100644
index 000000000..98631932a
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditMetadataModalContentConnector from './EditMetadataModalContentConnector';
+
+function EditMetadataModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditMetadataModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditMetadataModal;
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js
new file mode 100644
index 000000000..4f80d5f14
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalConnector.js
@@ -0,0 +1,45 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditMetadataModal from './EditMetadataModal';
+
+function createMapDispatchToProps(dispatch, props) {
+ const section = 'metadata';
+
+ return {
+ dispatchClearPendingChanges() {
+ dispatch(clearPendingChanges({ section }));
+ }
+ };
+}
+
+class EditMetadataModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.dispatchClearPendingChanges();
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditMetadataModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ dispatchClearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(null, createMapDispatchToProps)(EditMetadataModalConnector);
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContent.js
new file mode 100644
index 000000000..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
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditMetadataModalContent.propTypes = {
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onDeleteMetadataPress: PropTypes.func
+};
+
+export default EditMetadataModalContent;
diff --git a/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js
new file mode 100644
index 000000000..2cd7636a0
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/EditMetadataModalContentConnector.js
@@ -0,0 +1,93 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import { setMetadataValue, setMetadataFieldValue, saveMetadata } from 'Store/Actions/settingsActions';
+import EditMetadataModalContent from './EditMetadataModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.metadata,
+ (id, metadata) => {
+ const {
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = metadata;
+
+ const settings = selectSettings(_.find(items, { id }), pendingChanges, saveError);
+
+ return {
+ id,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setMetadataValue,
+ setMetadataFieldValue,
+ saveMetadata
+};
+
+class EditMetadataModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setMetadataValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setMetadataFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveMetadata({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditMetadataModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setMetadataValue: PropTypes.func.isRequired,
+ setMetadataFieldValue: PropTypes.func.isRequired,
+ saveMetadata: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditMetadataModalContentConnector);
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.css b/frontend/src/Settings/Metadata/Metadata/Metadata.css
new file mode 100644
index 000000000..2de4023de
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadata.css
@@ -0,0 +1,17 @@
+.metadata {
+ composes: card from 'Components/Card.css';
+
+ width: 290px;
+}
+
+.name {
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.label {
+ composes: label from 'Components/Label.css';
+
+ width: 100%;
+}
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadata.js b/frontend/src/Settings/Metadata/Metadata/Metadata.js
new file mode 100644
index 000000000..fb6495f7c
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadata.js
@@ -0,0 +1,103 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import EditMetadataModalConnector from './EditMetadataModalConnector';
+import styles from './Metadata.css';
+
+function getKind(enable) {
+ if (enable) {
+ return kinds.SUCCESS;
+ }
+
+ return kinds.DANGER;
+}
+
+class Metadata extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditMetadataModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditMetadataPress = () => {
+ this.setState({ isEditMetadataModalOpen: true });
+ }
+
+ onEditMetadataModalClose = () => {
+ this.setState({ isEditMetadataModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ enable,
+ fields
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+
+ Enable
+
+
+
+
+ {
+ fields.map((field) => {
+ return (
+
+ {field.label}
+
+ );
+ })
+ }
+
+
+
+
+ );
+ }
+}
+
+Metadata.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ enable: PropTypes.bool.isRequired,
+ fields: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Metadata;
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.css b/frontend/src/Settings/Metadata/Metadata/Metadatas.css
new file mode 100644
index 000000000..fb1bd6080
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.css
@@ -0,0 +1,4 @@
+.metadatas {
+ display: flex;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Metadata/Metadata/Metadatas.js b/frontend/src/Settings/Metadata/Metadata/Metadatas.js
new file mode 100644
index 000000000..9f015369d
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/Metadatas.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import FieldSet from 'Components/FieldSet';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import Metadata from './Metadata';
+import styles from './Metadatas.css';
+
+function Metadatas(props) {
+ const {
+ items,
+ ...otherProps
+ } = props;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ );
+}
+
+Metadatas.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default Metadatas;
diff --git a/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js
new file mode 100644
index 000000000..fb7153950
--- /dev/null
+++ b/frontend/src/Settings/Metadata/Metadata/MetadatasConnector.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchMetadata } from 'Store/Actions/settingsActions';
+import Metadatas from './Metadatas';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.metadata,
+ (metadata) => {
+ return {
+ ...metadata
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchMetadata
+};
+
+class MetadatasConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchMetadata();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MetadatasConnector.propTypes = {
+ fetchMetadata: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MetadatasConnector);
diff --git a/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js
new file mode 100644
index 000000000..714ad15e2
--- /dev/null
+++ b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProvider.js
@@ -0,0 +1,75 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, sizes } 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 MetadataProvider(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange
+ } = props;
+
+ return (
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+
Unable to load Metadata Provider settings
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+
+ );
+}
+
+MetadataProvider.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default MetadataProvider;
diff --git a/frontend/src/Settings/Metadata/MetadataProvider/MetadataProviderConnector.js b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProviderConnector.js
new file mode 100644
index 000000000..153a929e9
--- /dev/null
+++ b/frontend/src/Settings/Metadata/MetadataProvider/MetadataProviderConnector.js
@@ -0,0 +1,92 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
+import { setMetadataProviderValue, saveMetadataProvider, fetchMetadataProvider } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import connectSection from 'Store/connectSection';
+import MetadataProvider from './MetadataProvider';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(),
+ (advancedSettings, sectionSettings) => {
+ return {
+ advancedSettings,
+ ...sectionSettings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setMetadataProviderValue,
+ saveMetadataProvider,
+ fetchMetadataProvider,
+ clearPendingChanges
+};
+
+class MetadataProviderConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchMetadataProvider();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.hasPendingChanges !== prevProps.hasPendingChanges) {
+ this.props.onHasPendingChange(this.props.hasPendingChanges);
+ }
+ }
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: this.props.section });
+ }
+
+ //
+ // Control
+
+ save = () => {
+ this.props.saveMetadataProvider();
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setMetadataProviderValue({ name, value });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MetadataProviderConnector.propTypes = {
+ section: PropTypes.string.isRequired,
+ hasPendingChanges: PropTypes.bool.isRequired,
+ setMetadataProviderValue: PropTypes.func.isRequired,
+ saveMetadataProvider: PropTypes.func.isRequired,
+ fetchMetadataProvider: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired,
+ onHasPendingChange: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ { withRef: true },
+ { section: 'metadataProvider' }
+ )(MetadataProviderConnector);
diff --git a/frontend/src/Settings/Metadata/MetadataSettings.js b/frontend/src/Settings/Metadata/MetadataSettings.js
new file mode 100644
index 000000000..3170787b2
--- /dev/null
+++ b/frontend/src/Settings/Metadata/MetadataSettings.js
@@ -0,0 +1,60 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import MetadatasConnector from './Metadata/MetadatasConnector';
+import MetadataProviderConnector from './MetadataProvider/MetadataProviderConnector';
+
+class MetadataSettings extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ setMetadataProviderRef = (ref) => {
+ this._metadataProvider = ref;
+ }
+
+ onHasPendingChange = (hasPendingChanges) => {
+ this.setState({
+ hasPendingChanges
+ });
+ }
+
+ onSavePress = () => {
+ this._metadataProvider.getWrappedInstance().save();
+ }
+
+ //
+ // Render
+ render() {
+ 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 &&
+
+
+ Custom
+
+
+
+
+ Presets
+
+
+
+ {
+ presets.map((preset) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+ }
+
+
+ More info
+
+
+
+
+ );
+ }
+}
+
+AddNotificationItem.propTypes = {
+ implementation: PropTypes.string.isRequired,
+ implementationName: PropTypes.string.isRequired,
+ infoLink: PropTypes.string.isRequired,
+ presets: PropTypes.arrayOf(PropTypes.object),
+ onNotificationSelect: PropTypes.func.isRequired
+};
+
+export default AddNotificationItem;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js
new file mode 100644
index 000000000..45f5e14b6
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import AddNotificationModalContentConnector from './AddNotificationModalContentConnector';
+
+function AddNotificationModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+AddNotificationModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNotificationModal;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css
new file mode 100644
index 000000000..8744e516c
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.css
@@ -0,0 +1,5 @@
+.notifications {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js
new file mode 100644
index 000000000..adb66d7da
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContent.js
@@ -0,0 +1,85 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import AddNotificationItem from './AddNotificationItem';
+import styles from './AddNotificationModalContent.css';
+
+class AddNotificationModalContent extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ error,
+ isPopulated,
+ schema,
+ onNotificationSelect,
+ onModalClose
+ } = this.props;
+
+ return (
+
+
+ Add Notification
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new notification, please try again.
+ }
+
+ {
+ isPopulated && !error &&
+
+
+ {
+ schema.map((notification) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+AddNotificationModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isPopulated: PropTypes.bool.isRequired,
+ schema: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onNotificationSelect: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default AddNotificationModalContent;
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationModalContentConnector.js
new file mode 100644
index 000000000..a65670eca
--- /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 {
+ isFetching,
+ error,
+ isPopulated,
+ schema
+ } = notifications;
+
+ return {
+ isFetching,
+ error,
+ isPopulated,
+ schema
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNotificationSchema,
+ selectNotificationSchema
+};
+
+class AddNotificationModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchNotificationSchema();
+ }
+
+ //
+ // Listeners
+
+ onNotificationSelect = ({ implementation, name }) => {
+ this.props.selectNotificationSchema({ implementation, presetName: name });
+ this.props.onModalClose({ notificationSelected: true });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AddNotificationModalContentConnector.propTypes = {
+ fetchNotificationSchema: PropTypes.func.isRequired,
+ selectNotificationSchema: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AddNotificationModalContentConnector);
diff --git a/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js
new file mode 100644
index 000000000..e4df85b8a
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/AddNotificationPresetMenuItem.js
@@ -0,0 +1,49 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class AddNotificationPresetMenuItem extends Component {
+
+ //
+ // Listeners
+
+ onPress = () => {
+ const {
+ name,
+ implementation
+ } = this.props;
+
+ this.props.onPress({
+ name,
+ implementation
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ implementation,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ {name}
+
+ );
+ }
+}
+
+AddNotificationPresetMenuItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ implementation: PropTypes.string.isRequired,
+ onPress: PropTypes.func.isRequired
+};
+
+export default AddNotificationPresetMenuItem;
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js
new file mode 100644
index 000000000..91d9f67cc
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditNotificationModalContentConnector from './EditNotificationModalContentConnector';
+
+function EditNotificationModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditNotificationModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditNotificationModal;
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalConnector.js
new file mode 100644
index 000000000..77566f9e1
--- /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 = '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..f375275f2
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js
@@ -0,0 +1,235 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Alert from 'Components/Alert';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
+import styles from './EditNotificationModalContent.css';
+
+function EditNotificationModalContent(props) {
+ const {
+ advancedSettings,
+ isFetching,
+ error,
+ isSaving,
+ isTesting,
+ saveError,
+ item,
+ onInputChange,
+ onFieldChange,
+ onModalClose,
+ onSavePress,
+ onTestPress,
+ onDeleteNotificationPress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ name,
+ onGrab,
+ onDownload,
+ onUpgrade,
+ onRename,
+ supportsOnGrab,
+ supportsOnDownload,
+ supportsOnUpgrade,
+ supportsOnRename,
+ tags,
+ fields,
+ message
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Notification' : 'Add Notification'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new notification, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+ Delete
+
+ }
+
+
+ Test
+
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditNotificationModalContent.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ isTesting: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onFieldChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onTestPress: PropTypes.func.isRequired,
+ onDeleteNotificationPress: PropTypes.func
+};
+
+export default EditNotificationModalContent;
diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js
new file mode 100644
index 000000000..bca296315
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContentConnector.js
@@ -0,0 +1,94 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { setNotificationValue, setNotificationFieldValue, saveNotification, testNotification } from 'Store/Actions/settingsActions';
+import connectSection from 'Store/connectSection';
+import EditNotificationModalContent from './EditNotificationModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createProviderSettingsSelector(),
+ (advancedSettings, notification) => {
+ return {
+ advancedSettings,
+ ...notification
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setNotificationValue,
+ setNotificationFieldValue,
+ saveNotification,
+ testNotification
+};
+
+class EditNotificationModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setNotificationValue({ name, value });
+ }
+
+ onFieldChange = ({ name, value }) => {
+ this.props.setNotificationFieldValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveNotification({ id: this.props.id });
+ }
+
+ onTestPress = () => {
+ this.props.testNotification({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditNotificationModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setNotificationValue: PropTypes.func.isRequired,
+ setNotificationFieldValue: PropTypes.func.isRequired,
+ saveNotification: PropTypes.func.isRequired,
+ testNotification: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'notifications' }
+)(EditNotificationModalContentConnector);
diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.css b/frontend/src/Settings/Notifications/Notifications/Notification.css
new file mode 100644
index 000000000..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..cca3761fc
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notification.js
@@ -0,0 +1,151 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditNotificationModalConnector from './EditNotificationModalConnector';
+import styles from './Notification.css';
+
+function getLabelKind(supports, enabled) {
+ if (!supports) {
+ return kinds.DEFAULT;
+ }
+
+ if (!enabled) {
+ return kinds.DANGER;
+ }
+
+ return kinds.SUCCESS;
+}
+
+class Notification extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditNotificationModalOpen: false,
+ isDeleteNotificationModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditNotificationPress = () => {
+ this.setState({ isEditNotificationModalOpen: true });
+ }
+
+ onEditNotificationModalClose = () => {
+ this.setState({ isEditNotificationModalOpen: false });
+ }
+
+ onDeleteNotificationPress = () => {
+ this.setState({
+ isEditNotificationModalOpen: false,
+ isDeleteNotificationModalOpen: true
+ });
+ }
+
+ onDeleteNotificationModalClose= () => {
+ this.setState({ isDeleteNotificationModalOpen: false });
+ }
+
+ onConfirmDeleteNotification = () => {
+ this.props.onConfirmDeleteNotification(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ onGrab,
+ onDownload,
+ onUpgrade,
+ onRename,
+ supportsOnGrab,
+ supportsOnDownload,
+ supportsOnUpgrade,
+ supportsOnRename
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+ On Grab
+
+
+
+ On Download
+
+
+
+ On Upgrade
+
+
+
+ On Rename
+
+
+
+
+
+
+ );
+ }
+}
+
+Notification.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ onGrab: PropTypes.bool.isRequired,
+ onDownload: PropTypes.bool.isRequired,
+ onUpgrade: PropTypes.bool.isRequired,
+ onRename: PropTypes.bool.isRequired,
+ supportsOnGrab: PropTypes.bool.isRequired,
+ supportsOnDownload: PropTypes.bool.isRequired,
+ supportsOnUpgrade: PropTypes.bool.isRequired,
+ supportsOnRename: PropTypes.bool.isRequired,
+ onConfirmDeleteNotification: PropTypes.func.isRequired
+};
+
+export default Notification;
diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.css b/frontend/src/Settings/Notifications/Notifications/Notifications.css
new file mode 100644
index 000000000..26b890e88
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notifications.css
@@ -0,0 +1,20 @@
+.notifications {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addNotification {
+ composes: notification from './Notification.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/Notifications/Notifications/Notifications.js b/frontend/src/Settings/Notifications/Notifications/Notifications.js
new file mode 100644
index 000000000..1fad8ef5f
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/Notifications.js
@@ -0,0 +1,117 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import Notification from './Notification';
+import AddNotificationModal from './AddNotificationModal';
+import EditNotificationModalConnector from './EditNotificationModalConnector';
+import styles from './Notifications.css';
+
+class Notifications extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddNotificationModalOpen: false,
+ isEditNotificationModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddNotificationPress = () => {
+ this.setState({ isAddNotificationModalOpen: true });
+ }
+
+ onAddNotificationModalClose = ({ notificationSelected = false } = {}) => {
+ this.setState({
+ isAddNotificationModalOpen: false,
+ isEditNotificationModalOpen: notificationSelected
+ });
+ }
+
+ onEditNotificationModalClose = () => {
+ this.setState({ isEditNotificationModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ onConfirmDeleteNotification,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddNotificationModalOpen,
+ isEditNotificationModalOpen
+ } = this.state;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Notifications.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteNotification: PropTypes.func.isRequired
+};
+
+export default Notifications;
diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js
new file mode 100644
index 000000000..b2b5e5166
--- /dev/null
+++ b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js
@@ -0,0 +1,58 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchNotifications, deleteNotification } from 'Store/Actions/settingsActions';
+import Notifications from './Notifications';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.notifications,
+ (notifications) => {
+ return {
+ ...notifications
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchNotifications,
+ deleteNotification
+};
+
+class NotificationsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchNotifications();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteNotification = (id) => {
+ this.props.deleteNotification({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+NotificationsConnector.propTypes = {
+ fetchNotifications: PropTypes.func.isRequired,
+ deleteNotification: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(NotificationsConnector);
diff --git a/frontend/src/Settings/PendingChangesModal.js b/frontend/src/Settings/PendingChangesModal.js
new file mode 100644
index 000000000..e3b14e228
--- /dev/null
+++ b/frontend/src/Settings/PendingChangesModal.js
@@ -0,0 +1,62 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+
+function PendingChangesModal(props) {
+ const {
+ isOpen,
+ onConfirm,
+ onCancel
+ } = props;
+
+ return (
+
+
+ Unsaved Changes
+
+
+ You have unsaved changes, are you sure you want to leave this page?
+
+
+
+
+ Stay and review changes
+
+
+
+ Discard changes and leave
+
+
+
+
+ );
+}
+
+PendingChangesModal.propTypes = {
+ className: PropTypes.string,
+ isOpen: PropTypes.bool.isRequired,
+ kind: PropTypes.oneOf(kinds.all),
+ onConfirm: PropTypes.func.isRequired,
+ onCancel: PropTypes.func.isRequired
+};
+
+PendingChangesModal.defaultProps = {
+ kind: kinds.PRIMARY
+};
+
+export default PendingChangesModal;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.css b/frontend/src/Settings/Profiles/Delay/DelayProfile.css
new file mode 100644
index 000000000..238742efd
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.css
@@ -0,0 +1,40 @@
+.delayProfile {
+ display: flex;
+ align-items: stretch;
+ margin-bottom: 10px;
+ height: 30px;
+ border-bottom: 1px solid $borderColor;
+ line-height: 30px;
+}
+
+.column {
+ flex: 0 0 200px;
+}
+
+.actions {
+ display: flex;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
+
+.editButton {
+ width: $dragHandleWidth;
+ text-align: center;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfile.js b/frontend/src/Settings/Profiles/Delay/DelayProfile.js
new file mode 100644
index 000000000..d72a06467
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfile.js
@@ -0,0 +1,172 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import titleCase from 'Utilities/String/titleCase';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import TagList from 'Components/TagList';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
+import styles from './DelayProfile.css';
+
+function getDelay(enabled, delay) {
+ if (!enabled) {
+ return '-';
+ }
+
+ if (!delay) {
+ return 'No Delay';
+ }
+
+ if (delay === 1) {
+ return '1 Minute';
+ }
+
+ // TODO: use better units of time than just minutes
+ return `${delay} Minutes`;
+}
+
+class DelayProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditDelayProfileModalOpen: false,
+ isDeleteDelayProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditDelayProfilePress = () => {
+ this.setState({ isEditDelayProfileModalOpen: true });
+ }
+
+ onEditDelayProfileModalClose = () => {
+ this.setState({ isEditDelayProfileModalOpen: false });
+ }
+
+ onDeleteDelayProfilePress = () => {
+ this.setState({
+ isEditDelayProfileModalOpen: false,
+ isDeleteDelayProfileModalOpen: true
+ });
+ }
+
+ onDeleteDelayProfileModalClose = () => {
+ this.setState({ isDeleteDelayProfileModalOpen: false });
+ }
+
+ onConfirmDeleteDelayProfile = () => {
+ this.props.onConfirmDeleteDelayProfile(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ enableUsenet,
+ enableTorrent,
+ preferredProtocol,
+ usenetDelay,
+ torrentDelay,
+ tags,
+ tagList,
+ isDragging,
+ connectDragSource
+ } = this.props;
+
+ let preferred = titleCase(preferredProtocol);
+
+ if (!enableUsenet) {
+ preferred = 'Only Torrent';
+ } else if (!enableTorrent) {
+ preferred = 'Only Usenet';
+ }
+
+ return (
+
+
{preferred}
+
{getDelay(enableUsenet, usenetDelay)}
+
{getDelay(enableTorrent, torrentDelay)}
+
+
+
+
+
+
+
+
+ {
+ id !== 1 &&
+ connectDragSource(
+
+
+
+ )
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+DelayProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ enableUsenet: PropTypes.bool.isRequired,
+ enableTorrent: PropTypes.bool.isRequired,
+ preferredProtocol: PropTypes.string.isRequired,
+ usenetDelay: PropTypes.number.isRequired,
+ torrentDelay: PropTypes.number.isRequired,
+ tags: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ connectDragSource: PropTypes.func,
+ onConfirmDeleteDelayProfile: PropTypes.func.isRequired
+};
+
+DelayProfile.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default DelayProfile;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css
new file mode 100644
index 000000000..cc5a92830
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.css
@@ -0,0 +1,3 @@
+.dragPreview {
+ opacity: 0.75;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js
new file mode 100644
index 000000000..402ddcc13
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragPreview.js
@@ -0,0 +1,78 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragLayer } from 'react-dnd';
+import dimensions from 'Styles/Variables/dimensions.js';
+import { DELAY_PROFILE } from 'Helpers/dragTypes';
+import DragPreviewLayer from 'Components/DragPreviewLayer';
+import DelayProfile from './DelayProfile';
+import styles from './DelayProfileDragPreview.css';
+
+const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
+
+function collectDragLayer(monitor) {
+ return {
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset()
+ };
+}
+
+class DelayProfileDragPreview extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ width,
+ item,
+ itemType,
+ currentOffset
+ } = this.props;
+
+ if (!currentOffset || itemType !== DELAY_PROFILE) {
+ return null;
+ }
+
+ // The offset is shifted because the drag handle is on the right edge of the
+ // list item and the preview is wider than the drag handle.
+
+ const { x, y } = currentOffset;
+ const handleOffset = width - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ width,
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+DelayProfileDragPreview.propTypes = {
+ width: PropTypes.number.isRequired,
+ item: PropTypes.object,
+ itemType: PropTypes.string,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired
+ })
+};
+
+export default DragLayer(collectDragLayer)(DelayProfileDragPreview);
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css
new file mode 100644
index 000000000..835250678
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.css
@@ -0,0 +1,17 @@
+.delayProfileDragSource {
+ padding: 4px 0;
+}
+
+.delayProfilePlaceholder {
+ width: 100%;
+ height: 30px;
+ border-bottom: 1px dotted #aaa;
+}
+
+.delayProfilePlaceholderBefore {
+ margin-bottom: 8px;
+}
+
+.delayProfilePlaceholderAfter {
+ margin-top: 8px;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js
new file mode 100644
index 000000000..5c1c565e0
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfileDragSource.js
@@ -0,0 +1,148 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { findDOMNode } from 'react-dom';
+import { DragSource, DropTarget } from 'react-dnd';
+import classNames from 'classnames';
+import { DELAY_PROFILE } from 'Helpers/dragTypes';
+import DelayProfile from './DelayProfile';
+import styles from './DelayProfileDragSource.css';
+
+const delayProfileDragSource = {
+ beginDrag(item) {
+ return item;
+ },
+
+ endDrag(props, monitor, component) {
+ props.onDelayProfileDragEnd(monitor.getItem(), monitor.didDrop());
+ }
+};
+
+const delayProfileDropTarget = {
+ hover(props, monitor, component) {
+ const dragIndex = monitor.getItem().order;
+ const hoverIndex = props.order;
+
+ const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ if (dragIndex === hoverIndex) {
+ return;
+ }
+
+ // When moving up, only trigger if drag position is above 50% and
+ // when moving down, only trigger if drag position is below 50%.
+ // If we're moving down the hoverIndex needs to be increased
+ // by one so it's ordered properly. Otherwise the hoverIndex will work.
+
+ if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) {
+ props.onDelayProfileDragMove(dragIndex, hoverIndex + 1);
+ } else if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
+ props.onDelayProfileDragMove(dragIndex, hoverIndex);
+ }
+ }
+};
+
+function collectDragSource(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging()
+ };
+}
+
+function collectDropTarget(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver()
+ };
+}
+
+class DelayProfileDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ order,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOver,
+ connectDragSource,
+ connectDropTarget,
+ ...otherProps
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOver;
+ const isAfter = !isDragging && isDraggingDown && isOver;
+
+ // if (isDragging && !isOver) {
+ // return null;
+ // }
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+DelayProfileDragSource.propTypes = {
+ id: PropTypes.number.isRequired,
+ order: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool,
+ isDraggingUp: PropTypes.bool,
+ isDraggingDown: PropTypes.bool,
+ isOver: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ connectDropTarget: PropTypes.func,
+ onDelayProfileDragMove: PropTypes.func.isRequired,
+ onDelayProfileDragEnd: PropTypes.func.isRequired
+};
+
+export default DropTarget(
+ DELAY_PROFILE,
+ delayProfileDropTarget,
+ collectDropTarget
+)(DragSource(
+ DELAY_PROFILE,
+ delayProfileDragSource,
+ collectDragSource
+)(DelayProfileDragSource));
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.css b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css
new file mode 100644
index 000000000..3cf3e9020
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.css
@@ -0,0 +1,27 @@
+.delayProfiles {
+ user-select: none;
+}
+
+.delayProfilesHeader {
+ display: flex;
+ margin-bottom: 10px;
+ font-weight: bold;
+}
+
+.column {
+ flex: 0 0 200px;
+}
+
+.tags {
+ flex: 1 0 auto;
+}
+
+.addDelayProfile {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.addButton {
+ width: $dragHandleWidth;
+ text-align: center;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.js b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js
new file mode 100644
index 000000000..7fc0cab2a
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js
@@ -0,0 +1,150 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Measure from 'react-measure';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import DelayProfileDragSource from './DelayProfileDragSource';
+import DelayProfileDragPreview from './DelayProfileDragPreview';
+import DelayProfile from './DelayProfile';
+import EditDelayProfileModalConnector from './EditDelayProfileModalConnector';
+import styles from './DelayProfiles.css';
+
+class DelayProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isAddDelayProfileModalOpen: false,
+ width: 0
+ };
+ }
+
+ //
+ // Listeners
+
+ onAddDelayProfilePress = () => {
+ this.setState({ isAddDelayProfileModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isAddDelayProfileModalOpen: false });
+ }
+
+ onMeasure = ({ width }) => {
+ this.setState({ width });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ defaultProfile,
+ items,
+ tagList,
+ dragIndex,
+ dropIndex,
+ onConfirmDeleteDelayProfile,
+ ...otherProps
+ } = this.props;
+
+ const {
+ isAddDelayProfileModalOpen,
+ width
+ } = this.state;
+
+ const isDragging = dropIndex !== null;
+ const isDraggingUp = isDragging && dropIndex < dragIndex;
+ const isDraggingDown = isDragging && dropIndex > dragIndex;
+
+ return (
+
+
+
+
+
Protocol
+
Usenet Delay
+
Torrent Delay
+
Tags
+
+
+
+ {
+ items.map((item, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+ {
+ defaultProfile &&
+
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+DelayProfiles.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ defaultProfile: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
+ dragIndex: PropTypes.number,
+ dropIndex: PropTypes.number,
+ onConfirmDeleteDelayProfile: PropTypes.func.isRequired
+};
+
+export default DelayProfiles;
diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js
new file mode 100644
index 000000000..16fe5718c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/DelayProfilesConnector.js
@@ -0,0 +1,105 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDelayProfiles, deleteDelayProfile, reorderDelayProfile } from 'Store/Actions/settingsActions';
+import createTagsSelector from 'Store/Selectors/createTagsSelector';
+import DelayProfiles from './DelayProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.delayProfiles,
+ createTagsSelector(),
+ (delayProfiles, tagList) => {
+ const defaultProfile = _.find(delayProfiles.items, { id: 1 });
+ const items = _.sortBy(_.reject(delayProfiles.items, { id: 1 }), ['order']);
+
+ return {
+ defaultProfile,
+ ...delayProfiles,
+ items,
+ tagList
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDelayProfiles,
+ deleteDelayProfile,
+ reorderDelayProfile
+};
+
+class DelayProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ dragIndex: null,
+ dropIndex: null
+ };
+ }
+
+ componentDidMount() {
+ this.props.fetchDelayProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteDelayProfile = (id) => {
+ this.props.deleteDelayProfile({ id });
+ }
+
+ onDelayProfileDragMove = (dragIndex, dropIndex) => {
+ if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
+ this.setState({
+ dragIndex,
+ dropIndex
+ });
+ }
+ }
+
+ onDelayProfileDragEnd = ({ id }, didDrop) => {
+ const {
+ dropIndex
+ } = this.state;
+
+ if (didDrop && dropIndex !== null) {
+ this.props.reorderDelayProfile({ id, moveIndex: dropIndex - 1 });
+ }
+
+ this.setState({
+ dragIndex: null,
+ dropIndex: null
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DelayProfilesConnector.propTypes = {
+ fetchDelayProfiles: PropTypes.func.isRequired,
+ deleteDelayProfile: PropTypes.func.isRequired,
+ reorderDelayProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DelayProfilesConnector);
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js
new file mode 100644
index 000000000..a593471ac
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditDelayProfileModalContentConnector from './EditDelayProfileModalContentConnector';
+
+function EditDelayProfileModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditDelayProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditDelayProfileModal;
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js
new file mode 100644
index 000000000..1f696c846
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditDelayProfileModal from './EditDelayProfileModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditDelayProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'delayProfiles' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditDelayProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditDelayProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css
new file mode 100644
index 000000000..a3c7f464c
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.css
@@ -0,0 +1,5 @@
+.deleteButton {
+ composes: button from 'Components/Link/Button.css';
+
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js
new file mode 100644
index 000000000..8dbee2837
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContent.js
@@ -0,0 +1,186 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import { boolSettingShape, numberSettingShape, tagSettingShape } from 'Helpers/Props/Shapes/settingShape';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Alert from 'Components/Alert';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import styles from './EditDelayProfileModalContent.css';
+
+function EditDelayProfileModalContent(props) {
+ const {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item,
+ protocol,
+ protocolOptions,
+ onInputChange,
+ onProtocolChange,
+ onSavePress,
+ onModalClose,
+ onDeleteDelayProfilePress,
+ ...otherProps
+ } = props;
+
+ const {
+ enableUsenet,
+ enableTorrent,
+ usenetDelay,
+ torrentDelay,
+ tags
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Delay Profile' : 'Add Delay Profile'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new quality profile, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id && id > 1 &&
+
+ Delete
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+const delayProfileShape = {
+ enableUsenet: PropTypes.shape(boolSettingShape).isRequired,
+ enableTorrent: PropTypes.shape(boolSettingShape).isRequired,
+ usenetDelay: PropTypes.shape(numberSettingShape).isRequired,
+ torrentDelay: PropTypes.shape(numberSettingShape).isRequired,
+ order: PropTypes.shape(numberSettingShape),
+ tags: PropTypes.shape(tagSettingShape).isRequired
+};
+
+EditDelayProfileModalContent.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.shape(delayProfileShape).isRequired,
+ protocol: PropTypes.string.isRequired,
+ protocolOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onProtocolChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteDelayProfilePress: PropTypes.func
+};
+
+export default EditDelayProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js
new file mode 100644
index 000000000..8cd001950
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Delay/EditDelayProfileModalContentConnector.js
@@ -0,0 +1,178 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+import { setDelayProfileValue, saveDelayProfile } from 'Store/Actions/settingsActions';
+import EditDelayProfileModalContent from './EditDelayProfileModalContent';
+
+const newDelayProfile = {
+ enableUsenet: true,
+ enableTorrent: true,
+ preferredProtocol: 'usenet',
+ usenetDelay: 0,
+ torrentDelay: 0,
+ tags: []
+};
+
+function createDelayProfileSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.settings.delayProfiles,
+ (id, delayProfiles) => {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ pendingChanges,
+ items
+ } = delayProfiles;
+
+ const profile = id ? _.find(items, { id }) : newDelayProfile;
+ const settings = selectSettings(profile, pendingChanges, saveError);
+
+ return {
+ id,
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createDelayProfileSelector(),
+ (delayProfile) => {
+ const protocolOptions = [
+ { key: 'preferUsenet', value: 'Prefer Usenet' },
+ { key: 'preferTorrent', value: 'Prefer Torrent' },
+ { key: 'onlyUsenet', value: 'Only Usenet' },
+ { key: 'onlyTorrent', value: 'Only Torrent' }
+ ];
+
+ const enableUsenet = delayProfile.item.enableUsenet.value;
+ const enableTorrent = delayProfile.item.enableTorrent.value;
+ const preferredProtocol = delayProfile.item.preferredProtocol.value;
+ let protocol = 'preferUsenet';
+
+ if (preferredProtocol === 'usenet') {
+ protocol = 'preferUsenet';
+ } else {
+ protocol = 'preferTorrent';
+ }
+
+ if (!enableUsenet) {
+ protocol = 'onlyTorrent';
+ }
+
+ if (!enableTorrent) {
+ protocol = 'onlyUsenet';
+ }
+
+ return {
+ protocol,
+ protocolOptions,
+ ...delayProfile
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ setDelayProfileValue,
+ saveDelayProfile
+};
+
+class EditDelayProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.id) {
+ Object.keys(newDelayProfile).forEach((name) => {
+ this.props.setDelayProfileValue({
+ name,
+ value: newDelayProfile[name]
+ });
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setDelayProfileValue({ name, value });
+ }
+
+ onProtocolChange = ({ value }) => {
+ switch (value) {
+ case 'preferUsenet':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' });
+ break;
+ case 'preferTorrent':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' });
+ break;
+ case 'onlyUsenet':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: true });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: false });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'usenet' });
+ break;
+ case 'onlyTorrent':
+ this.props.setDelayProfileValue({ name: 'enableUsenet', value: false });
+ this.props.setDelayProfileValue({ name: 'enableTorrent', value: true });
+ this.props.setDelayProfileValue({ name: 'preferredProtocol', value: 'torrent' });
+ break;
+ default:
+ throw Error(`Unknown protocol option: ${value}`);
+ }
+ }
+
+ onSavePress = () => {
+ this.props.saveDelayProfile({ id: this.props.id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditDelayProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setDelayProfileValue: PropTypes.func.isRequired,
+ saveDelayProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(EditDelayProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModal.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModal.js
new file mode 100644
index 000000000..7fe5a1823
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditLanguageProfileModalContentConnector from './EditLanguageProfileModalContentConnector';
+
+function EditLanguageProfileModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditLanguageProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditLanguageProfileModal;
diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalConnector.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalConnector.js
new file mode 100644
index 000000000..44866e2a3
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditLanguageProfileModal from './EditLanguageProfileModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditLanguageProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'languageProfiles' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditLanguageProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditLanguageProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.css b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.css
new file mode 100644
index 000000000..74dd1c8b7
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.css
@@ -0,0 +1,3 @@
+.deleteButtonContainer {
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js
new file mode 100644
index 000000000..f1686dcce
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContent.js
@@ -0,0 +1,149 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import LanguageProfileItems from './LanguageProfileItems';
+import styles from './EditLanguageProfileModalContent.css';
+
+function EditLanguageProfileModalContent(props) {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ languages,
+ item,
+ isInUse,
+ onInputChange,
+ onCutoffChange,
+ onSavePress,
+ onModalClose,
+ onDeleteLanguageProfilePress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ name,
+ cutoff,
+ languages: itemLanguages
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Language Profile' : 'Add Language Profile'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new language profile, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+
+ Delete
+
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditLanguageProfileModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ languages: PropTypes.arrayOf(PropTypes.object).isRequired,
+ item: PropTypes.object.isRequired,
+ isInUse: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onCutoffChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteLanguageProfilePress: PropTypes.func
+};
+
+export default EditLanguageProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js
new file mode 100644
index 000000000..13f72f623
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/EditLanguageProfileModalContentConnector.js
@@ -0,0 +1,194 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { fetchLanguageProfileSchema, setLanguageProfileValue, saveLanguageProfile } from 'Store/Actions/settingsActions';
+import connectSection from 'Store/connectSection';
+import EditLanguageProfileModalContent from './EditLanguageProfileModalContent';
+
+function createLanguagesSelector() {
+ return createSelector(
+ createProviderSettingsSelector(),
+ (languageProfile) => {
+ const languages = languageProfile.item.languages;
+ if (!languages || !languages.value) {
+ return [];
+ }
+
+ return _.reduceRight(languages.value, (result, { allowed, language }) => {
+ if (allowed) {
+ result.push({
+ key: language.id,
+ value: language.name
+ });
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createProviderSettingsSelector(),
+ createLanguagesSelector(),
+ createProfileInUseSelector('languageProfileId'),
+ (languageProfile, languages, isInUse) => {
+ return {
+ languages,
+ ...languageProfile,
+ isInUse
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchLanguageProfileSchema,
+ setLanguageProfileValue,
+ saveLanguageProfile
+};
+
+class EditLanguageProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ dragIndex: null,
+ dropIndex: null
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.id) {
+ this.props.fetchLanguageProfileSchema();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setLanguageProfileValue({ name, value });
+ }
+
+ onCutoffChange = ({ name, value }) => {
+ const id = parseInt(value);
+ const item = _.find(this.props.item.languages.value, (i) => i.language.id === id);
+
+ this.props.setLanguageProfileValue({ name, value: item.language });
+ }
+
+ onSavePress = () => {
+ this.props.saveLanguageProfile({ id: this.props.id });
+ }
+
+ onLanguageProfileItemAllowedChange = (id, allowed) => {
+ const languageProfile = _.cloneDeep(this.props.item);
+
+ const item = _.find(languageProfile.languages.value, (i) => i.language.id === id);
+ item.allowed = allowed;
+
+ this.props.setLanguageProfileValue({
+ name: 'languages',
+ value: languageProfile.languages.value
+ });
+
+ const cutoff = languageProfile.cutoff.value;
+
+ // If the cutoff isn't allowed anymore or there isn't a cutoff set one
+ if (!cutoff || !_.find(languageProfile.languages.value, (i) => i.language.id === cutoff.id).allowed) {
+ const firstAllowed = _.find(languageProfile.languages.value, { allowed: true });
+
+ this.props.setLanguageProfileValue({ name: 'cutoff', value: firstAllowed ? firstAllowed.language : null });
+ }
+ }
+
+ onLanguageProfileItemDragMove = (dragIndex, dropIndex) => {
+ if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
+ this.setState({
+ dragIndex,
+ dropIndex
+ });
+ }
+ }
+
+ onLanguageProfileItemDragEnd = ({ id }, didDrop) => {
+ const {
+ dragIndex,
+ dropIndex
+ } = this.state;
+
+ if (didDrop && dropIndex !== null) {
+ const languageProfile = _.cloneDeep(this.props.item);
+
+ const languages = languageProfile.languages.value.splice(dragIndex, 1);
+ languageProfile.languages.value.splice(dropIndex, 0, languages[0]);
+
+ this.props.setLanguageProfileValue({
+ name: 'languages',
+ value: languageProfile.languages.value
+ });
+ }
+
+ this.setState({
+ dragIndex: null,
+ dropIndex: null
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ if (_.isEmpty(this.props.item.languages) && !this.props.isFetching) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+EditLanguageProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setLanguageProfileValue: PropTypes.func.isRequired,
+ fetchLanguageProfileSchema: PropTypes.func.isRequired,
+ saveLanguageProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'languageProfiles' }
+)(EditLanguageProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfile.css b/frontend/src/Settings/Profiles/Language/LanguageProfile.css
new file mode 100644
index 000000000..d23bd1734
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfile.css
@@ -0,0 +1,19 @@
+.languageProfile {
+ composes: card from 'Components/Card.css';
+
+ width: 300px;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.languages {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfile.js b/frontend/src/Settings/Profiles/Language/LanguageProfile.js
new file mode 100644
index 000000000..a13a5509d
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfile.js
@@ -0,0 +1,124 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditLanguageProfileModalConnector from './EditLanguageProfileModalConnector';
+import styles from './LanguageProfile.css';
+
+class LanguageProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditLanguageProfileModalOpen: false,
+ isDeleteLanguageProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditLanguageProfilePress = () => {
+ this.setState({ isEditLanguageProfileModalOpen: true });
+ }
+
+ onEditLanguageProfileModalClose = () => {
+ this.setState({ isEditLanguageProfileModalOpen: false });
+ }
+
+ onDeleteLanguageProfilePress = () => {
+ this.setState({
+ isEditLanguageProfileModalOpen: false,
+ isDeleteLanguageProfileModalOpen: true
+ });
+ }
+
+ onDeleteLanguageProfileModalClose = () => {
+ this.setState({ isDeleteLanguageProfileModalOpen: false });
+ }
+
+ onConfirmDeleteLanguageProfile = () => {
+ this.props.onConfirmDeleteLanguageProfile(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ cutoff,
+ languages,
+ isDeleting
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+ {
+ languages.map((item) => {
+ if (!item.allowed) {
+ return null;
+ }
+
+ const isCutoff = item.language.id === cutoff.id;
+
+ return (
+
+ {item.language.name}
+
+ );
+ })
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+LanguageProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ cutoff: PropTypes.object.isRequired,
+ languages: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteLanguageProfile: PropTypes.func.isRequired
+};
+
+export default LanguageProfile;
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItem.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.css
new file mode 100644
index 000000000..a10233929
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.css
@@ -0,0 +1,44 @@
+.languageProfileItem {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background: #fafafa;
+}
+
+.checkContainer {
+ position: relative;
+ margin-right: 4px;
+ margin-bottom: 7px;
+ margin-left: 8px;
+}
+
+.languageName {
+ display: flex;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 2px;
+ font-weight: normal;
+ line-height: 36px;
+ cursor: pointer;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItem.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.js
new file mode 100644
index 000000000..2a3671268
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItem.js
@@ -0,0 +1,83 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './LanguageProfileItem.css';
+
+class LanguageProfileItem extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ languageId,
+ onLanguageProfileItemAllowedChange
+ } = this.props;
+
+ onLanguageProfileItemAllowedChange(languageId, value);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ allowed,
+ isDragging,
+ connectDragSource
+ } = this.props;
+
+ return (
+
+
+
+ {name}
+
+
+ {
+ connectDragSource(
+
+
+
+ )
+ }
+
+ );
+ }
+}
+
+LanguageProfileItem.propTypes = {
+ languageId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ sortIndex: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ connectDragSource: PropTypes.func,
+ onLanguageProfileItemAllowedChange: PropTypes.func
+};
+
+LanguageProfileItem.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default LanguageProfileItem;
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.css
new file mode 100644
index 000000000..b927d9bce
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.css
@@ -0,0 +1,4 @@
+.dragPreview {
+ width: 380px;
+ opacity: 0.75;
+}
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.js
new file mode 100644
index 000000000..0b6b27d8e
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragPreview.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragLayer } from 'react-dnd';
+import dimensions from 'Styles/Variables/dimensions.js';
+import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
+import DragPreviewLayer from 'Components/DragPreviewLayer';
+import LanguageProfileItem from './LanguageProfileItem';
+import styles from './LanguageProfileItemDragPreview.css';
+
+const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
+const formLabelWidth = parseInt(dimensions.formLabelWidth);
+const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
+const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
+
+function collectDragLayer(monitor) {
+ return {
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset()
+ };
+}
+
+class LanguageProfileItemDragPreview extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ item,
+ itemType,
+ currentOffset
+ } = this.props;
+
+ if (!currentOffset || itemType !== QUALITY_PROFILE_ITEM) {
+ return null;
+ }
+
+ // The offset is shifted because the drag handle is on the right edge of the
+ // list item and the preview is wider than the drag handle.
+
+ const { x, y } = currentOffset;
+ const handleOffset = formGroupSmallWidth - formLabelWidth - formLabelRightMarginWidth - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ const {
+ languageId,
+ name,
+ allowed,
+ sortIndex
+ } = item;
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+LanguageProfileItemDragPreview.propTypes = {
+ item: PropTypes.object,
+ itemType: PropTypes.string,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired
+ })
+};
+
+export default DragLayer(collectDragLayer)(LanguageProfileItemDragPreview);
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.css
new file mode 100644
index 000000000..f59379129
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.css
@@ -0,0 +1,18 @@
+.languageProfileItemDragSource {
+ padding: 4px 0;
+}
+
+.languageProfileItemPlaceholder {
+ width: 100%;
+ height: 36px;
+ border: 1px dotted #aaa;
+ border-radius: 4px;
+}
+
+.languageProfileItemPlaceholderBefore {
+ margin-bottom: 8px;
+}
+
+.languageProfileItemPlaceholderAfter {
+ margin-top: 8px;
+}
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.js
new file mode 100644
index 000000000..304363726
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItemDragSource.js
@@ -0,0 +1,157 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { findDOMNode } from 'react-dom';
+import { DragSource, DropTarget } from 'react-dnd';
+import classNames from 'classnames';
+import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
+import LanguageProfileItem from './LanguageProfileItem';
+import styles from './LanguageProfileItemDragSource.css';
+
+const languageProfileItemDragSource = {
+ beginDrag({ languageId, name, allowed, sortIndex }) {
+ return {
+ languageId,
+ name,
+ allowed,
+ sortIndex
+ };
+ },
+
+ endDrag(props, monitor, component) {
+ props.onLanguageProfileItemDragEnd(monitor.getItem(), monitor.didDrop());
+ }
+};
+
+const languageProfileItemDropTarget = {
+ hover(props, monitor, component) {
+ const dragIndex = monitor.getItem().sortIndex;
+ const hoverIndex = props.sortIndex;
+
+ const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ // Moving up, only trigger if drag position is above 50%
+ if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) {
+ return;
+ }
+
+ // Moving down, only trigger if drag position is below 50%
+ if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
+ return;
+ }
+
+ props.onLanguageProfileItemDragMove(dragIndex, hoverIndex);
+ }
+};
+
+function collectDragSource(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging()
+ };
+}
+
+function collectDropTarget(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver()
+ };
+}
+
+class LanguageProfileItemDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ languageId,
+ name,
+ allowed,
+ sortIndex,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOver,
+ connectDragSource,
+ connectDropTarget,
+ onLanguageProfileItemAllowedChange
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOver;
+ const isAfter = !isDragging && isDraggingDown && isOver;
+
+ // if (isDragging && !isOver) {
+ // return null;
+ // }
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+LanguageProfileItemDragSource.propTypes = {
+ languageId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ sortIndex: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool,
+ isDraggingUp: PropTypes.bool,
+ isDraggingDown: PropTypes.bool,
+ isOver: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ connectDropTarget: PropTypes.func,
+ onLanguageProfileItemAllowedChange: PropTypes.func.isRequired,
+ onLanguageProfileItemDragMove: PropTypes.func.isRequired,
+ onLanguageProfileItemDragEnd: PropTypes.func.isRequired
+};
+
+export default DropTarget(
+ QUALITY_PROFILE_ITEM,
+ languageProfileItemDropTarget,
+ collectDropTarget
+)(DragSource(
+ QUALITY_PROFILE_ITEM,
+ languageProfileItemDragSource,
+ collectDragSource
+)(LanguageProfileItemDragSource));
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItems.css b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.css
new file mode 100644
index 000000000..48b30f326
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.css
@@ -0,0 +1,6 @@
+.languages {
+ margin-top: 10px;
+ /* TODO: This should consider the number of languages in the list */
+ min-height: 550px;
+ user-select: none;
+}
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileItems.js b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.js
new file mode 100644
index 000000000..831743cbe
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileItems.js
@@ -0,0 +1,103 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import LanguageProfileItemDragSource from './LanguageProfileItemDragSource';
+import LanguageProfileItemDragPreview from './LanguageProfileItemDragPreview';
+import styles from './LanguageProfileItems.css';
+
+class LanguageProfileItems extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ dragIndex,
+ dropIndex,
+ languageProfileItems,
+ errors,
+ warnings,
+ ...otherProps
+ } = this.props;
+
+ const isDragging = dropIndex !== null;
+ const isDraggingUp = isDragging && dropIndex > dragIndex;
+ const isDraggingDown = isDragging && dropIndex < dragIndex;
+
+ return (
+
+ Languages
+
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+ {
+ languageProfileItems.map(({ allowed, language }, index) => {
+ return (
+
+ );
+ }).reverse()
+ }
+
+
+
+
+
+ );
+ }
+}
+
+LanguageProfileItems.propTypes = {
+ dragIndex: PropTypes.number,
+ dropIndex: PropTypes.number,
+ languageProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object)
+};
+
+LanguageProfileItems.defaultProps = {
+ errors: [],
+ warnings: []
+};
+
+export default LanguageProfileItems;
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfileNameConnector.js b/frontend/src/Settings/Profiles/Language/LanguageProfileNameConnector.js
new file mode 100644
index 000000000..61a7153b5
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfileNameConnector.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector';
+
+function createMapStateToProps() {
+ return createSelector(
+ createLanguageProfileSelector(),
+ (languageProfile) => {
+ return {
+ name: languageProfile.name
+ };
+ }
+ );
+}
+
+function LanguageProfileNameConnector({ name, ...otherProps }) {
+ return (
+
+ {name}
+
+ );
+}
+
+LanguageProfileNameConnector.propTypes = {
+ languageProfileId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired
+};
+
+export default connect(createMapStateToProps)(LanguageProfileNameConnector);
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfiles.css b/frontend/src/Settings/Profiles/Language/LanguageProfiles.css
new file mode 100644
index 000000000..5a2fb73bd
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfiles.css
@@ -0,0 +1,21 @@
+.languageProfiles {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addLanguageProfile {
+ composes: languageProfile from './LanguageProfile.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+ font-size: 45px;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfiles.js b/frontend/src/Settings/Profiles/Language/LanguageProfiles.js
new file mode 100644
index 000000000..2c2df29aa
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfiles.js
@@ -0,0 +1,102 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import LanguageProfile from './LanguageProfile';
+import EditLanguageProfileModalConnector from './EditLanguageProfileModalConnector';
+import styles from './LanguageProfiles.css';
+
+class LanguageProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isLanguageProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditLanguageProfilePress = () => {
+ this.setState({ isLanguageProfileModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isLanguageProfileModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ isDeleting,
+ onConfirmDeleteLanguageProfile,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+LanguageProfiles.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteLanguageProfile: PropTypes.func.isRequired
+};
+
+export default LanguageProfiles;
diff --git a/frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js b/frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js
new file mode 100644
index 000000000..2dc8967eb
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Language/LanguageProfilesConnector.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchLanguageProfiles, deleteLanguageProfile } from 'Store/Actions/settingsActions';
+import LanguageProfiles from './LanguageProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.languageProfiles,
+ (advancedSettings, languageProfiles) => {
+ return {
+ advancedSettings,
+ ...languageProfiles
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchLanguageProfiles,
+ deleteLanguageProfile
+};
+
+class LanguageProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchLanguageProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteLanguageProfile = (id) => {
+ this.props.deleteLanguageProfile({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+LanguageProfilesConnector.propTypes = {
+ fetchLanguageProfiles: PropTypes.func.isRequired,
+ deleteLanguageProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(LanguageProfilesConnector);
diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js
new file mode 100644
index 000000000..ed288ca9b
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Profiles.js
@@ -0,0 +1,36 @@
+import React, { Component } from 'react';
+import { DragDropContext } from 'react-dnd';
+import HTML5Backend from 'react-dnd-html5-backend';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import QualityProfilesConnector from './Quality/QualityProfilesConnector';
+import LanguageProfilesConnector from './Language/LanguageProfilesConnector';
+import DelayProfilesConnector from './Delay/DelayProfilesConnector';
+
+class Profiles extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+// Only a single DragDropContext can exist so it's done here to allow editing
+// quality profiles and reordering delay profiles to work.
+
+export default DragDropContext(HTML5Backend)(Profiles);
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js
new file mode 100644
index 000000000..13539e302
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModal.js
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import EditQualityProfileModalContentConnector from './EditQualityProfileModalContentConnector';
+
+function EditQualityProfileModal({ isOpen, onModalClose, ...otherProps }) {
+ return (
+
+
+
+ );
+}
+
+EditQualityProfileModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default EditQualityProfileModal;
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js
new file mode 100644
index 000000000..5ec77950f
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalConnector.js
@@ -0,0 +1,43 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import EditQualityProfileModal from './EditQualityProfileModal';
+
+function mapStateToProps() {
+ return {};
+}
+
+const mapDispatchToProps = {
+ clearPendingChanges
+};
+
+class EditQualityProfileModalConnector extends Component {
+
+ //
+ // Listeners
+
+ onModalClose = () => {
+ this.props.clearPendingChanges({ section: 'qualityProfiles' });
+ this.props.onModalClose();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+EditQualityProfileModalConnector.propTypes = {
+ onModalClose: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(EditQualityProfileModalConnector);
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css
new file mode 100644
index 000000000..74dd1c8b7
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.css
@@ -0,0 +1,3 @@
+.deleteButtonContainer {
+ margin-right: auto;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js
new file mode 100644
index 000000000..21c504e4f
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js
@@ -0,0 +1,149 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { inputTypes, kinds } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import Form from 'Components/Form/Form';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputGroup from 'Components/Form/FormInputGroup';
+import QualityProfileItems from './QualityProfileItems';
+import styles from './EditQualityProfileModalContent.css';
+
+function EditQualityProfileModalContent(props) {
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ qualities,
+ item,
+ isInUse,
+ onInputChange,
+ onCutoffChange,
+ onSavePress,
+ onModalClose,
+ onDeleteQualityProfilePress,
+ ...otherProps
+ } = props;
+
+ const {
+ id,
+ name,
+ cutoff,
+ items
+ } = item;
+
+ return (
+
+
+ {id ? 'Edit Quality Profile' : 'Add Quality Profile'}
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && !!error &&
+ Unable to add a new quality profile, please try again.
+ }
+
+ {
+ !isFetching && !error &&
+
+ }
+
+
+ {
+ id &&
+
+
+ Delete
+
+
+ }
+
+
+ Cancel
+
+
+
+ Save
+
+
+
+ );
+}
+
+EditQualityProfileModalContent.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ item: PropTypes.object.isRequired,
+ isInUse: PropTypes.bool.isRequired,
+ onInputChange: PropTypes.func.isRequired,
+ onCutoffChange: PropTypes.func.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired,
+ onDeleteQualityProfilePress: PropTypes.func
+};
+
+export default EditQualityProfileModalContent;
diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js
new file mode 100644
index 000000000..9de5080ca
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContentConnector.js
@@ -0,0 +1,194 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { createSelector } from 'reselect';
+import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector';
+import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
+import { fetchQualityProfileSchema, setQualityProfileValue, saveQualityProfile } from 'Store/Actions/settingsActions';
+import connectSection from 'Store/connectSection';
+import EditQualityProfileModalContent from './EditQualityProfileModalContent';
+
+function createQualitiesSelector() {
+ return createSelector(
+ createProviderSettingsSelector(),
+ (qualityProfile) => {
+ const items = qualityProfile.item.items;
+ if (!items || !items.value) {
+ return [];
+ }
+
+ return _.reduceRight(items.value, (result, { allowed, quality }) => {
+ if (allowed) {
+ result.push({
+ key: quality.id,
+ value: quality.name
+ });
+ }
+
+ return result;
+ }, []);
+ }
+ );
+}
+
+function createMapStateToProps() {
+ return createSelector(
+ createProviderSettingsSelector(),
+ createQualitiesSelector(),
+ createProfileInUseSelector('qualityProfileId'),
+ (qualityProfile, qualities, isInUse) => {
+ return {
+ qualities,
+ ...qualityProfile,
+ isInUse
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQualityProfileSchema,
+ setQualityProfileValue,
+ saveQualityProfile
+};
+
+class EditQualityProfileModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ dragIndex: null,
+ dropIndex: null
+ };
+ }
+
+ componentDidMount() {
+ if (!this.props.id) {
+ this.props.fetchQualityProfileSchema();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
+ this.props.onModalClose();
+ }
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setQualityProfileValue({ name, value });
+ }
+
+ onCutoffChange = ({ name, value }) => {
+ const id = parseInt(value);
+ const item = _.find(this.props.item.items.value, (i) => i.quality.id === id);
+
+ this.props.setQualityProfileValue({ name, value: item.quality });
+ }
+
+ onSavePress = () => {
+ this.props.saveQualityProfile({ id: this.props.id });
+ }
+
+ onQualityProfileItemAllowedChange = (id, allowed) => {
+ const qualityProfile = _.cloneDeep(this.props.item);
+
+ const item = _.find(qualityProfile.items.value, (i) => i.quality.id === id);
+ item.allowed = allowed;
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: qualityProfile.items.value
+ });
+
+ const cutoff = qualityProfile.cutoff.value;
+
+ // If the cutoff isn't allowed anymore or there isn't a cutoff set one
+ if (!cutoff || !_.find(qualityProfile.items.value, (i) => i.quality.id === cutoff.id).allowed) {
+ const firstAllowed = _.find(qualityProfile.items.value, { allowed: true });
+
+ this.props.setQualityProfileValue({ name: 'cutoff', value: firstAllowed ? firstAllowed.quality : null });
+ }
+ }
+
+ onQualityProfileItemDragMove = (dragIndex, dropIndex) => {
+ if (this.state.dragIndex !== dragIndex || this.state.dropIndex !== dropIndex) {
+ this.setState({
+ dragIndex,
+ dropIndex
+ });
+ }
+ }
+
+ onQualityProfileItemDragEnd = ({ id }, didDrop) => {
+ const {
+ dragIndex,
+ dropIndex
+ } = this.state;
+
+ if (didDrop && dropIndex !== null) {
+ const qualityProfile = _.cloneDeep(this.props.item);
+
+ const items = qualityProfile.items.value.splice(dragIndex, 1);
+ qualityProfile.items.value.splice(dropIndex, 0, items[0]);
+
+ this.props.setQualityProfileValue({
+ name: 'items',
+ value: qualityProfile.items.value
+ });
+ }
+
+ this.setState({
+ dragIndex: null,
+ dropIndex: null
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ if (_.isEmpty(this.props.item.items) && !this.props.isFetching) {
+ return null;
+ }
+
+ return (
+
+ );
+ }
+}
+
+EditQualityProfileModalContentConnector.propTypes = {
+ id: PropTypes.number,
+ isFetching: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ saveError: PropTypes.object,
+ item: PropTypes.object.isRequired,
+ setQualityProfileValue: PropTypes.func.isRequired,
+ fetchQualityProfileSchema: PropTypes.func.isRequired,
+ saveQualityProfile: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'qualityProfiles' }
+)(EditQualityProfileModalContentConnector);
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.css b/frontend/src/Settings/Profiles/Quality/QualityProfile.css
new file mode 100644
index 000000000..1ac9041bd
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.css
@@ -0,0 +1,19 @@
+.qualityProfile {
+ composes: card from 'Components/Card.css';
+
+ width: 300px;
+}
+
+.name {
+ @add-mixin truncate;
+
+ margin-bottom: 20px;
+ font-weight: 300;
+ font-size: 24px;
+}
+
+.qualities {
+ display: flex;
+ flex-wrap: wrap;
+ margin-top: 5px;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfile.js b/frontend/src/Settings/Profiles/Quality/QualityProfile.js
new file mode 100644
index 000000000..211656ab1
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfile.js
@@ -0,0 +1,124 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import Card from 'Components/Card';
+import Label from 'Components/Label';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
+import styles from './QualityProfile.css';
+
+class QualityProfile extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isEditQualityProfileModalOpen: false,
+ isDeleteQualityProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditQualityProfilePress = () => {
+ this.setState({ isEditQualityProfileModalOpen: true });
+ }
+
+ onEditQualityProfileModalClose = () => {
+ this.setState({ isEditQualityProfileModalOpen: false });
+ }
+
+ onDeleteQualityProfilePress = () => {
+ this.setState({
+ isEditQualityProfileModalOpen: false,
+ isDeleteQualityProfileModalOpen: true
+ });
+ }
+
+ onDeleteQualityProfileModalClose = () => {
+ this.setState({ isDeleteQualityProfileModalOpen: false });
+ }
+
+ onConfirmDeleteQualityProfile = () => {
+ this.props.onConfirmDeleteQualityProfile(this.props.id);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ name,
+ cutoff,
+ items,
+ isDeleting
+ } = this.props;
+
+ return (
+
+
+ {name}
+
+
+
+ {
+ items.map((item) => {
+ if (!item.allowed) {
+ return null;
+ }
+
+ const isCutoff = item.quality.id === cutoff.id;
+
+ return (
+
+ {item.quality.name}
+
+ );
+ })
+ }
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfile.propTypes = {
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ cutoff: PropTypes.object.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ isDeleting: PropTypes.bool.isRequired,
+ onConfirmDeleteQualityProfile: PropTypes.func.isRequired
+};
+
+export default QualityProfile;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css
new file mode 100644
index 000000000..90d48a2c5
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.css
@@ -0,0 +1,44 @@
+.qualityProfileItem {
+ display: flex;
+ align-items: stretch;
+ width: 100%;
+ border: 1px solid #aaa;
+ border-radius: 4px;
+ background: #fafafa;
+}
+
+.checkContainer {
+ position: relative;
+ margin-right: 4px;
+ margin-bottom: 7px;
+ margin-left: 8px;
+}
+
+.qualityName {
+ display: flex;
+ flex-grow: 1;
+ margin-bottom: 0;
+ margin-left: 2px;
+ font-weight: normal;
+ line-height: 36px;
+ cursor: pointer;
+}
+
+.dragHandle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ width: $dragHandleWidth;
+ text-align: center;
+ cursor: grab;
+}
+
+.dragIcon {
+ top: 0;
+}
+
+.isDragging {
+ opacity: 0.25;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js
new file mode 100644
index 000000000..1a0fa0114
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItem.js
@@ -0,0 +1,83 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import CheckInput from 'Components/Form/CheckInput';
+import styles from './QualityProfileItem.css';
+
+class QualityProfileItem extends Component {
+
+ //
+ // Listeners
+
+ onAllowedChange = ({ value }) => {
+ const {
+ qualityId,
+ onQualityProfileItemAllowedChange
+ } = this.props;
+
+ onQualityProfileItemAllowedChange(qualityId, value);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ name,
+ allowed,
+ isDragging,
+ connectDragSource
+ } = this.props;
+
+ return (
+
+
+
+ {name}
+
+
+ {
+ connectDragSource(
+
+
+
+ )
+ }
+
+ );
+ }
+}
+
+QualityProfileItem.propTypes = {
+ qualityId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ sortIndex: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ connectDragSource: PropTypes.func,
+ onQualityProfileItemAllowedChange: PropTypes.func
+};
+
+QualityProfileItem.defaultProps = {
+ // The drag preview will not connect the drag handle.
+ connectDragSource: (node) => node
+};
+
+export default QualityProfileItem;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css
new file mode 100644
index 000000000..b927d9bce
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.css
@@ -0,0 +1,4 @@
+.dragPreview {
+ width: 380px;
+ opacity: 0.75;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js
new file mode 100644
index 000000000..1fd249714
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragPreview.js
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { DragLayer } from 'react-dnd';
+import dimensions from 'Styles/Variables/dimensions.js';
+import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
+import DragPreviewLayer from 'Components/DragPreviewLayer';
+import QualityProfileItem from './QualityProfileItem';
+import styles from './QualityProfileItemDragPreview.css';
+
+const formGroupSmallWidth = parseInt(dimensions.formGroupSmallWidth);
+const formLabelWidth = parseInt(dimensions.formLabelWidth);
+const formLabelRightMarginWidth = parseInt(dimensions.formLabelRightMarginWidth);
+const dragHandleWidth = parseInt(dimensions.dragHandleWidth);
+
+function collectDragLayer(monitor) {
+ return {
+ item: monitor.getItem(),
+ itemType: monitor.getItemType(),
+ currentOffset: monitor.getSourceClientOffset()
+ };
+}
+
+class QualityProfileItemDragPreview extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ item,
+ itemType,
+ currentOffset
+ } = this.props;
+
+ if (!currentOffset || itemType !== QUALITY_PROFILE_ITEM) {
+ return null;
+ }
+
+ // The offset is shifted because the drag handle is on the right edge of the
+ // list item and the preview is wider than the drag handle.
+
+ const { x, y } = currentOffset;
+ const handleOffset = formGroupSmallWidth - formLabelWidth - formLabelRightMarginWidth - dragHandleWidth;
+ const transform = `translate3d(${x - handleOffset}px, ${y}px, 0)`;
+
+ const style = {
+ position: 'absolute',
+ WebkitTransform: transform,
+ msTransform: transform,
+ transform
+ };
+
+ const {
+ qualityId,
+ name,
+ allowed,
+ sortIndex
+ } = item;
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfileItemDragPreview.propTypes = {
+ item: PropTypes.object,
+ itemType: PropTypes.string,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired
+ })
+};
+
+export default DragLayer(collectDragLayer)(QualityProfileItemDragPreview);
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css
new file mode 100644
index 000000000..5b9f36fe9
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.css
@@ -0,0 +1,18 @@
+.qualityProfileItemDragSource {
+ padding: 4px 0;
+}
+
+.qualityProfileItemPlaceholder {
+ width: 100%;
+ height: 36px;
+ border: 1px dotted #aaa;
+ border-radius: 4px;
+}
+
+.qualityProfileItemPlaceholderBefore {
+ margin-bottom: 8px;
+}
+
+.qualityProfileItemPlaceholderAfter {
+ margin-top: 8px;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js
new file mode 100644
index 000000000..ed8adc107
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItemDragSource.js
@@ -0,0 +1,157 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { findDOMNode } from 'react-dom';
+import { DragSource, DropTarget } from 'react-dnd';
+import classNames from 'classnames';
+import { QUALITY_PROFILE_ITEM } from 'Helpers/dragTypes';
+import QualityProfileItem from './QualityProfileItem';
+import styles from './QualityProfileItemDragSource.css';
+
+const qualityProfileItemDragSource = {
+ beginDrag({ qualityId, name, allowed, sortIndex }) {
+ return {
+ qualityId,
+ name,
+ allowed,
+ sortIndex
+ };
+ },
+
+ endDrag(props, monitor, component) {
+ props.onQualityProfileItemDragEnd(monitor.getItem(), monitor.didDrop());
+ }
+};
+
+const qualityProfileItemDropTarget = {
+ hover(props, monitor, component) {
+ const dragIndex = monitor.getItem().sortIndex;
+ const hoverIndex = props.sortIndex;
+
+ const hoverBoundingRect = findDOMNode(component).getBoundingClientRect();
+ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
+ const clientOffset = monitor.getClientOffset();
+ const hoverClientY = clientOffset.y - hoverBoundingRect.top;
+
+ // Moving up, only trigger if drag position is above 50%
+ if (dragIndex < hoverIndex && hoverClientY > hoverMiddleY) {
+ return;
+ }
+
+ // Moving down, only trigger if drag position is below 50%
+ if (dragIndex > hoverIndex && hoverClientY < hoverMiddleY) {
+ return;
+ }
+
+ props.onQualityProfileItemDragMove(dragIndex, hoverIndex);
+ }
+};
+
+function collectDragSource(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging()
+ };
+}
+
+function collectDropTarget(connect, monitor) {
+ return {
+ connectDropTarget: connect.dropTarget(),
+ isOver: monitor.isOver()
+ };
+}
+
+class QualityProfileItemDragSource extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ qualityId,
+ name,
+ allowed,
+ sortIndex,
+ isDragging,
+ isDraggingUp,
+ isDraggingDown,
+ isOver,
+ connectDragSource,
+ connectDropTarget,
+ onQualityProfileItemAllowedChange
+ } = this.props;
+
+ const isBefore = !isDragging && isDraggingUp && isOver;
+ const isAfter = !isDragging && isDraggingDown && isOver;
+
+ // if (isDragging && !isOver) {
+ // return null;
+ // }
+
+ return connectDropTarget(
+
+ {
+ isBefore &&
+
+ }
+
+
+
+ {
+ isAfter &&
+
+ }
+
+ );
+ }
+}
+
+QualityProfileItemDragSource.propTypes = {
+ qualityId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ allowed: PropTypes.bool.isRequired,
+ sortIndex: PropTypes.number.isRequired,
+ isDragging: PropTypes.bool,
+ isDraggingUp: PropTypes.bool,
+ isDraggingDown: PropTypes.bool,
+ isOver: PropTypes.bool,
+ connectDragSource: PropTypes.func,
+ connectDropTarget: PropTypes.func,
+ onQualityProfileItemAllowedChange: PropTypes.func.isRequired,
+ onQualityProfileItemDragMove: PropTypes.func.isRequired,
+ onQualityProfileItemDragEnd: PropTypes.func.isRequired
+};
+
+export default DropTarget(
+ QUALITY_PROFILE_ITEM,
+ qualityProfileItemDropTarget,
+ collectDropTarget
+)(DragSource(
+ QUALITY_PROFILE_ITEM,
+ qualityProfileItemDragSource,
+ collectDragSource
+)(QualityProfileItemDragSource));
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css
new file mode 100644
index 000000000..344df5b08
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.css
@@ -0,0 +1,6 @@
+.qualities {
+ margin-top: 10px;
+ /* TODO: This should consider the number of qualities in the list */
+ min-height: 550px;
+ user-select: none;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js
new file mode 100644
index 000000000..5a58da630
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js
@@ -0,0 +1,103 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FormGroup from 'Components/Form/FormGroup';
+import FormLabel from 'Components/Form/FormLabel';
+import FormInputHelpText from 'Components/Form/FormInputHelpText';
+import QualityProfileItemDragSource from './QualityProfileItemDragSource';
+import QualityProfileItemDragPreview from './QualityProfileItemDragPreview';
+import styles from './QualityProfileItems.css';
+
+class QualityProfileItems extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ dragIndex,
+ dropIndex,
+ qualityProfileItems,
+ errors,
+ warnings,
+ ...otherProps
+ } = this.props;
+
+ const isDragging = dropIndex !== null;
+ const isDraggingUp = isDragging && dropIndex > dragIndex;
+ const isDraggingDown = isDragging && dropIndex < dragIndex;
+
+ return (
+
+ Qualities
+
+
+
+ {
+ errors.map((error, index) => {
+ return (
+
+ );
+ })
+ }
+
+ {
+ warnings.map((warning, index) => {
+ return (
+
+ );
+ })
+ }
+
+
+ {
+ qualityProfileItems.map(({ allowed, quality }, index) => {
+ return (
+
+ );
+ }).reverse()
+ }
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfileItems.propTypes = {
+ dragIndex: PropTypes.number,
+ dropIndex: PropTypes.number,
+ qualityProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired,
+ errors: PropTypes.arrayOf(PropTypes.object),
+ warnings: PropTypes.arrayOf(PropTypes.object)
+};
+
+QualityProfileItems.defaultProps = {
+ errors: [],
+ warnings: []
+};
+
+export default QualityProfileItems;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js
new file mode 100644
index 000000000..bf13815ff
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfileNameConnector.js
@@ -0,0 +1,31 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
+
+function createMapStateToProps() {
+ return createSelector(
+ createQualityProfileSelector(),
+ (qualityProfile) => {
+ return {
+ name: qualityProfile.name
+ };
+ }
+ );
+}
+
+function QualityProfileNameConnector({ name, ...otherProps }) {
+ return (
+
+ {name}
+
+ );
+}
+
+QualityProfileNameConnector.propTypes = {
+ qualityProfileId: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired
+};
+
+export default connect(createMapStateToProps)(QualityProfileNameConnector);
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.css b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css
new file mode 100644
index 000000000..9644a7c2d
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.css
@@ -0,0 +1,21 @@
+.qualityProfiles {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.addQualityProfile {
+ composes: qualityProfile from './QualityProfile.css';
+
+ background-color: $cardAlternateBackgroundColor;
+ color: $gray;
+ text-align: center;
+ font-size: 45px;
+}
+
+.center {
+ display: inline-block;
+ padding: 5px 20px 0;
+ border: 1px solid $borderColor;
+ border-radius: 4px;
+ background-color: $white;
+}
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfiles.js b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js
new file mode 100644
index 000000000..75f049695
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfiles.js
@@ -0,0 +1,102 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import sortByName from 'Utilities/Array/sortByName';
+import { icons } from 'Helpers/Props';
+import FieldSet from 'Components/FieldSet';
+import Card from 'Components/Card';
+import Icon from 'Components/Icon';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import QualityProfile from './QualityProfile';
+import EditQualityProfileModalConnector from './EditQualityProfileModalConnector';
+import styles from './QualityProfiles.css';
+
+class QualityProfiles extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isQualityProfileModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onEditQualityProfilePress = () => {
+ this.setState({ isQualityProfileModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isQualityProfileModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ isDeleting,
+ onConfirmDeleteQualityProfile,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+ {
+ items.sort(sortByName).map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+QualityProfiles.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ isDeleting: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onConfirmDeleteQualityProfile: PropTypes.func.isRequired
+};
+
+export default QualityProfiles;
diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js
new file mode 100644
index 000000000..4bb1529ee
--- /dev/null
+++ b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js
@@ -0,0 +1,60 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQualityProfiles, deleteQualityProfile } from 'Store/Actions/settingsActions';
+import QualityProfiles from './QualityProfiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ (state) => state.settings.qualityProfiles,
+ (advancedSettings, qualityProfiles) => {
+ return {
+ advancedSettings,
+ ...qualityProfiles
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQualityProfiles,
+ deleteQualityProfile
+};
+
+class QualityProfilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchQualityProfiles();
+ }
+
+ //
+ // Listeners
+
+ onConfirmDeleteQualityProfile = (id) => {
+ this.props.deleteQualityProfile({ id });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityProfilesConnector.propTypes = {
+ fetchQualityProfiles: PropTypes.func.isRequired,
+ deleteQualityProfile: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector);
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css b/frontend/src/Settings/Quality/Definition/QualityDefinition.css
new file mode 100644
index 000000000..69e8018f5
--- /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: text from 'Components/Form/TextInput.css';
+
+ display: inline-block;
+ margin-left: 5px;
+ padding: 6px;
+ width: 75px;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .qualityDefinition {
+ flex-wrap: wrap;
+ height: auto;
+
+ &:first-child {
+ border-top: none;
+ }
+ }
+
+ .qualityDefinition:first-child {
+ border-top: none;
+ }
+
+ .quality {
+ font-weight: bold;
+ line-height: inherit;
+ }
+
+ .sizeLimit {
+ margin-top: 10px;
+ }
+}
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.js b/frontend/src/Settings/Quality/Definition/QualityDefinition.js
new file mode 100644
index 000000000..b1c715d8c
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js
@@ -0,0 +1,166 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import ReactSlider from 'react-slider';
+import formatBytes from 'Utilities/Number/formatBytes';
+import { kinds } from 'Helpers/Props';
+import Label from 'Components/Label';
+import NumberInput from 'Components/Form/NumberInput';
+import TextInput from 'Components/Form/TextInput';
+import styles from './QualityDefinition.css';
+
+const slider = {
+ min: 0,
+ max: 200,
+ step: 0.1
+};
+
+function getValue(value) {
+ if (value < slider.min) {
+ return slider.min;
+ }
+
+ if (value > slider.max) {
+ return slider.max;
+ }
+
+ return value;
+}
+
+class QualityDefinition extends Component {
+
+ //
+ // Listeners
+
+ onSizeChange = ([minSize, maxSize]) => {
+ maxSize = maxSize === slider.max ? null : maxSize;
+
+ this.props.onSizeChange({ minSize, maxSize });
+ }
+
+ onMinSizeChange = ({ value }) => {
+ const minSize = getValue(value);
+
+ this.props.onSizeChange({
+ minSize,
+ maxSize: this.props.maxSize
+ });
+ }
+
+ onMaxSizeChange = ({ value }) => {
+ const maxSize = value === slider.max ? null : getValue(value);
+
+ this.props.onSizeChange({
+ minSize: this.props.minSize,
+ maxSize
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ id,
+ quality,
+ title,
+ minSize,
+ maxSize,
+ advancedSettings,
+ onTitleChange
+ } = this.props;
+
+ const minBytes = minSize * 1024 * 1024;
+ const minThirty = formatBytes(minBytes * 30, 2);
+ const minSixty = formatBytes(minBytes * 60, 2);
+
+ const maxBytes = maxSize && maxSize * 1024 * 1024;
+ const maxThirty = maxBytes ? formatBytes(maxBytes * 30, 2) : 'Unlimited';
+ const maxSixty = maxBytes ? formatBytes(maxBytes * 60, 2) : 'Unlimited';
+
+ return (
+
+
+ {quality.name}
+
+
+
+
+
+
+
+
+
+
+
+ {minThirty}
+ {minSixty}
+
+
+
+ {maxThirty}
+ {maxSixty}
+
+
+
+
+ {
+ advancedSettings &&
+
+
+ Min
+
+
+
+
+
+ Max
+
+
+
+
+ }
+
+ );
+ }
+}
+
+QualityDefinition.propTypes = {
+ id: PropTypes.number.isRequired,
+ quality: PropTypes.object.isRequired,
+ title: PropTypes.string.isRequired,
+ minSize: PropTypes.number,
+ maxSize: PropTypes.number,
+ advancedSettings: PropTypes.bool.isRequired,
+ onTitleChange: PropTypes.func.isRequired,
+ onSizeChange: PropTypes.func.isRequired
+};
+
+export default QualityDefinition;
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js
new file mode 100644
index 000000000..be83cc069
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionConnector.js
@@ -0,0 +1,70 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { setQualityDefinitionValue } from 'Store/Actions/settingsActions';
+import { clearPendingChanges } from 'Store/Actions/baseActions';
+import QualityDefinition from './QualityDefinition';
+
+function mapStateToProps(state) {
+ return {
+ advancedSettings: state.settings.advancedSettings
+ };
+}
+
+const mapDispatchToProps = {
+ setQualityDefinitionValue,
+ clearPendingChanges
+};
+
+class QualityDefinitionConnector extends Component {
+
+ componentWillUnmount() {
+ this.props.clearPendingChanges({ section: 'qualityDefinitions' });
+ }
+
+ //
+ // Listeners
+
+ onTitleChange = ({ value }) => {
+ this.props.setQualityDefinitionValue({ id: this.props.id, name: 'title', value });
+ }
+
+ onSizeChange = ({ minSize, maxSize }) => {
+ const {
+ id,
+ minSize: currentMinSize,
+ maxSize: currentMaxSize
+ } = this.props;
+
+ if (minSize !== currentMinSize) {
+ this.props.setQualityDefinitionValue({ id, name: 'minSize', value: minSize });
+ }
+
+ if (minSize !== currentMaxSize) {
+ this.props.setQualityDefinitionValue({ id, name: 'maxSize', value: maxSize });
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityDefinitionConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ minSize: PropTypes.number,
+ maxSize: PropTypes.number,
+ setQualityDefinitionValue: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(QualityDefinitionConnector);
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.css b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css
new file mode 100644
index 000000000..689017684
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.css
@@ -0,0 +1,41 @@
+.header {
+ display: flex;
+ font-weight: bold;
+}
+
+.quality,
+.title {
+ flex: 0 1 250px;
+}
+
+.sizeLimit {
+ flex: 0 1 500px;
+}
+
+.megabytesPerMinute {
+ flex: 0 0 250px;
+}
+
+.sizeLimitHelpTextContainer {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 20px;
+ max-width: 1000px;
+}
+
+.sizeLimitHelpText {
+ max-width: 500px;
+ color: $helpTextColor;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ .header {
+ display: none;
+ }
+
+ .definitions {
+ &:first-child {
+ border-top: none;
+ }
+ }
+}
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitions.js b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js
new file mode 100644
index 000000000..dab22ea60
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitions.js
@@ -0,0 +1,65 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import FieldSet from 'Components/FieldSet';
+import PageSectionContent from 'Components/Page/PageSectionContent';
+import QualityDefinitionConnector from './QualityDefinitionConnector';
+import styles from './QualityDefinitions.css';
+
+class QualityDefinitions extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ items,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
Quality
+
Title
+
Size Limit
+
Megabytes Per Minute
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+ Limits are automatically adjusted for the album duration.
+
+
+
+
+ );
+ }
+}
+
+QualityDefinitions.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ defaultProfile: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired
+};
+
+export default QualityDefinitions;
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js
new file mode 100644
index 000000000..2170919be
--- /dev/null
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinitionsConnector.js
@@ -0,0 +1,78 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchQualityDefinitions, saveQualityDefinitions } from 'Store/Actions/settingsActions';
+import QualityDefinitions from './QualityDefinitions';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.qualityDefinitions,
+ (qualityDefinitions) => {
+ const items = qualityDefinitions.items.map((item) => {
+ const pendingChanges = qualityDefinitions.pendingChanges[item.id] || {};
+
+ return Object.assign({}, item, pendingChanges);
+ });
+
+ return {
+ ...qualityDefinitions,
+ items,
+ hasPendingChanges: !_.isEmpty(qualityDefinitions.pendingChanges)
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchQualityDefinitions,
+ saveQualityDefinitions
+};
+
+class QualityDefinitionsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchQualityDefinitions();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ hasPendingChanges
+ } = this.props;
+
+ if (hasPendingChanges !== prevProps.hasPendingChanges) {
+ this.props.onHasPendingChange(hasPendingChanges);
+ }
+ }
+
+ //
+ // Control
+
+ save = () => {
+ this.props.saveQualityDefinitions();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+QualityDefinitionsConnector.propTypes = {
+ hasPendingChanges: PropTypes.bool.isRequired,
+ fetchQualityDefinitions: PropTypes.func.isRequired,
+ saveQualityDefinitions: PropTypes.func.isRequired,
+ onHasPendingChange: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps, null, { withRef: true })(QualityDefinitionsConnector);
diff --git a/frontend/src/Settings/Quality/Quality.js b/frontend/src/Settings/Quality/Quality.js
new file mode 100644
index 000000000..ed58dac67
--- /dev/null
+++ b/frontend/src/Settings/Quality/Quality.js
@@ -0,0 +1,59 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
+import QualityDefinitionsConnector from './Definition/QualityDefinitionsConnector';
+
+class Quality extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ hasPendingChanges: false
+ };
+ }
+
+ //
+ // Listeners
+
+ setQualityDefinitionsRef = (ref) => {
+ this._qualityDefinitions = ref;
+ }
+
+ onHasPendingChange = (hasPendingChanges) => {
+ this.setState({
+ hasPendingChanges
+ });
+ }
+
+ onSavePress = () => {
+ this._qualityDefinitions.getWrappedInstance().save();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default Quality;
diff --git a/frontend/src/Settings/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..c39830f63
--- /dev/null
+++ b/frontend/src/Settings/Settings.js
@@ -0,0 +1,122 @@
+import React from 'react';
+import Link from 'Components/Link/Link';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import SettingsToolbarConnector from './SettingsToolbarConnector';
+import styles from './Settings.css';
+
+function Settings() {
+ return (
+
+
+
+
+
+ Media Management
+
+
+
+ Naming and file management settings
+
+
+
+ Profiles
+
+
+
+ Quality, Language and Delay profiles
+
+
+
+ Quality
+
+
+
+ Quality sizes and naming
+
+
+
+ Indexers
+
+
+
+ Indexers and release restrictions
+
+
+
+ Download Clients
+
+
+
+ Download clients, download handling and remote path mappings
+
+
+
+ Connect
+
+
+
+ Notifications, connections to media servers/players and custom scripts
+
+
+
+ Metadata
+
+
+
+ Create metadata files when episodes are imported or artist are refreshed
+
+
+
+ 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.css b/frontend/src/Settings/SettingsToolbar.css
new file mode 100644
index 000000000..2d3aa1c6f
--- /dev/null
+++ b/frontend/src/Settings/SettingsToolbar.css
@@ -0,0 +1,7 @@
+.advancedSettings {
+ composes: toolbarButton from 'Components/Page/Toolbar/PageToolbarButton.css';
+}
+
+.advancedSettingsEnabled {
+ color: $toobarButtonHoverColor;
+}
diff --git a/frontend/src/Settings/SettingsToolbar.js b/frontend/src/Settings/SettingsToolbar.js
new file mode 100644
index 000000000..0d758ce23
--- /dev/null
+++ b/frontend/src/Settings/SettingsToolbar.js
@@ -0,0 +1,104 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import classNames from 'classnames';
+import { icons } from 'Helpers/Props';
+import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PendingChangesModal from './PendingChangesModal';
+import styles from './SettingsToolbar.css';
+
+class SettingsToolbar extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.bindShortcut(shortcuts.SAVE_SETTINGS.key, this.saveSettings, { isGlobal: true });
+ }
+
+ //
+ // Control
+
+ saveSettings = (event) => {
+ event.preventDefault();
+
+ const {
+ hasPendingChanges,
+ onSavePress
+ } = this.props;
+
+ if (hasPendingChanges) {
+ onSavePress();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ advancedSettings,
+ showSave,
+ isSaving,
+ hasPendingChanges,
+ hasPendingLocation,
+ onSavePress,
+ onConfirmNavigation,
+ onCancelNavigation,
+ onAdvancedSettingsPress
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ showSave &&
+
+ }
+
+
+
+ );
+ }
+}
+
+SettingsToolbar.propTypes = {
+ advancedSettings: PropTypes.bool.isRequired,
+ showSave: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool,
+ hasPendingLocation: PropTypes.bool.isRequired,
+ hasPendingChanges: PropTypes.bool,
+ onSavePress: PropTypes.func,
+ onAdvancedSettingsPress: PropTypes.func.isRequired,
+ onConfirmNavigation: PropTypes.func.isRequired,
+ onCancelNavigation: PropTypes.func.isRequired,
+ bindShortcut: PropTypes.func.isRequired
+};
+
+SettingsToolbar.defaultProps = {
+ showSave: true
+};
+
+export default keyboardShortcuts(SettingsToolbar);
diff --git a/frontend/src/Settings/SettingsToolbarConnector.js b/frontend/src/Settings/SettingsToolbarConnector.js
new file mode 100644
index 000000000..8bfb3dad5
--- /dev/null
+++ b/frontend/src/Settings/SettingsToolbarConnector.js
@@ -0,0 +1,147 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router-dom';
+import { toggleAdvancedSettings } from 'Store/Actions/settingsActions';
+import SettingsToolbar from './SettingsToolbar';
+
+function mapStateToProps(state) {
+ return {
+ advancedSettings: state.settings.advancedSettings
+ };
+}
+
+const mapDispatchToProps = {
+ toggleAdvancedSettings
+};
+
+class SettingsToolbarConnector extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ nextLocation: null,
+ nextLocationAction: null,
+ confirmed: false
+ };
+
+ this._unblock = null;
+ }
+
+ componentDidMount() {
+ this._unblock = this.props.history.block(this.routerWillLeave);
+ }
+
+ componentWillUnmount() {
+ if (this._unblock) {
+ this._unblock();
+ }
+ }
+
+ //
+ // Control
+
+ routerWillLeave = (nextLocation, nextLocationAction) => {
+ if (this.state.confirmed) {
+ this.setState({
+ nextLocation: null,
+ nextLocationAction: null,
+ confirmed: false
+ });
+
+ return true;
+ }
+
+ if (this.props.hasPendingChanges ) {
+ this.setState({
+ nextLocation,
+ nextLocationAction
+ });
+
+ return false;
+ }
+
+ return true;
+ }
+
+ //
+ // Listeners
+
+ onAdvancedSettingsPress = () => {
+ this.props.toggleAdvancedSettings();
+ }
+
+ onConfirmNavigation = () => {
+ const {
+ nextLocation,
+ nextLocationAction
+ } = this.state;
+
+ const history = this.props.history;
+
+ const path = `${nextLocation.pathname}${nextLocation.search}`;
+
+ this.setState({
+ confirmed: true
+ }, () => {
+ if (nextLocationAction === 'PUSH') {
+ history.push(path);
+ } else {
+ // Unfortunately back and forward both use POP,
+ // which means we don't actually know which direction
+ // the user wanted to go, assuming back.
+
+ history.goBack();
+ }
+ });
+ }
+
+ onCancelNavigation = () => {
+ this.setState({
+ nextLocation: null,
+ nextLocationAction: null,
+ confirmed: false
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ const hasPendingLocation = this.state.nextLocation !== null;
+
+ return (
+
+ );
+ }
+}
+
+const historyShape = {
+ block: PropTypes.func.isRequired,
+ goBack: PropTypes.func.isRequired,
+ push: PropTypes.func.isRequired
+};
+
+SettingsToolbarConnector.propTypes = {
+ hasPendingChanges: PropTypes.bool.isRequired,
+ history: PropTypes.shape(historyShape).isRequired,
+ onSavePress: PropTypes.func,
+ toggleAdvancedSettings: PropTypes.func.isRequired
+};
+
+SettingsToolbarConnector.defaultProps = {
+ hasPendingChanges: false
+};
+
+export default withRouter(connect(mapStateToProps, mapDispatchToProps)(SettingsToolbarConnector));
diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js
new file mode 100644
index 000000000..c443e4819
--- /dev/null
+++ b/frontend/src/Settings/UI/UISettings.js
@@ -0,0 +1,197 @@
+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';
+
+class UISettings extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ error,
+ settings,
+ hasSettings,
+ onInputChange,
+ onSavePress,
+ ...otherProps
+ } = this.props;
+
+ const firstDayOfWeekOptions = [
+ { key: 0, value: 'Sunday' },
+ { key: 1, value: 'Monday' }
+ ];
+
+ 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/3' },
+ { 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' }
+ ];
+
+ const timeFormatOptions = [
+ { key: 'h(:mm)a', value: '5pm/5:30pm' },
+ { key: 'HH:mm', value: '17:00/17:30' }
+ ];
+
+ return (
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching && error &&
+ Unable to load UI settings
+ }
+
+ {
+ hasSettings && !isFetching && !error &&
+
+ }
+
+
+ );
+ }
+
+}
+
+UISettings.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ settings: PropTypes.object.isRequired,
+ hasSettings: PropTypes.bool.isRequired,
+ onSavePress: PropTypes.func.isRequired,
+ onInputChange: PropTypes.func.isRequired
+};
+
+export default UISettings;
diff --git a/frontend/src/Settings/UI/UISettingsConnector.js b/frontend/src/Settings/UI/UISettingsConnector.js
new file mode 100644
index 000000000..70c9bdd14
--- /dev/null
+++ b/frontend/src/Settings/UI/UISettingsConnector.js
@@ -0,0 +1,82 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+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 connectSection from 'Store/connectSection';
+import UISettings from './UISettings';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.settings.advancedSettings,
+ createSettingsSectionSelector(),
+ (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: this.props.section });
+ }
+
+ //
+ // Listeners
+
+ onInputChange = ({ name, value }) => {
+ this.props.setUISettingsValue({ name, value });
+ }
+
+ onSavePress = () => {
+ this.props.saveUISettings();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+UISettingsConnector.propTypes = {
+ section: PropTypes.string.isRequired,
+ setUISettingsValue: PropTypes.func.isRequired,
+ saveUISettings: PropTypes.func.isRequired,
+ fetchUISettings: PropTypes.func.isRequired,
+ clearPendingChanges: PropTypes.func.isRequired
+};
+
+export default connectSection(
+ createMapStateToProps,
+ mapDispatchToProps,
+ undefined,
+ undefined,
+ { section: 'ui' }
+)(UISettingsConnector);
diff --git a/frontend/src/Shared/piwikCheck.js b/frontend/src/Shared/piwikCheck.js
new file mode 100644
index 000000000..b9e18fdbb
--- /dev/null
+++ b/frontend/src/Shared/piwikCheck.js
@@ -0,0 +1,10 @@
+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/createBatchToggleAlbumMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js
new file mode 100644
index 000000000..6d93633a8
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createBatchToggleAlbumMonitoredHandler.js
@@ -0,0 +1,41 @@
+import $ from 'jquery';
+import updateAlbums from 'Utilities/Album/updateAlbums';
+
+function createBatchToggleAlbumMonitoredHandler(section, getFromState) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ const {
+ albumIds,
+ monitored
+ } = payload;
+
+ const state = getFromState(getState());
+
+ updateAlbums(dispatch, section, state.items, albumIds, {
+ isSaving: true
+ });
+
+ const promise = $.ajax({
+ url: '/album/monitor',
+ method: 'PUT',
+ data: JSON.stringify({ albumIds, monitored }),
+ dataType: 'json'
+ });
+
+ promise.done(() => {
+ updateAlbums(dispatch, section, state.items, albumIds, {
+ isSaving: false,
+ monitored
+ });
+ });
+
+ promise.fail(() => {
+ updateAlbums(dispatch, section, state.items, albumIds, {
+ isSaving: false
+ });
+ });
+ };
+ };
+}
+
+export default createBatchToggleAlbumMonitoredHandler;
diff --git a/frontend/src/Store/Actions/Creators/createFetchHandler.js b/frontend/src/Store/Actions/Creators/createFetchHandler.js
new file mode 100644
index 000000000..5bf31b92e
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchHandler.js
@@ -0,0 +1,46 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { set, update, updateItem } from '../baseActions';
+
+function createFetchHandler(section, url) {
+ return function(payload = {}) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isFetching: true }));
+
+ const {
+ id,
+ ...otherPayload
+ } = payload;
+
+ const promise = $.ajax({
+ url: id == null ? url : `${url}/${id}`,
+ data: otherPayload,
+ traditional: true
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ id == null ? update({ section, data }) : updateItem({ section, ...data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ };
+ };
+}
+
+export default createFetchHandler;
diff --git a/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js
new file mode 100644
index 000000000..e58811ee4
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchSchemaHandler.js
@@ -0,0 +1,35 @@
+import $ from 'jquery';
+import { set } from '../baseActions';
+
+function createFetchSchemaHandler(section, url) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isFetchingSchema: true }));
+
+ const promise = $.ajax({
+ url
+ });
+
+ promise.done((data) => {
+ dispatch(set({
+ section,
+ isFetchingSchema: false,
+ schemaPopulated: true,
+ schemaError: null,
+ schema: data
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetchingSchema: false,
+ schemaPopulated: true,
+ schemaError: xhr
+ }));
+ });
+ };
+ };
+}
+
+export default createFetchSchemaHandler;
diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js
new file mode 100644
index 000000000..4f8d779a8
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js
@@ -0,0 +1,54 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { set, updateServerSideCollection } from '../baseActions';
+
+function createFetchServerSideCollectionHandler(section, url, getFromState) {
+ return function(payload = {}) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isFetching: true }));
+
+ const state = getFromState(getState());
+ const sectionState = state.hasOwnProperty(section) ? state[section] : state;
+ const page = payload.page || sectionState.page || 1;
+
+ const data = Object.assign({ page },
+ _.pick(sectionState, [
+ 'pageSize',
+ 'sortDirection',
+ 'sortKey',
+ 'filterKey',
+ 'filterValue'
+ ]));
+
+ const promise = $.ajax({
+ url,
+ data
+ });
+
+ promise.done((response) => {
+ dispatch(batchActions([
+ updateServerSideCollection({ section, data: response }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ };
+ };
+}
+
+export default createFetchServerSideCollectionHandler;
diff --git a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js
new file mode 100644
index 000000000..f09a05948
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js
@@ -0,0 +1,47 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { set, removeItem } from '../baseActions';
+
+function createRemoveItemHandler(section, url) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ const {
+ id,
+ ...queryParms
+ } = payload;
+
+ dispatch(set({ section, isDeleting: true }));
+
+ const ajaxOptions = {
+ url: `${url}/${id}?${$.param(queryParms, true)}`,
+ method: 'DELETE'
+ };
+
+ const promise = $.ajax(ajaxOptions);
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ removeItem({ section, id }),
+
+ set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+
+ return promise;
+ };
+ };
+}
+
+export default createRemoveItemHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSaveHandler.js b/frontend/src/Store/Actions/Creators/createSaveHandler.js
new file mode 100644
index 000000000..76048192e
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSaveHandler.js
@@ -0,0 +1,44 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { set, update } from '../baseActions';
+
+function createSaveHandler(section, url, getFromState) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isSaving: true }));
+
+ const state = getFromState(getState());
+ const saveData = Object.assign({}, state.item, state.pendingChanges);
+
+ const promise = $.ajax({
+ url,
+ method: 'PUT',
+ dataType: 'json',
+ data: JSON.stringify(saveData)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ };
+ };
+}
+
+export default createSaveHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js
new file mode 100644
index 000000000..6b6d89314
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js
@@ -0,0 +1,68 @@
+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(payload) {
+ return function(dispatch, getState) {
+ if (abortCurrentRequests[section]) {
+ abortCurrentRequests[section]();
+ abortCurrentRequests[section] = null;
+ }
+ };
+ };
+}
+
+function createSaveProviderHandler(section, url, getFromState) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isSaving: true }));
+
+ const id = payload.id;
+ const saveData = getProviderState(payload, getState, getFromState);
+
+ const ajaxOptions = {
+ url,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json',
+ data: JSON.stringify(saveData)
+ };
+
+ if (id) {
+ ajaxOptions.url = `${url}/${id}`;
+ ajaxOptions.method = 'PUT';
+ }
+
+ const { 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..91cef5d5e
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createServerSideCollectionHandlers.js
@@ -0,0 +1,52 @@
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import pages from 'Utilities/pages';
+import createFetchServerSideCollectionHandler from './createFetchServerSideCollectionHandler';
+import createSetServerSideCollectionPageHandler from './createSetServerSideCollectionPageHandler';
+import createSetServerSideCollectionSortHandler from './createSetServerSideCollectionSortHandler';
+import createSetServerSideCollectionFilterHandler from './createSetServerSideCollectionFilterHandler';
+
+function createServerSideCollectionHandlers(section, url, getFromState, handlers) {
+ const actionHandlers = {};
+ const fetchHandlerType = handlers[serverSideCollectionHandlers.FETCH];
+ const fetchHandler = createFetchServerSideCollectionHandler(section, url, getFromState);
+ actionHandlers[fetchHandlerType] = fetchHandler;
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.FIRST_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.FIRST_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.FIRST, getFromState, fetchHandler);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.PREVIOUS_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.PREVIOUS_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.PREVIOUS, getFromState, fetchHandler);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.NEXT_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.NEXT_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.NEXT, getFromState, fetchHandler);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.LAST_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.LAST_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.LAST, getFromState, fetchHandler);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.EXACT_PAGE)) {
+ const handlerType = handlers[serverSideCollectionHandlers.EXACT_PAGE];
+ actionHandlers[handlerType] = createSetServerSideCollectionPageHandler(section, pages.EXACT, getFromState, fetchHandler);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.SORT)) {
+ const handlerType = handlers[serverSideCollectionHandlers.SORT];
+ actionHandlers[handlerType] = createSetServerSideCollectionSortHandler(section, getFromState, fetchHandler);
+ }
+
+ if (handlers.hasOwnProperty(serverSideCollectionHandlers.FILTER)) {
+ const handlerType = handlers[serverSideCollectionHandlers.FILTER];
+ actionHandlers[handlerType] = createSetServerSideCollectionFilterHandler(section, getFromState, fetchHandler);
+ }
+
+ return actionHandlers;
+}
+
+export default createServerSideCollectionHandlers;
diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js
new file mode 100644
index 000000000..0aaa342db
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionFilterHandler.js
@@ -0,0 +1,12 @@
+import { set } from '../baseActions';
+
+function createSetServerSideCollectionFilterHandler(section, getFromState, fetchHandler) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, ...payload }));
+ dispatch(fetchHandler({ page: 1 }));
+ };
+ };
+}
+
+export default createSetServerSideCollectionFilterHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js
new file mode 100644
index 000000000..88682f118
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionPageHandler.js
@@ -0,0 +1,37 @@
+import pages from 'Utilities/pages';
+
+function createSetServerSideCollectionPageHandler(section, page, getFromState, fetchHandler) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ const state = getFromState(getState());
+ const sectionState = state.hasOwnProperty(section) ? state[section] : state;
+ const currentPage = sectionState.page || 1;
+ let nextPage = 0;
+
+ switch (page) {
+ case pages.FIRST:
+ nextPage = 1;
+ break;
+ case pages.PREVIOUS:
+ nextPage = currentPage - 1;
+ break;
+ case pages.NEXT:
+ nextPage = currentPage + 1;
+ break;
+ case pages.LAST:
+ nextPage = sectionState.totalPages;
+ break;
+ default:
+ nextPage = payload.page;
+ }
+
+ // If we prefer to update the page immediately we should
+ // set the page and not pass a page to the fetch handler.
+
+ // dispatch(set({ section, page: nextPage }));
+ dispatch(fetchHandler({ page: nextPage }));
+ };
+ };
+}
+
+export default createSetServerSideCollectionPageHandler;
diff --git a/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js
new file mode 100644
index 000000000..6ee0ac4b2
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createSetServerSideCollectionSortHandler.js
@@ -0,0 +1,28 @@
+import { sortDirections } from 'Helpers/Props';
+import { set } from '../baseActions';
+
+function createSetServerSideCollectionSortHandler(section, getFromState, fetchHandler) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ const state = getFromState(getState());
+ const sectionState = state.hasOwnProperty(section) ? state[section] : state;
+ const sortKey = payload.sortKey || sectionState.sortKey;
+ let sortDirection = payload.sortDirection;
+
+ if (!sortDirection) {
+ if (payload.sortKey === sectionState.sortKey) {
+ sortDirection = sectionState.sortDirection === sortDirections.ASCENDING ?
+ sortDirections.DESCENDING :
+ sortDirections.ASCENDING;
+ } else {
+ sortDirection = sectionState.sortDirection;
+ }
+ }
+
+ dispatch(set({ section, sortKey, sortDirection }));
+ dispatch(fetchHandler());
+ };
+ };
+}
+
+export default createSetServerSideCollectionSortHandler;
diff --git a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js
new file mode 100644
index 000000000..f3cf354bc
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js
@@ -0,0 +1,56 @@
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getProviderState from 'Utilities/State/getProviderState';
+import { set } from '../baseActions';
+
+const abortCurrentRequests = {};
+
+export function createCancelTestProviderHandler(section) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ if (abortCurrentRequests[section]) {
+ abortCurrentRequests[section]();
+ abortCurrentRequests[section] = null;
+ }
+ };
+ };
+}
+
+function createTestProviderHandler(section, url, getFromState) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isTesting: true }));
+
+ const testData = getProviderState(payload, getState, getFromState);
+
+ const ajaxOptions = {
+ url: `${url}/test`,
+ method: 'POST',
+ contentType: 'application/json',
+ dataType: 'json',
+ data: JSON.stringify(testData)
+ };
+
+ const { 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/Creators/createToggleAlbumMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createToggleAlbumMonitoredHandler.js
new file mode 100644
index 000000000..94581aec9
--- /dev/null
+++ b/frontend/src/Store/Actions/Creators/createToggleAlbumMonitoredHandler.js
@@ -0,0 +1,41 @@
+import $ from 'jquery';
+import updateAlbums from 'Utilities/Album/updateAlbums';
+
+function createToggleAlbumMonitoredHandler(section, getFromState) {
+ return function(payload) {
+ return function(dispatch, getState) {
+ const {
+ albumId,
+ monitored
+ } = payload;
+
+ const state = getFromState(getState());
+
+ updateAlbums(dispatch, section, state.items, [albumId], {
+ isSaving: true
+ });
+
+ const promise = $.ajax({
+ url: `/album/${albumId}`,
+ method: 'PUT',
+ data: JSON.stringify({ monitored }),
+ dataType: 'json'
+ });
+
+ promise.done(() => {
+ updateAlbums(dispatch, section, state.items, [albumId], {
+ isSaving: false,
+ monitored
+ });
+ });
+
+ promise.fail(() => {
+ updateAlbums(dispatch, section, state.items, [albumId], {
+ isSaving: false
+ });
+ });
+ };
+ };
+}
+
+export default createToggleAlbumMonitoredHandler;
diff --git a/frontend/src/Store/Actions/actionTypes.js b/frontend/src/Store/Actions/actionTypes.js
new file mode 100644
index 000000000..2c7a4dc50
--- /dev/null
+++ b/frontend/src/Store/Actions/actionTypes.js
@@ -0,0 +1,416 @@
+//
+// BASE
+
+export const SET = 'SET';
+
+export const UPDATE = 'UPDATE';
+export const UPDATE_ITEM = 'UPDATE_ITEM';
+export const UPDATE_SERVER_SIDE_COLLECTION = 'UPDATE_SERVER_SIDE_COLLECTION';
+
+export const SET_SETTING_VALUE = 'SET_SETTING_VALUE';
+export const CLEAR_PENDING_CHANGES = 'CLEAR_PENDING_CHANGES';
+export const SAVE_SETTINGS = 'SAVE_SETTINGS';
+
+export const REMOVE_ITEM = 'REMOVE_ITEM';
+
+//
+// App
+
+export const SHOW_MESSAGE = 'SHOW_MESSAGE';
+export const HIDE_MESSAGE = 'HIDE_MESSAGE';
+export const SAVE_DIMENSIONS = 'SAVE_DIMENSIONS';
+export const SET_VERSION = 'SET_VERSION';
+export const SET_APP_VALUE = 'SET_APP_VALUE';
+export const SET_IS_SIDEBAR_VISIBLE = 'SET_IS_SIDEBAR_VISIBLE';
+
+//
+// Add Artist
+
+export const LOOKUP_ARTIST = 'LOOKUP_ARTIST';
+export const ADD_ARTIST = 'ADD_ARTIST';
+export const SET_ADD_ARTIST_VALUE = 'SET_ADD_ARTIST_VALUE';
+export const CLEAR_ADD_ARTIST = 'CLEAR_ADD_ARTIST';
+export const SET_ADD_ARTIST_DEFAULT = 'SET_ADD_ARTIST_DEFAULT';
+
+//
+// Import Artist
+
+export const QUEUE_LOOKUP_ARTIST = 'QUEUE_LOOKUP_ARTIST';
+export const START_LOOKUP_ARTIST = 'START_LOOKUP_ARTIST';
+export const CLEAR_IMPORT_ARTIST = 'CLEAR_IMPORT_ARTIST';
+export const SET_IMPORT_ARTIST_VALUE = 'SET_IMPORT_ARTIST_VALUE';
+export const IMPORT_ARTIST = 'IMPORT_ARTIST';
+
+//
+// Artist
+
+export const FETCH_ARTIST = 'FETCH_ARTIST';
+export const SET_ARTIST_VALUE = 'SET_ARTIST_VALUE';
+export const SAVE_ARTIST = 'SAVE_ARTIST';
+export const DELETE_ARTIST = 'DELETE_ARTIST';
+
+export const SET_ARTIST_SORT = 'SET_ARTIST_SORT';
+export const SET_ARTIST_FILTER = 'SET_ARTIST_FILTER';
+export const SET_ARTIST_VIEW = 'SET_ARTIST_VIEW';
+export const SET_ARTIST_TABLE_OPTION = 'SET_ARTIST_TABLE_OPTION';
+export const SET_ARTIST_POSTER_OPTION = 'SET_ARTIST_POSTER_OPTION';
+export const SET_ARTIST_BANNER_OPTION = 'SET_ARTIST_BANNER_OPTION';
+export const SET_ARTIST_OVERVIEW_OPTION = 'SET_ARTIST_OVERVIEW_OPTION';
+
+export const TOGGLE_ARTIST_MONITORED = 'TOGGLE_ARTIST_MONITORED';
+export const TOGGLE_ALBUM_MONITORED = 'TOGGLE_ALBUM_MONITORED';
+
+//
+// Artist Editor
+
+export const SET_ARTIST_EDITOR_SORT = 'SET_ARTIST_EDITOR_SORT';
+export const SET_ARTIST_EDITOR_FILTER = 'SET_ARTIST_EDITOR_FILTER';
+export const SAVE_ARTIST_EDITOR = 'SAVE_ARTIST_EDITOR';
+export const BULK_DELETE_ARTIST = 'BULK_DELETE_ARTIST';
+
+//
+// Season Pass
+
+export const SET_ALBUM_STUDIO_SORT = 'SET_ALBUM_STUDIO_SORT';
+export const SET_ALBUM_STUDIO_FILTER = 'SET_ALBUM_STUDIO_FILTER';
+export const SAVE_ALBUM_STUDIO = 'SAVE_ALBUM_STUDIO';
+
+//
+// Episodes
+
+export const FETCH_EPISODES = 'FETCH_EPISODES';
+export const SET_EPISODES_SORT = 'SET_EPISODES_SORT';
+export const SET_EPISODES_TABLE_OPTION = 'SET_EPISODES_TABLE_OPTION';
+export const CLEAR_EPISODES = 'CLEAR_EPISODES';
+export const TOGGLE_EPISODE_MONITORED = 'TOGGLE_EPISODE_MONITORED';
+export const TOGGLE_EPISODES_MONITORED = 'TOGGLE_EPISODES_MONITORED';
+
+//
+// Tracks
+
+export const FETCH_TRACKS = 'FETCH_TRACKS';
+export const SET_TRACKS_SORT = 'SET_TRACKS_SORT';
+export const SET_TRACKS_TABLE_OPTION = 'SET_TRACKS_TABLE_OPTION';
+export const CLEAR_TRACKS = 'CLEAR_TRACKS';
+
+//
+// Episode Files
+
+export const FETCH_TRACK_FILES = 'FETCH_TRACK_FILES';
+export const CLEAR_TRACK_FILES = 'CLEAR_TRACK_FILES';
+export const DELETE_TRACK_FILE = 'DELETE_TRACK_FILE';
+export const DELETE_TRACK_FILES = 'DELETE_TRACK_FILES';
+export const UPDATE_TRACK_FILES = 'UPDATE_TRACK_FILES';
+
+//
+// Album History
+
+export const FETCH_ALBUM_HISTORY = 'FETCH_ALBUM_HISTORY';
+export const CLEAR_ALBUM_HISTORY = 'CLEAR_ALBUM_HISTORY';
+export const ALBUM_HISTORY_MARK_AS_FAILED = 'ALBUM_HISTORY_MARK_AS_FAILED';
+
+//
+// Releases
+
+export const FETCH_RELEASES = 'FETCH_RELEASES';
+export const SET_RELEASES_SORT = 'SET_RELEASES_SORT';
+export const CLEAR_RELEASES = 'CLEAR_RELEASES';
+export const GRAB_RELEASE = 'GRAB_RELEASE';
+export const UPDATE_RELEASE = 'UPDATE_RELEASE';
+
+//
+// Calendar
+
+export const FETCH_CALENDAR = 'FETCH_CALENDAR';
+export const SET_CALENDAR_DAYS_COUNT = 'SET_CALENDAR_DAYS_COUNT';
+export const SET_CALENDAR_INCLUDE_UNMONITORED = 'SET_CALENDAR_INCLUDE_UNMONITORED';
+export const SET_CALENDAR_VIEW = 'SET_CALENDAR_VIEW';
+export const GOTO_CALENDAR_TODAY = 'GOTO_CALENDAR_TODAY';
+export const GOTO_CALENDAR_PREVIOUS_RANGE = 'GOTO_CALENDAR_PREVIOUS_RANGE';
+export const GOTO_CALENDAR_NEXT_RANGE = 'GOTO_CALENDAR_NEXT_RANGE';
+export const CLEAR_CALENDAR = 'CLEAR_CALENDAR';
+
+//
+// History
+
+export const FETCH_HISTORY = 'FETCH_HISTORY';
+export const GOTO_FIRST_HISTORY_PAGE = 'GOTO_FIRST_HISTORY_PAGE';
+export const GOTO_PREVIOUS_HISTORY_PAGE = 'GOTO_PREVIOUS_HISTORY_PAGE';
+export const GOTO_NEXT_HISTORY_PAGE = 'GOTO_NEXT_HISTORY_PAGE';
+export const GOTO_LAST_HISTORY_PAGE = 'GOTO_LAST_HISTORY_PAGE';
+export const GOTO_HISTORY_PAGE = 'GOTO_HISTORY_PAGE';
+export const SET_HISTORY_SORT = 'SET_HISTORY_SORT';
+export const SET_HISTORY_FILTER = 'SET_HISTORY_FILTER';
+export const SET_HISTORY_TABLE_OPTION = 'SET_HISTORY_TABLE_OPTION';
+export const CLEAR_HISTORY = 'CLEAR_HISTORY';
+
+export const MARK_AS_FAILED = 'MARK_AS_FAILED';
+
+//
+// Queue
+
+export const FETCH_QUEUE_STATUS = 'FETCH_QUEUE_STATUS';
+
+export const FETCH_QUEUE_DETAILS = 'FETCH_QUEUE_DETAILS';
+export const CLEAR_QUEUE_DETAILS = 'CLEAR_QUEUE_DETAILS';
+
+export const FETCH_QUEUE = 'FETCH_QUEUE';
+export const GOTO_FIRST_QUEUE_PAGE = 'GOTO_FIRST_QUEUE_PAGE';
+export const GOTO_PREVIOUS_QUEUE_PAGE = 'GOTO_PREVIOUS_QUEUE_PAGE';
+export const GOTO_NEXT_QUEUE_PAGE = 'GOTO_NEXT_QUEUE_PAGE';
+export const GOTO_LAST_QUEUE_PAGE = 'GOTO_LAST_QUEUE_PAGE';
+export const GOTO_QUEUE_PAGE = 'GOTO_QUEUE_PAGE';
+export const SET_QUEUE_SORT = 'SET_QUEUE_SORT';
+export const SET_QUEUE_TABLE_OPTION = 'SET_QUEUE_TABLE_OPTION';
+export const CLEAR_QUEUE = 'CLEAR_QUEUE';
+
+export const GRAB_QUEUE_ITEM = 'GRAB_QUEUE_ITEM';
+export const GRAB_QUEUE_ITEMS = 'GRAB_QUEUE_ITEMS';
+export const REMOVE_QUEUE_ITEM = 'REMOVE_QUEUE_ITEM';
+export const REMOVE_QUEUE_ITEMS = 'REMOVE_QUEUE_ITEMS';
+
+//
+// Blacklist
+
+export const FETCH_BLACKLIST = 'FETCH_BLACKLIST';
+export const GOTO_FIRST_BLACKLIST_PAGE = 'GOTO_FIRST_BLACKLIST_PAGE';
+export const GOTO_PREVIOUS_BLACKLIST_PAGE = 'GOTO_PREVIOUS_BLACKLIST_PAGE';
+export const GOTO_NEXT_BLACKLIST_PAGE = 'GOTO_NEXT_BLACKLIST_PAGE';
+export const GOTO_LAST_BLACKLIST_PAGE = 'GOTO_LAST_BLACKLIST_PAGE';
+export const GOTO_BLACKLIST_PAGE = 'GOTO_BLACKLIST_PAGE';
+export const SET_BLACKLIST_SORT = 'SET_BLACKLIST_SORT';
+export const SET_BLACKLIST_TABLE_OPTION = 'SET_BLACKLIST_TABLE_OPTION';
+
+//
+// Wanted
+
+export const FETCH_MISSING = 'FETCH_MISSING';
+export const GOTO_FIRST_MISSING_PAGE = 'GOTO_FIRST_MISSING_PAGE';
+export const GOTO_PREVIOUS_MISSING_PAGE = 'GOTO_PREVIOUS_MISSING_PAGE';
+export const GOTO_NEXT_MISSING_PAGE = 'GOTO_NEXT_MISSING_PAGE';
+export const GOTO_LAST_MISSING_PAGE = 'GOTO_LAST_MISSING_PAGE';
+export const GOTO_MISSING_PAGE = 'GOTO_MISSING_PAGE';
+export const SET_MISSING_SORT = 'SET_MISSING_SORT';
+export const SET_MISSING_FILTER = 'SET_MISSING_FILTER';
+export const SET_MISSING_TABLE_OPTION = 'SET_MISSING_TABLE_OPTION';
+export const CLEAR_MISSING = 'CLEAR_MISSING';
+
+export const BATCH_TOGGLE_MISSING_ALBUMS = 'BATCH_TOGGLE_MISSING_ALBUMS';
+
+export const FETCH_CUTOFF_UNMET = 'FETCH_CUTOFF_UNMET';
+export const GOTO_FIRST_CUTOFF_UNMET_PAGE = 'GOTO_FIRST_CUTOFF_UNMET_PAGE';
+export const GOTO_PREVIOUS_CUTOFF_UNMET_PAGE = 'GOTO_PREVIOUS_CUTOFF_UNMET_PAGE';
+export const GOTO_NEXT_CUTOFF_UNMET_PAGE = 'GOTO_NEXT_CUTOFF_UNMET_PAGE';
+export const GOTO_LAST_CUTOFF_UNMET_PAGE = 'GOTO_LAST_CUTOFF_UNMET_PAGE';
+export const GOTO_CUTOFF_UNMET_PAGE = 'GOTO_CUTOFF_UNMET_PAGE';
+export const SET_CUTOFF_UNMET_SORT = 'SET_CUTOFF_UNMET_SORT';
+export const SET_CUTOFF_UNMET_FILTER = 'SET_CUTOFF_UNMET_FILTER';
+export const SET_CUTOFF_UNMET_TABLE_OPTION = 'SET_CUTOFF_UNMET_TABLE_OPTION';
+export const CLEAR_CUTOFF_UNMET = 'CLEAR_CUTOFF_UNMET';
+
+export const BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS = 'BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS';
+
+//
+// Settings
+
+export const TOGGLE_ADVANCED_SETTINGS = 'TOGGLE_ADVANCED_SETTINGS';
+
+export const FETCH_UI_SETTINGS = 'FETCH_UI_SETTINGS';
+export const SET_UI_SETTINGS_VALUE = 'SET_UI_SETTINGS_VALUE';
+export const SAVE_UI_SETTINGS = 'SAVE_UI_SETTINGS';
+
+export const FETCH_MEDIA_MANAGEMENT_SETTINGS = 'FETCH_MEDIA_MANAGEMENT_SETTINGS';
+export const SET_MEDIA_MANAGEMENT_SETTINGS_VALUE = 'SET_MEDIA_MANAGEMENT_SETTINGS_VALUE';
+export const SAVE_MEDIA_MANAGEMENT_SETTINGS = 'SAVE_MEDIA_MANAGEMENT_SETTINGS';
+
+export const FETCH_NAMING_SETTINGS = 'FETCH_NAMING_SETTINGS';
+export const SET_NAMING_SETTINGS_VALUE = 'SET_NAMING_SETTINGS_VALUE';
+export const SAVE_NAMING_SETTINGS = 'SAVE_NAMING_SETTINGS';
+export const FETCH_NAMING_EXAMPLES = 'FETCH_NAMING_EXAMPLES';
+
+export const FETCH_QUALITY_PROFILES = 'FETCH_QUALITY_PROFILES';
+export const FETCH_QUALITY_PROFILE_SCHEMA = 'FETCH_QUALITY_PROFILE_SCHEMA';
+export const SET_QUALITY_PROFILE_VALUE = 'SET_QUALITY_PROFILE_VALUE';
+export const SAVE_QUALITY_PROFILE = 'SAVE_QUALITY_PROFILE';
+export const DELETE_QUALITY_PROFILE = 'DELETE_QUALITY_PROFILE';
+
+export const FETCH_LANGUAGE_PROFILES = 'FETCH_LANGUAGE_PROFILES';
+export const FETCH_LANGUAGE_PROFILE_SCHEMA = 'FETCH_LANGUAGE_PROFILE_SCHEMA';
+export const SET_LANGUAGE_PROFILE_VALUE = 'SET_LANGUAGE_PROFILE_VALUE';
+export const SAVE_LANGUAGE_PROFILE = 'SAVE_LANGUAGE_PROFILE';
+export const DELETE_LANGUAGE_PROFILE = 'DELETE_LANGUAGE_PROFILE';
+
+export const FETCH_DELAY_PROFILES = 'FETCH_DELAY_PROFILES';
+export const SET_DELAY_PROFILE_VALUE = 'SET_DELAY_PROFILE_VALUE';
+export const SAVE_DELAY_PROFILE = 'SAVE_DELAY_PROFILE';
+export const DELETE_DELAY_PROFILE = 'DELETE_DELAY_PROFILE';
+export const REORDER_DELAY_PROFILE = 'REORDER_DELAY_PROFILE';
+
+export const FETCH_QUALITY_DEFINITIONS = 'FETCH_QUALITY_DEFINITIONS';
+export const SET_QUALITY_DEFINITION_VALUE = 'SET_QUALITY_DEFINITION_VALUE';
+export const SAVE_QUALITY_DEFINITIONS = 'SAVE_QUALITY_DEFINITIONS';
+
+export const FETCH_INDEXERS = 'FETCH_INDEXERS';
+export const FETCH_INDEXER_SCHEMA = 'FETCH_INDEXER_SCHEMA';
+export const SELECT_INDEXER_SCHEMA = 'SELECT_INDEXER_SCHEMA';
+export const SET_INDEXER_VALUE = 'SET_INDEXER_VALUE';
+export const SET_INDEXER_FIELD_VALUE = 'SET_INDEXER_FIELD_VALUE';
+export const SAVE_INDEXER = 'SAVE_INDEXER';
+export const CANCEL_SAVE_INDEXER = 'CANCEL_SAVE_INDEXER';
+export const DELETE_INDEXER = 'DELETE_INDEXER';
+export const TEST_INDEXER = 'TEST_INDEXER';
+export const CANCEL_TEST_INDEXER = 'CANCEL_TEST_INDEXER';
+
+export const FETCH_INDEXER_OPTIONS = 'FETCH_INDEXER_OPTIONS';
+export const SET_INDEXER_OPTIONS_VALUE = 'SET_INDEXER_OPTIONS_VALUE';
+export const SAVE_INDEXER_OPTIONS = 'SAVE_INDEXER_OPTIONS';
+
+export const FETCH_RESTRICTIONS = 'FETCH_RESTRICTIONS';
+export const SET_RESTRICTION_VALUE = 'SET_RESTRICTION_VALUE';
+export const SAVE_RESTRICTION = 'SAVE_RESTRICTION';
+export const DELETE_RESTRICTION = 'DELETE_RESTRICTION';
+
+export const FETCH_DOWNLOAD_CLIENTS = 'FETCH_DOWNLOAD_CLIENTS';
+export const FETCH_DOWNLOAD_CLIENT_SCHEMA = 'FETCH_DOWNLOAD_CLIENT_SCHEMA';
+export const SELECT_DOWNLOAD_CLIENT_SCHEMA = 'SELECT_DOWNLOAD_CLIENT_SCHEMA';
+export const SET_DOWNLOAD_CLIENT_VALUE = 'SET_DOWNLOAD_CLIENT_VALUE';
+export const SET_DOWNLOAD_CLIENT_FIELD_VALUE = 'SET_DOWNLOAD_CLIENT_FIELD_VALUE';
+export const SAVE_DOWNLOAD_CLIENT = 'SAVE_DOWNLOAD_CLIENT';
+export const CANCEL_SAVE_DOWNLOAD_CLIENT = 'CANCEL_SAVE_DOWNLOAD_CLIENT';
+export const DELETE_DOWNLOAD_CLIENT = 'DELETE_DOWNLOAD_CLIENT';
+export const TEST_DOWNLOAD_CLIENT = 'TEST_DOWNLOAD_CLIENT';
+export const CANCEL_TEST_DOWNLOAD_CLIENT = 'CANCEL_TEST_DOWNLOAD_CLIENT';
+
+export const FETCH_DOWNLOAD_CLIENT_OPTIONS = 'FETCH_DOWNLOAD_CLIENT_OPTIONS';
+export const SET_DOWNLOAD_CLIENT_OPTIONS_VALUE = 'SET_DOWNLOAD_CLIENT_OPTIONS_VALUE';
+export const SAVE_DOWNLOAD_CLIENT_OPTIONS = 'SAVE_DOWNLOAD_CLIENT_OPTIONS';
+
+export const FETCH_REMOTE_PATH_MAPPINGS = 'FETCH_REMOTE_PATH_MAPPINGS';
+export const SET_REMOTE_PATH_MAPPING_VALUE = 'SET_REMOTE_PATH_MAPPING_VALUE';
+export const SAVE_REMOTE_PATH_MAPPING = 'SAVE_REMOTE_PATH_MAPPING';
+export const DELETE_REMOTE_PATH_MAPPING = 'DELETE_REMOTE_PATH_MAPPING';
+
+export const FETCH_NOTIFICATIONS = 'FETCH_NOTIFICATIONS';
+export const FETCH_NOTIFICATION_SCHEMA = 'FETCH_NOTIFICATION_SCHEMA';
+export const SELECT_NOTIFICATION_SCHEMA = 'SELECT_NOTIFICATION_SCHEMA';
+export const SET_NOTIFICATION_VALUE = 'SET_NOTIFICATION_VALUE';
+export const SET_NOTIFICATION_FIELD_VALUE = 'SET_NOTIFICATION_FIELD_VALUE';
+export const SAVE_NOTIFICATION = 'SAVE_NOTIFICATION';
+export const CANCEL_SAVE_NOTIFICATION = 'CANCEL_SAVE_NOTIFICATION';
+export const DELETE_NOTIFICATION = 'DELETE_NOTIFICATION';
+export const CANCEL_TEST_NOTIFICATION = 'CANCEL_TEST_NOTIFICATION';
+
+export const FETCH_METADATA = 'FETCH_METADATA';
+export const SET_METADATA_VALUE = 'SET_METADATA_VALUE';
+export const SET_METADATA_FIELD_VALUE = 'SET_METADATA_FIELD_VALUE';
+export const SAVE_METADATA = 'SAVE_METADATA';
+
+export const FETCH_METADATA_PROVIDER = 'FETCH_METADATA_PROVIDER';
+export const SET_METADATA_PROVIDER_VALUE = 'SET_METADATA_PROVIDER_VALUE';
+export const SAVE_METADATA_PROVIDER = 'SAVE_METADATA_PROVIDER';
+
+//
+// System
+
+export const FETCH_STATUS = 'FETCH_STATUS';
+export const FETCH_HEALTH = 'FETCH_HEALTH';
+export const FETCH_DISK_SPACE = 'FETCH_DISK_SPACE';
+
+export const FETCH_TASK = 'FETCH_TASK';
+export const FETCH_TASKS = 'FETCH_TASKS';
+export const FETCH_BACKUPS = 'FETCH_BACKUPS';
+export const FETCH_UPDATES = 'FETCH_UPDATES';
+
+export const FETCH_LOGS = 'FETCH_LOGS';
+export const GOTO_FIRST_LOGS_PAGE = 'GOTO_FIRST_LOGS_PAGE';
+export const GOTO_PREVIOUS_LOGS_PAGE = 'GOTO_PREVIOUS_LOGS_PAGE';
+export const GOTO_NEXT_LOGS_PAGE = 'GOTO_NEXT_LOGS_PAGE';
+export const GOTO_LAST_LOGS_PAGE = 'GOTO_LAST_LOGS_PAGE';
+export const GOTO_LOGS_PAGE = 'GOTO_LOGS_PAGE';
+export const SET_LOGS_SORT = 'SET_LOGS_SORT';
+export const SET_LOGS_FILTER = 'SET_LOGS_FILTER';
+export const SET_LOGS_TABLE_OPTION = 'SET_LOGS_TABLE_OPTION';
+
+export const FETCH_LOG_FILES = 'FETCH_LOG_FILES';
+export const FETCH_UPDATE_LOG_FILES = 'FETCH_UPDATE_LOG_FILES';
+
+export const FETCH_GENERAL_SETTINGS = 'FETCH_GENERAL_SETTINGS';
+export const SET_GENERAL_SETTINGS_VALUE = 'SET_GENERAL_SETTINGS_VALUE';
+export const SAVE_GENERAL_SETTINGS = 'SAVE_GENERAL_SETTINGS';
+
+export const RESTART = 'RESTART';
+export const SHUTDOWN = 'SHUTDOWN';
+
+//
+// Commands
+
+export const FETCH_COMMANDS = 'FETCH_COMMANDS';
+export const EXECUTE_COMMAND = 'EXECUTE_COMMAND';
+export const ADD_COMMAND = 'ADD_COMMAND';
+export const UPDATE_COMMAND = 'UPDATE_COMMAND';
+export const FINISH_COMMAND = 'FINISH_COMMAND';
+export const REMOVE_COMMAND = 'REMOVE_COMMAND';
+export const REGISTER_FINISH_COMMAND_HANDLER = 'REGISTER_FINISH_COMMAND_HANDLER';
+export const UNREGISTER_FINISH_COMMAND_HANDLER = 'UNREGISTER_FINISH_COMMAND_HANDLER';
+
+//
+// Paths
+
+export const FETCH_PATHS = 'FETCH_PATHS';
+export const UPDATE_PATHS = 'UPDATE_PATHS';
+export const CLEAR_PATHS = 'CLEAR_PATHS';
+
+//
+// Languages
+
+export const FETCH_LANGUAGES = 'FETCH_LANGUAGES';
+
+//
+// Tags
+
+export const FETCH_TAGS = 'FETCH_TAGS';
+export const ADD_TAG = 'ADD_TAG';
+
+//
+// Captcha
+
+export const REFRESH_CAPTCHA = 'REFRESH_CAPTCHA';
+export const GET_CAPTCHA_COOKIE = 'GET_CAPTCHA_COOKIE';
+export const SET_CAPTCHA_VALUE = 'SET_CAPTCHA_VALUE';
+export const RESET_CAPTCHA = 'RESET_CAPTCHA';
+
+//
+// OAuth
+
+export const START_OAUTH = 'START_OAUTH';
+export const GET_OAUTH_TOKEN = 'GET_OAUTH_TOKEN';
+export const SET_OAUTH_VALUE = 'SET_OAUTH_VALUE';
+export const RESET_OAUTH = 'RESET_OAUTH';
+
+//
+// Interactive Import
+
+export const FETCH_INTERACTIVE_IMPORT_ITEMS = 'FETCH_INTERACTIVE_IMPORT_ITEMS';
+export const UPDATE_INTERACTIVE_IMPORT_ITEM = 'UPDATE_INTERACTIVE_IMPORT_ITEM';
+export const SET_INTERACTIVE_IMPORT_SORT = 'SET_INTERACTIVE_IMPORT_SORT';
+export const CLEAR_INTERACTIVE_IMPORT = 'CLEAR_INTERACTIVE_IMPORT';
+export const ADD_RECENT_FOLDER = 'ADD_RECENT_FOLDER';
+export const REMOVE_RECENT_FOLDER = 'REMOVE_RECENT_FOLDER';
+export const SET_INTERACTIVE_IMPORT_MODE = 'SET_INTERACTIVE_IMPORT_MODE';
+
+export const FETCH_INTERACTIVE_IMPORT_ALBUMS = 'FETCH_INTERACTIVE_IMPORT_ALBUMS';
+export const SET_INTERACTIVE_IMPORT_ALBUMS_SORT = 'SET_INTERACTIVE_IMPORT_ALBUMS_SORT';
+export const CLEAR_INTERACTIVE_IMPORT_ALBUMS = 'CLEAR_INTERACTIVE_IMPORT_ALBUMS';
+
+//
+// Root Folders
+
+export const FETCH_ROOT_FOLDERS = 'FETCH_ROOT_FOLDERS';
+export const ADD_ROOT_FOLDER = 'ADD_ROOT_FOLDER';
+export const DELETE_ROOT_FOLDER = 'DELETE_ROOT_FOLDER';
+
+//
+// Organize Preview
+
+export const FETCH_ORGANIZE_PREVIEW = 'FETCH_ORGANIZE_PREVIEW';
+export const CLEAR_ORGANIZE_PREVIEW = 'CLEAR_ORGANIZE_PREVIEW';
diff --git a/frontend/src/Store/Actions/addArtistActionHandlers.js b/frontend/src/Store/Actions/addArtistActionHandlers.js
new file mode 100644
index 000000000..09ca0f9bf
--- /dev/null
+++ b/frontend/src/Store/Actions/addArtistActionHandlers.js
@@ -0,0 +1,94 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import createAjaxRequest from 'Utilities/createAjaxRequest';
+import getNewSeries from 'Utilities/Series/getNewSeries';
+import * as types from './actionTypes';
+import { set, update, updateItem } from './baseActions';
+
+let abortCurrentRequest = null;
+const section = 'addArtist';
+
+const addArtistActionHandlers = {
+ [types.LOOKUP_ARTIST]: function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isFetching: true }));
+
+ if (abortCurrentRequest) {
+ abortCurrentRequest();
+ }
+
+ const { request, abortRequest } = createAjaxRequest()({
+ url: '/artist/lookup',
+ data: {
+ term: payload.term
+ }
+ });
+
+ abortCurrentRequest = abortRequest;
+
+ request.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ request.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr.aborted ? null : xhr
+ }));
+ });
+ };
+ },
+
+ [types.ADD_ARTIST]: function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isAdding: true }));
+
+ const foreignArtistId = payload.foreignArtistId;
+ const items = getState().addArtist.items;
+ const newSeries = getNewSeries(_.cloneDeep(_.find(items, { foreignArtistId })), payload);
+
+ const promise = $.ajax({
+ url: '/artist',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(newSeries)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ updateItem({ section: 'artist', ...data }),
+
+ set({
+ section,
+ isAdding: false,
+ isAdded: true,
+ addError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isAdding: false,
+ isAdded: false,
+ addError: xhr
+ }));
+ });
+ };
+ }
+};
+
+export default addArtistActionHandlers;
diff --git a/frontend/src/Store/Actions/addArtistActions.js b/frontend/src/Store/Actions/addArtistActions.js
new file mode 100644
index 000000000..e0ee67420
--- /dev/null
+++ b/frontend/src/Store/Actions/addArtistActions.js
@@ -0,0 +1,15 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import addArtistActionHandlers from './addArtistActionHandlers';
+
+export const lookupArtist = addArtistActionHandlers[types.LOOKUP_ARTIST];
+export const addArtist = addArtistActionHandlers[types.ADD_ARTIST];
+export const clearAddArtist = createAction(types.CLEAR_ADD_ARTIST);
+export const setAddArtistDefault = createAction(types.SET_ADD_ARTIST_DEFAULT);
+
+export const setAddArtistValue = createAction(types.SET_ADD_ARTIST_VALUE, (payload) => {
+ return {
+ section: 'addArtist',
+ ...payload
+ };
+});
diff --git a/frontend/src/Store/Actions/albumHistoryActionHandlers.js b/frontend/src/Store/Actions/albumHistoryActionHandlers.js
new file mode 100644
index 000000000..8d1952fa7
--- /dev/null
+++ b/frontend/src/Store/Actions/albumHistoryActionHandlers.js
@@ -0,0 +1,75 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { sortDirections } from 'Helpers/Props';
+import * as types from './actionTypes';
+import { set, update } from './baseActions';
+import { fetchAlbumHistory } from './albumHistoryActions';
+
+const albumHistoryActionHandlers = {
+ [types.FETCH_ALBUM_HISTORY]: function(payload) {
+ const section = 'albumHistory';
+
+ return function(dispatch, getState) {
+ dispatch(set({ section, isFetching: true }));
+
+ const queryParams = {
+ pageSize: 1000,
+ page: 1,
+ filterKey: 'albumId',
+ filterValue: payload.albumId,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING
+ };
+
+ const promise = $.ajax({
+ url: '/history',
+ data: queryParams
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data: data.records }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ };
+ },
+
+ [types.ALBUM_HISTORY_MARK_AS_FAILED]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ historyId,
+ albumId
+ } = payload;
+
+ const promise = $.ajax({
+ url: '/history/failed',
+ method: 'POST',
+ data: {
+ id: historyId
+ }
+ });
+
+ promise.done(() => {
+ dispatch(fetchAlbumHistory({ albumId }));
+ });
+ };
+ }
+};
+
+export default albumHistoryActionHandlers;
diff --git a/frontend/src/Store/Actions/albumHistoryActions.js b/frontend/src/Store/Actions/albumHistoryActions.js
new file mode 100644
index 000000000..3e15c4b1f
--- /dev/null
+++ b/frontend/src/Store/Actions/albumHistoryActions.js
@@ -0,0 +1,7 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import albumHistoryActionHandlers from './albumHistoryActionHandlers';
+
+export const fetchAlbumHistory = albumHistoryActionHandlers[types.FETCH_ALBUM_HISTORY];
+export const clearAlbumHistory = createAction(types.CLEAR_ALBUM_HISTORY);
+export const albumHistoryMarkAsFailed = albumHistoryActionHandlers[types.ALBUM_HISTORY_MARK_AS_FAILED];
diff --git a/frontend/src/Store/Actions/albumStudioActionHandlers.js b/frontend/src/Store/Actions/albumStudioActionHandlers.js
new file mode 100644
index 000000000..b20b96704
--- /dev/null
+++ b/frontend/src/Store/Actions/albumStudioActionHandlers.js
@@ -0,0 +1,83 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import getMonitoringOptions from 'Utilities/Series/getMonitoringOptions';
+import * as types from './actionTypes';
+import { set } from './baseActions';
+import { fetchArtist } from './artistActions';
+
+const section = 'albumStudio';
+
+const albumStudioActionHandlers = {
+ [types.SAVE_ALBUM_STUDIO]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ artistIds,
+ monitored,
+ monitor
+ } = payload;
+
+ let monitoringOptions = null;
+ const artist = [];
+ const allArtists = getState().artist.items;
+
+ artistIds.forEach((id) => {
+ const s = _.find(allArtists, { id });
+ const artistToUpdate = { id };
+
+ if (payload.hasOwnProperty('monitored')) {
+ artistToUpdate.monitored = monitored;
+ }
+
+ if (monitor) {
+ const {
+ albums,
+ options: artistMonitoringOptions
+ } = getMonitoringOptions(_.cloneDeep(s.albums), monitor);
+
+ if (!monitoringOptions) {
+ monitoringOptions = artistMonitoringOptions;
+ }
+
+ artistToUpdate.albums = albums;
+ }
+
+ artist.push(artistToUpdate);
+ });
+
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: '/albumStudio',
+ method: 'POST',
+ data: JSON.stringify({
+ artist,
+ monitoringOptions
+ }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(fetchArtist());
+
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ };
+ }
+};
+
+export default albumStudioActionHandlers;
diff --git a/frontend/src/Store/Actions/albumStudioActions.js b/frontend/src/Store/Actions/albumStudioActions.js
new file mode 100644
index 000000000..7d90bbcc8
--- /dev/null
+++ b/frontend/src/Store/Actions/albumStudioActions.js
@@ -0,0 +1,7 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import albumStudioActionHandlers from './albumStudioActionHandlers';
+
+export const setAlbumStudioSort = createAction(types.SET_ALBUM_STUDIO_SORT);
+export const setAlbumStudioFilter = createAction(types.SET_ALBUM_STUDIO_FILTER);
+export const saveAlbumStudio = albumStudioActionHandlers[types.SAVE_ALBUM_STUDIO];
diff --git a/frontend/src/Store/Actions/appActions.js b/frontend/src/Store/Actions/appActions.js
new file mode 100644
index 000000000..3d0191a21
--- /dev/null
+++ b/frontend/src/Store/Actions/appActions.js
@@ -0,0 +1,27 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+
+export const saveDimensions = createAction(types.SAVE_DIMENSIONS);
+export const setVersion = createAction(types.SET_VERSION);
+export const setIsSidebarVisible = createAction(types.SET_IS_SIDEBAR_VISIBLE);
+
+export const setAppValue = createAction(types.SET_APP_VALUE, (payload) => {
+ return {
+ section: 'app',
+ ...payload
+ };
+});
+
+export const showMessage = createAction(types.SHOW_MESSAGE, (payload) => {
+ return {
+ section: 'messages',
+ ...payload
+ };
+});
+
+export const hideMessage = createAction(types.HIDE_MESSAGE, (payload) => {
+ return {
+ section: 'messages',
+ ...payload
+ };
+});
diff --git a/frontend/src/Store/Actions/artistActionHandlers.js b/frontend/src/Store/Actions/artistActionHandlers.js
new file mode 100644
index 000000000..d455c40af
--- /dev/null
+++ b/frontend/src/Store/Actions/artistActionHandlers.js
@@ -0,0 +1,132 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import * as types from './actionTypes';
+import createFetchHandler from './Creators/createFetchHandler';
+import createSaveProviderHandler from './Creators/createSaveProviderHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import { updateItem } from './baseActions';
+
+const section = 'artist';
+
+const artistActionHandlers = {
+ [types.FETCH_ARTIST]: createFetchHandler(section, '/artist'),
+
+ [types.SAVE_ARTIST]: createSaveProviderHandler(
+ section,
+ '/artist',
+ (state) => state.artist),
+
+ [types.DELETE_ARTIST]: createRemoveItemHandler(
+ section,
+ '/artist',
+ (state) => state.artist),
+
+ [types.TOGGLE_ARTIST_MONITORED]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ artistId: id,
+ monitored
+ } = payload;
+
+ const artist = _.find(getState().artist.items, { id });
+
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: `/artist/${id}`,
+ method: 'PUT',
+ data: JSON.stringify({
+ ...artist,
+ monitored
+ }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: false,
+ monitored
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id,
+ section,
+ isSaving: false
+ }));
+ });
+ };
+ },
+
+ [types.TOGGLE_ALBUM_MONITORED]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ artistId: id,
+ seasonNumber,
+ monitored
+ } = payload;
+
+ const artist = _.find(getState().artist.items, { id });
+ const seasons = _.cloneDeep(artist.seasons);
+ const season = _.find(seasons, { seasonNumber });
+
+ season.isSaving = true;
+
+ dispatch(updateItem({
+ id,
+ section,
+ seasons
+ }));
+
+ season.monitored = monitored;
+
+ const promise = $.ajax({
+ url: `/artist/${id}`,
+ method: 'PUT',
+ data: JSON.stringify({
+ ...artist,
+ seasons
+ }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ const episodes = _.filter(getState().episodes.items, { artistId: id, seasonNumber });
+
+ dispatch(batchActions([
+ updateItem({
+ id,
+ section,
+ ...data
+ }),
+
+ ...episodes.map((episode) => {
+ return updateItem({
+ id: episode.id,
+ section: 'episodes',
+ monitored
+ });
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id,
+ section,
+ seasons: artist.seasons
+ }));
+ });
+ };
+ }
+};
+
+export default artistActionHandlers;
diff --git a/frontend/src/Store/Actions/artistActions.js b/frontend/src/Store/Actions/artistActions.js
new file mode 100644
index 000000000..bf805763b
--- /dev/null
+++ b/frontend/src/Store/Actions/artistActions.js
@@ -0,0 +1,16 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import artistActionHandlers from './artistActionHandlers';
+
+export const fetchArtist = artistActionHandlers[types.FETCH_ARTIST];
+export const saveArtist = artistActionHandlers[types.SAVE_ARTIST];
+export const deleteArtist = artistActionHandlers[types.DELETE_ARTIST];
+export const toggleArtistMonitored = artistActionHandlers[types.TOGGLE_ARTIST_MONITORED];
+export const toggleSeasonMonitored = artistActionHandlers[types.TOGGLE_ALBUM_MONITORED];
+
+export const setArtistValue = createAction(types.SET_ARTIST_VALUE, (payload) => {
+ return {
+ section: 'artist',
+ ...payload
+ };
+});
diff --git a/frontend/src/Store/Actions/artistEditorActionHandlers.js b/frontend/src/Store/Actions/artistEditorActionHandlers.js
new file mode 100644
index 000000000..81bc2ecdc
--- /dev/null
+++ b/frontend/src/Store/Actions/artistEditorActionHandlers.js
@@ -0,0 +1,86 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import * as types from './actionTypes';
+import { set, updateItem } from './baseActions';
+
+const section = 'artistEditor';
+
+const artistEditorActionHandlers = {
+ [types.SAVE_ARTIST_EDITOR]: function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: '/artist/editor',
+ method: 'PUT',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ ...data.map((artist) => {
+ return updateItem({
+ id: artist.id,
+ section: 'artist',
+ ...artist
+ });
+ }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ };
+ },
+
+ [types.BULK_DELETE_ARTIST]: function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({
+ section,
+ isDeleting: true
+ }));
+
+ const promise = $.ajax({
+ url: '/artist/editor',
+ method: 'DELETE',
+ data: JSON.stringify(payload),
+ dataType: 'json'
+ });
+
+ promise.done(() => {
+ // SignaR will take care of removing the serires from the collection
+
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+ };
+ }
+};
+
+export default artistEditorActionHandlers;
diff --git a/frontend/src/Store/Actions/artistEditorActions.js b/frontend/src/Store/Actions/artistEditorActions.js
new file mode 100644
index 000000000..5dbaf4f26
--- /dev/null
+++ b/frontend/src/Store/Actions/artistEditorActions.js
@@ -0,0 +1,8 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import artistEditorActionHandlers from './artistEditorActionHandlers';
+
+export const setArtistEditorSort = createAction(types.SET_ARTIST_EDITOR_SORT);
+export const setArtistEditorFilter = createAction(types.SET_ARTIST_EDITOR_FILTER);
+export const saveArtistEditor = artistEditorActionHandlers[types.SAVE_ARTIST_EDITOR];
+export const bulkDeleteArtist = artistEditorActionHandlers[types.BULK_DELETE_ARTIST];
diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js
new file mode 100644
index 000000000..2e15d6c0b
--- /dev/null
+++ b/frontend/src/Store/Actions/artistIndexActions.js
@@ -0,0 +1,10 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+
+export const setArtistSort = createAction(types.SET_ARTIST_SORT);
+export const setArtistFilter = createAction(types.SET_ARTIST_FILTER);
+export const setArtistView = createAction(types.SET_ARTIST_VIEW);
+export const setArtistTableOption = createAction(types.SET_ARTIST_TABLE_OPTION);
+export const setArtistPosterOption = createAction(types.SET_ARTIST_POSTER_OPTION);
+export const setArtistBannerOption = createAction(types.SET_ARTIST_BANNER_OPTION);
+export const setArtistOverviewOption = createAction(types.SET_ARTIST_OVERVIEW_OPTION);
diff --git a/frontend/src/Store/Actions/baseActions.js b/frontend/src/Store/Actions/baseActions.js
new file mode 100644
index 000000000..e2d7e9d7e
--- /dev/null
+++ b/frontend/src/Store/Actions/baseActions.js
@@ -0,0 +1,13 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+
+export const set = createAction(types.SET);
+
+export const update = createAction(types.UPDATE);
+export const updateItem = createAction(types.UPDATE_ITEM);
+export const updateServerSideCollection = createAction(types.UPDATE_SERVER_SIDE_COLLECTION);
+
+export const setSettingValue = createAction(types.SET_SETTING_VALUE);
+export const clearPendingChanges = createAction(types.CLEAR_PENDING_CHANGES);
+
+export const removeItem = createAction(types.REMOVE_ITEM);
diff --git a/frontend/src/Store/Actions/blacklistActionHandlers.js b/frontend/src/Store/Actions/blacklistActionHandlers.js
new file mode 100644
index 000000000..0a4e4a1f6
--- /dev/null
+++ b/frontend/src/Store/Actions/blacklistActionHandlers.js
@@ -0,0 +1,17 @@
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import * as types from './actionTypes';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+
+const blacklistActionHandlers = {
+ ...createServerSideCollectionHandlers('blacklist', '/blacklist', (state) => state, {
+ [serverSideCollectionHandlers.FETCH]: types.FETCH_BLACKLIST,
+ [serverSideCollectionHandlers.FIRST_PAGE]: types.GOTO_FIRST_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: types.GOTO_PREVIOUS_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: types.GOTO_NEXT_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: types.GOTO_LAST_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: types.GOTO_BLACKLIST_PAGE,
+ [serverSideCollectionHandlers.SORT]: types.SET_BLACKLIST_SORT
+ })
+};
+
+export default blacklistActionHandlers;
diff --git a/frontend/src/Store/Actions/blacklistActions.js b/frontend/src/Store/Actions/blacklistActions.js
new file mode 100644
index 000000000..d931842d1
--- /dev/null
+++ b/frontend/src/Store/Actions/blacklistActions.js
@@ -0,0 +1,12 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import blacklistActionHandlers from './blacklistActionHandlers';
+
+export const fetchBlacklist = blacklistActionHandlers[types.FETCH_BLACKLIST];
+export const gotoBlacklistFirstPage = blacklistActionHandlers[types.GOTO_FIRST_BLACKLIST_PAGE];
+export const gotoBlacklistPreviousPage = blacklistActionHandlers[types.GOTO_PREVIOUS_BLACKLIST_PAGE];
+export const gotoBlacklistNextPage = blacklistActionHandlers[types.GOTO_NEXT_BLACKLIST_PAGE];
+export const gotoBlacklistLastPage = blacklistActionHandlers[types.GOTO_LAST_BLACKLIST_PAGE];
+export const gotoBlacklistPage = blacklistActionHandlers[types.GOTO_BLACKLIST_PAGE];
+export const setBlacklistSort = blacklistActionHandlers[types.SET_BLACKLIST_SORT];
+export const setBlacklistTableOption = createAction(types.SET_BLACKLIST_TABLE_OPTION);
diff --git a/frontend/src/Store/Actions/calendarActionHandlers.js b/frontend/src/Store/Actions/calendarActionHandlers.js
new file mode 100644
index 000000000..bb619858a
--- /dev/null
+++ b/frontend/src/Store/Actions/calendarActionHandlers.js
@@ -0,0 +1,264 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import moment from 'moment';
+import { batchActions } from 'redux-batched-actions';
+import * as calendarViews from 'Calendar/calendarViews';
+import * as types from './actionTypes';
+import { set, update } from './baseActions';
+import { fetchCalendar } from './calendarActions';
+
+const viewRanges = {
+ [calendarViews.DAY]: 'day',
+ [calendarViews.WEEK]: 'week',
+ [calendarViews.MONTH]: 'month',
+ [calendarViews.FORECAST]: 'day'
+};
+
+function getDays(start, end) {
+ const startTime = moment(start);
+ const endTime = moment(end);
+ const difference = endTime.diff(startTime, 'days');
+
+ // Difference is one less than the number of days we need to account for.
+ return _.times(difference + 1, (i) => {
+ return startTime.clone().add(i, 'days').toISOString();
+ });
+}
+
+function getDates(time, view, firstDayOfWeek, dayCount) {
+ const weekName = firstDayOfWeek === 0 ? 'week' : 'isoWeek';
+
+ let start = time.clone().startOf('day');
+ let end = time.clone().endOf('day');
+
+ if (view === calendarViews.WEEK) {
+ start = time.clone().startOf(weekName);
+ end = time.clone().endOf(weekName);
+ }
+
+ if (view === calendarViews.FORECAST) {
+ start = time.clone().subtract(1, 'day').startOf('day');
+ end = time.clone().add(dayCount - 2, 'days').endOf('day');
+ }
+
+ if (view === calendarViews.MONTH) {
+ start = time.clone().startOf('month').startOf(weekName);
+ end = time.clone().endOf('month').endOf(weekName);
+ }
+
+ if (view === calendarViews.AGENDA) {
+ start = time.clone().subtract(1, 'day').startOf('day');
+ end = time.clone().add(1, 'month').endOf('day');
+ }
+
+ return {
+ start: start.toISOString(),
+ end: end.toISOString(),
+ time: time.toISOString(),
+ dates: getDays(start, end)
+ };
+}
+
+function getPopulatableRange(startDate, endDate, view) {
+ switch (view) {
+ case calendarViews.DAY:
+ return {
+ start: moment(startDate).subtract(1, 'day').toISOString(),
+ end: moment(endDate).add(1, 'day').toISOString()
+ };
+ case calendarViews.WEEK:
+ case calendarViews.FORECAST:
+ return {
+ start: moment(startDate).subtract(1, 'week').toISOString(),
+ end: moment(endDate).add(1, 'week').toISOString()
+ };
+ default:
+ return {
+ start: startDate,
+ end: endDate
+ };
+ }
+}
+
+function isRangePopulated(start, end, state) {
+ const {
+ start: currentStart,
+ end: currentEnd,
+ view: currentView
+ } = state;
+
+ if (!currentStart || !currentEnd) {
+ return false;
+ }
+
+ const {
+ start: currentPopulatedStart,
+ end: currentPopulatedEnd
+ } = getPopulatableRange(currentStart, currentEnd, currentView);
+
+ if (
+ moment(start).isAfter(currentPopulatedStart) &&
+ moment(start).isBefore(currentPopulatedEnd)
+ ) {
+ return true;
+ }
+
+ return false;
+}
+
+const section = 'calendar';
+
+const calendarActionHandlers = {
+ [types.FETCH_CALENDAR]: function(payload) {
+ return function(dispatch, getState) {
+ const state = getState();
+ const unmonitored = state.calendar.unmonitored;
+
+ const {
+ time,
+ view
+ } = payload;
+
+ const dayCount = state.calendar.dayCount;
+ const dates = getDates(moment(time), view, state.settings.ui.item.firstDayOfWeek, dayCount);
+ const { start, end } = getPopulatableRange(dates.start, dates.end, view);
+ const isPrePopulated = isRangePopulated(start, end, state.calendar);
+
+ const basesAttrs = {
+ section,
+ isFetching: true
+ };
+
+ const attrs = isPrePopulated ?
+ {
+ view,
+ ...basesAttrs,
+ ...dates
+ } :
+ basesAttrs;
+
+ dispatch(set(attrs));
+
+ const promise = $.ajax({
+ url: '/calendar',
+ data: {
+ unmonitored,
+ start,
+ end
+ }
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ view,
+ ...dates,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ };
+ },
+
+ [types.SET_CALENDAR_DAYS_COUNT]: function(payload) {
+ return function(dispatch, getState) {
+ 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 }));
+ };
+ },
+
+ [types.SET_CALENDAR_INCLUDE_UNMONITORED]: function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({
+ section,
+ unmonitored: payload.unmonitored
+ }));
+
+ const state = getState();
+ const { time, view } = state.calendar;
+
+ dispatch(fetchCalendar({ time, view }));
+ };
+ },
+
+ [types.SET_CALENDAR_VIEW]: function(payload) {
+ return function(dispatch, getState) {
+ const state = getState();
+ const view = payload.view;
+ const time = view === calendarViews.FORECAST ?
+ moment() :
+ state.calendar.time;
+
+ dispatch(fetchCalendar({ time, view }));
+ };
+ },
+
+ [types.GOTO_CALENDAR_TODAY]: function(payload) {
+ return function(dispatch, getState) {
+ const state = getState();
+ const view = state.calendar.view;
+ const time = moment();
+
+ dispatch(fetchCalendar({ time, view }));
+ };
+ },
+
+ [types.GOTO_CALENDAR_PREVIOUS_RANGE]: function(payload) {
+ return function(dispatch, getState) {
+ const state = getState();
+
+ const {
+ view,
+ dayCount
+ } = state.calendar;
+
+ const amount = view === calendarViews.FORECAST ? dayCount : 1;
+ const time = moment(state.calendar.time).subtract(amount, viewRanges[view]);
+
+ dispatch(fetchCalendar({ time, view }));
+ };
+ },
+
+ [types.GOTO_CALENDAR_NEXT_RANGE]: function(payload) {
+ return function(dispatch, getState) {
+ const state = getState();
+
+ const {
+ view,
+ dayCount
+ } = state.calendar;
+
+ const amount = view === calendarViews.FORECAST ? dayCount : 1;
+ const time = moment(state.calendar.time).add(amount, viewRanges[view]);
+
+ dispatch(fetchCalendar({ time, view }));
+ };
+ }
+};
+
+export default calendarActionHandlers;
diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js
new file mode 100644
index 000000000..452769343
--- /dev/null
+++ b/frontend/src/Store/Actions/calendarActions.js
@@ -0,0 +1,12 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import calendarActionHandlers from './calendarActionHandlers';
+
+export const fetchCalendar = calendarActionHandlers[types.FETCH_CALENDAR];
+export const setCalendarDaysCount = calendarActionHandlers[types.SET_CALENDAR_DAYS_COUNT];
+export const setCalendarIncludeUnmonitored = calendarActionHandlers[types.SET_CALENDAR_INCLUDE_UNMONITORED];
+export const setCalendarView = calendarActionHandlers[types.SET_CALENDAR_VIEW];
+export const gotoCalendarToday = calendarActionHandlers[types.GOTO_CALENDAR_TODAY];
+export const gotoCalendarPreviousRange = calendarActionHandlers[types.GOTO_CALENDAR_PREVIOUS_RANGE];
+export const gotoCalendarNextRange = calendarActionHandlers[types.GOTO_CALENDAR_NEXT_RANGE];
+export const clearCalendar = createAction(types.CLEAR_CALENDAR);
diff --git a/frontend/src/Store/Actions/captchaActionHandlers.js b/frontend/src/Store/Actions/captchaActionHandlers.js
new file mode 100644
index 000000000..6e00a840e
--- /dev/null
+++ b/frontend/src/Store/Actions/captchaActionHandlers.js
@@ -0,0 +1,67 @@
+import requestAction from 'Utilities/requestAction';
+import * as types from './actionTypes';
+import { setCaptchaValue } from './captchaActions';
+
+const captchaActionHandlers = {
+ [types.REFRESH_CAPTCHA]: function(payload) {
+ return (dispatch, getState) => {
+ const actionPayload = {
+ action: 'checkCaptcha',
+ ...payload
+ };
+
+ dispatch(setCaptchaValue({
+ refreshing: true
+ }));
+
+ const promise = requestAction(actionPayload);
+
+ promise.done((data) => {
+ if (!data.captchaRequest) {
+ dispatch(setCaptchaValue({
+ refreshing: false
+ }));
+ }
+
+ dispatch(setCaptchaValue({
+ refreshing: false,
+ ...data.captchaRequest
+ }));
+ });
+
+ promise.fail(() => {
+ dispatch(setCaptchaValue({
+ refreshing: false
+ }));
+ });
+ };
+ },
+
+ [types.GET_CAPTCHA_COOKIE]: function(payload) {
+ return (dispatch, getState) => {
+ const state = getState().captcha;
+
+ const queryParams = {
+ responseUrl: state.responseUrl,
+ ray: state.ray,
+ captchaResponse: payload.captchaResponse
+ };
+
+ const actionPayload = {
+ action: 'getCaptchaCookie',
+ queryParams,
+ ...payload
+ };
+
+ const promise = requestAction(actionPayload);
+
+ promise.done((data) => {
+ dispatch(setCaptchaValue({
+ token: data.captchaToken
+ }));
+ });
+ };
+ }
+};
+
+export default captchaActionHandlers;
diff --git a/frontend/src/Store/Actions/captchaActions.js b/frontend/src/Store/Actions/captchaActions.js
new file mode 100644
index 000000000..e87a2a088
--- /dev/null
+++ b/frontend/src/Store/Actions/captchaActions.js
@@ -0,0 +1,8 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import captchaActionHandlers from './captchaActionHandlers';
+
+export const refreshCaptcha = captchaActionHandlers[types.REFRESH_CAPTCHA];
+export const getCaptchaCookie = captchaActionHandlers[types.GET_CAPTCHA_COOKIE];
+export const setCaptchaValue = createAction(types.SET_CAPTCHA_VALUE);
+export const resetCaptcha = createAction(types.RESET_CAPTCHA);
diff --git a/frontend/src/Store/Actions/commandActionHandlers.js b/frontend/src/Store/Actions/commandActionHandlers.js
new file mode 100644
index 000000000..e0f8baea0
--- /dev/null
+++ b/frontend/src/Store/Actions/commandActionHandlers.js
@@ -0,0 +1,141 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import { isSameCommand } from 'Utilities/Command';
+import { messageTypes } from 'Helpers/Props';
+import * as types from './actionTypes';
+import createFetchHandler from './Creators/createFetchHandler';
+import { showMessage, hideMessage } from './appActions';
+import { updateItem } from './baseActions';
+import { addCommand, removeCommand } from './commandActions';
+
+let lastCommand = null;
+let lastCommandTimeout = null;
+const removeCommandTimeoutIds = {};
+
+function showCommandMessage(payload, dispatch) {
+ const {
+ id,
+ name,
+ manual,
+ message,
+ body = {},
+ state
+ } = payload;
+
+ const {
+ sendUpdatesToClient,
+ suppressMessages
+ } = body;
+
+ if (!message || !body || !sendUpdatesToClient || suppressMessages) {
+ return;
+ }
+
+ let type = messageTypes.INFO;
+ let hideAfter = 0;
+
+ if (state === 'completed') {
+ type = messageTypes.SUCCESS;
+ hideAfter = 4;
+ } else if (state === 'failed') {
+ type = messageTypes.ERROR;
+ hideAfter = manual ? 10 : 4;
+ }
+
+ dispatch(showMessage({
+ id,
+ name,
+ message,
+ type,
+ hideAfter
+ }));
+}
+
+function scheduleRemoveCommand(command, dispatch) {
+ const {
+ id,
+ state
+ } = command;
+
+ if (state === 'queued') {
+ return;
+ }
+
+ const timeoutId = removeCommandTimeoutIds[id];
+
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+
+ removeCommandTimeoutIds[id] = setTimeout(() => {
+ dispatch(batchActions([
+ removeCommand({ section: 'commands', id }),
+ hideMessage({ id })
+ ]));
+
+ delete removeCommandTimeoutIds[id];
+ }, 30000);
+}
+
+const commandActionHandlers = {
+ [types.FETCH_COMMANDS]: createFetchHandler('commands', '/command'),
+
+ [types.EXECUTE_COMMAND](payload) {
+ return (dispatch, getState) => {
+ // TODO: show a message for the user
+ if (lastCommand && isSameCommand(lastCommand, payload)) {
+ console.warn('Please wait at least 5 seconds before running this command again');
+ }
+
+ lastCommand = payload;
+
+ // clear last command after 5 seconds.
+ if (lastCommandTimeout) {
+ clearTimeout(lastCommandTimeout);
+ }
+
+ lastCommandTimeout = setTimeout(() => {
+ lastCommand = null;
+ }, 5000);
+
+ const promise = $.ajax({
+ url: '/command',
+ method: 'POST',
+ data: JSON.stringify(payload)
+ });
+
+ promise.done((data) => {
+ dispatch(addCommand(data));
+ });
+ };
+ },
+
+ [types.UPDATE_COMMAND](payload) {
+ return (dispatch, getState) => {
+ dispatch(updateItem({ section: 'commands', ...payload }));
+
+ showCommandMessage(payload, dispatch);
+ scheduleRemoveCommand(payload, dispatch);
+ };
+ },
+
+ [types.FINISH_COMMAND](payload) {
+ return (dispatch, getState) => {
+ const state = getState();
+ const handlers = state.commands.handlers;
+ Object.keys(handlers).forEach((key) => {
+ const handler = handlers[key];
+
+ if (handler.name === payload.name) {
+ dispatch(handler.handler(payload));
+ }
+ });
+
+ dispatch(removeCommand({ section: 'commands', ...payload }));
+ showCommandMessage(payload, dispatch);
+ };
+ }
+
+};
+
+export default commandActionHandlers;
diff --git a/frontend/src/Store/Actions/commandActions.js b/frontend/src/Store/Actions/commandActions.js
new file mode 100644
index 000000000..84b6d4fdb
--- /dev/null
+++ b/frontend/src/Store/Actions/commandActions.js
@@ -0,0 +1,14 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import commandActionHandlers from './commandActionHandlers';
+
+export const fetchCommands = commandActionHandlers[types.FETCH_COMMANDS];
+export const executeCommand = commandActionHandlers[types.EXECUTE_COMMAND];
+export const updateCommand = commandActionHandlers[types.UPDATE_COMMAND];
+export const finishCommand = commandActionHandlers[types.FINISH_COMMAND];
+
+export const addCommand = createAction(types.ADD_COMMAND);
+export const removeCommand = createAction(types.REMOVE_COMMAND);
+
+export const registerFinishCommandHandler = createAction(types.REGISTER_FINISH_COMMAND_HANDLER);
+export const unregisterFinishCommandHandler = createAction(types.UNREGISTER_FINISH_COMMAND_HANDLER);
diff --git a/frontend/src/Store/Actions/episodeActionHandlers.js b/frontend/src/Store/Actions/episodeActionHandlers.js
new file mode 100644
index 000000000..b74f5c3fa
--- /dev/null
+++ b/frontend/src/Store/Actions/episodeActionHandlers.js
@@ -0,0 +1,111 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import episodeEntities from 'Album/episodeEntities';
+import createFetchHandler from './Creators/createFetchHandler';
+import * as types from './actionTypes';
+import { updateItem } from './baseActions';
+
+const section = 'episodes';
+
+const episodeActionHandlers = {
+ [types.FETCH_EPISODES]: createFetchHandler(section, '/album'),
+
+ [types.TOGGLE_EPISODE_MONITORED]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ albumId,
+ episodeEntity = episodeEntities.EPISODES,
+ monitored
+ } = payload;
+
+ const episodeSection = _.last(episodeEntity.split('.'));
+
+ dispatch(updateItem({
+ id: albumId,
+ section: episodeSection,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: `/album/${albumId}`,
+ method: 'PUT',
+ data: JSON.stringify({ monitored }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(updateItem({
+ id: albumId,
+ section: episodeSection,
+ isSaving: false,
+ monitored
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ id: albumId,
+ section: episodeSection,
+ isSaving: false
+ }));
+ });
+ };
+ },
+
+ [types.TOGGLE_EPISODES_MONITORED]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ albumIds,
+ episodeEntity = episodeEntities.EPISODES,
+ monitored
+ } = payload;
+
+ const episodeSection = _.last(episodeEntity.split('.'));
+
+ dispatch(batchActions(
+ albumIds.map((albumId) => {
+ return updateItem({
+ id: albumId,
+ section: episodeSection,
+ isSaving: true
+ });
+ })
+ ));
+
+ const promise = $.ajax({
+ url: '/album/monitor',
+ method: 'PUT',
+ data: JSON.stringify({ albumIds, monitored }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions(
+ albumIds.map((albumId) => {
+ return updateItem({
+ id: albumId,
+ section: episodeSection,
+ isSaving: false,
+ monitored
+ });
+ })
+ ));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions(
+ albumIds.map((albumId) => {
+ return updateItem({
+ id: albumId,
+ section: episodeSection,
+ isSaving: false
+ });
+ })
+ ));
+ });
+ };
+ }
+};
+
+export default episodeActionHandlers;
diff --git a/frontend/src/Store/Actions/episodeActions.js b/frontend/src/Store/Actions/episodeActions.js
new file mode 100644
index 000000000..b0abe85eb
--- /dev/null
+++ b/frontend/src/Store/Actions/episodeActions.js
@@ -0,0 +1,10 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import episodeActionHandlers from './episodeActionHandlers';
+
+export const fetchEpisodes = episodeActionHandlers[types.FETCH_EPISODES];
+export const setEpisodesSort = createAction(types.SET_EPISODES_SORT);
+export const setEpisodesTableOption = createAction(types.SET_EPISODES_TABLE_OPTION);
+export const clearEpisodes = createAction(types.CLEAR_EPISODES);
+export const toggleEpisodeMonitored = episodeActionHandlers[types.TOGGLE_EPISODE_MONITORED];
+export const toggleEpisodesMonitored = episodeActionHandlers[types.TOGGLE_EPISODES_MONITORED];
diff --git a/frontend/src/Store/Actions/historyActionHandlers.js b/frontend/src/Store/Actions/historyActionHandlers.js
new file mode 100644
index 000000000..44cff0fc3
--- /dev/null
+++ b/frontend/src/Store/Actions/historyActionHandlers.js
@@ -0,0 +1,60 @@
+import $ from 'jquery';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import * as types from './actionTypes';
+import { updateItem } from './baseActions';
+
+const section = 'history';
+
+const historyActionHandlers = {
+ ...createServerSideCollectionHandlers('history', '/history', (state) => state.history, {
+ [serverSideCollectionHandlers.FETCH]: types.FETCH_HISTORY,
+ [serverSideCollectionHandlers.FIRST_PAGE]: types.GOTO_FIRST_HISTORY_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: types.GOTO_PREVIOUS_HISTORY_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: types.GOTO_NEXT_HISTORY_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: types.GOTO_LAST_HISTORY_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: types.GOTO_HISTORY_PAGE,
+ [serverSideCollectionHandlers.SORT]: types.SET_HISTORY_SORT,
+ [serverSideCollectionHandlers.FILTER]: types.SET_HISTORY_FILTER
+ }),
+
+ [types.MARK_AS_FAILED]: function(payload) {
+ return function(dispatch, getState) {
+ const id = payload.id;
+
+ dispatch(updateItem({
+ section,
+ id,
+ isMarkingAsFailed: true
+ }));
+
+ const promise = $.ajax({
+ url: '/history/failed',
+ method: 'POST',
+ data: {
+ id
+ }
+ });
+
+ promise.done(() => {
+ dispatch(updateItem({
+ section,
+ id,
+ isMarkingAsFailed: false,
+ markAsFailedError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ section,
+ id,
+ isMarkingAsFailed: false,
+ markAsFailedError: xhr
+ }));
+ });
+ };
+ }
+};
+
+export default historyActionHandlers;
diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js
new file mode 100644
index 000000000..d4fe71be9
--- /dev/null
+++ b/frontend/src/Store/Actions/historyActions.js
@@ -0,0 +1,16 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import historyActionHandlers from './historyActionHandlers';
+
+export const fetchHistory = historyActionHandlers[types.FETCH_HISTORY];
+export const gotoHistoryFirstPage = historyActionHandlers[types.GOTO_FIRST_HISTORY_PAGE];
+export const gotoHistoryPreviousPage = historyActionHandlers[types.GOTO_PREVIOUS_HISTORY_PAGE];
+export const gotoHistoryNextPage = historyActionHandlers[types.GOTO_NEXT_HISTORY_PAGE];
+export const gotoHistoryLastPage = historyActionHandlers[types.GOTO_LAST_HISTORY_PAGE];
+export const gotoHistoryPage = historyActionHandlers[types.GOTO_HISTORY_PAGE];
+export const setHistorySort = historyActionHandlers[types.SET_HISTORY_SORT];
+export const setHistoryFilter = historyActionHandlers[types.SET_HISTORY_FILTER];
+export const setHistoryTableOption = createAction(types.SET_HISTORY_TABLE_OPTION);
+export const clearHistory = createAction(types.CLEAR_HISTORY);
+
+export const markAsFailed = historyActionHandlers[types.MARK_AS_FAILED];
diff --git a/frontend/src/Store/Actions/importArtistActionHandlers.js b/frontend/src/Store/Actions/importArtistActionHandlers.js
new file mode 100644
index 000000000..3fd7099e1
--- /dev/null
+++ b/frontend/src/Store/Actions/importArtistActionHandlers.js
@@ -0,0 +1,172 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import getNewSeries from 'Utilities/Series/getNewSeries';
+import * as types from './actionTypes';
+import { set, updateItem, removeItem } from './baseActions';
+import { startLookupArtist } from './importArtistActions';
+import { fetchRootFolders } from './rootFolderActions';
+
+const section = 'importArtist';
+let concurrentLookups = 0;
+
+const importArtistActionHandlers = {
+ [types.QUEUE_LOOKUP_ARTIST]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ name,
+ path,
+ term
+ } = payload;
+
+ const state = getState().importArtist;
+ const item = _.find(state.items, { id: name }) || {
+ id: name,
+ term,
+ path,
+ isFetching: false,
+ isPopulated: false,
+ error: null
+ };
+
+ dispatch(updateItem({
+ section,
+ ...item,
+ term,
+ queued: true,
+ items: []
+ }));
+
+ if (term && term.length > 2) {
+ dispatch(startLookupArtist());
+ }
+ };
+ },
+
+ [types.START_LOOKUP_ARTIST]: function(payload) {
+ return function(dispatch, getState) {
+ if (concurrentLookups >= 1) {
+ return;
+ }
+
+ const state = getState().importArtist;
+ const queued = _.find(state.items, { queued: true });
+
+ if (!queued) {
+ return;
+ }
+
+ concurrentLookups++;
+
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: true
+ }));
+
+ const promise = $.ajax({
+ url: '/artist/lookup',
+ data: {
+ term: queued.term
+ }
+ });
+
+ promise.done((data) => {
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: false,
+ isPopulated: true,
+ error: null,
+ items: data,
+ queued: false,
+ selectedArtist: queued.selectedArtist || data[0]
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ section,
+ id: queued.id,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr,
+ queued: false
+ }));
+ });
+
+ promise.always(() => {
+ concurrentLookups--;
+ dispatch(startLookupArtist());
+ });
+ };
+ },
+
+ [types.IMPORT_ARTIST]: function(payload) {
+ return function(dispatch, getState) {
+ dispatch(set({ section, isImporting: true }));
+
+ const ids = payload.ids;
+ const items = getState().importArtist.items;
+ const addedIds = [];
+
+ const allNewSeries = ids.reduce((acc, id) => {
+ const item = _.find(items, { id });
+ const selectedArtist = item.selectedArtist;
+
+ // Make sure we have a selected artist and
+ // the same artist hasn't been added yet.
+ if (selectedArtist && !_.some(acc, { foreignArtistId: selectedArtist.foreignArtistId })) {
+ const newSeries = getNewSeries(_.cloneDeep(selectedArtist), item);
+ newSeries.path = item.path;
+
+ addedIds.push(id);
+ acc.push(newSeries);
+ }
+
+ return acc;
+ }, []);
+
+ const promise = $.ajax({
+ url: '/artist/import',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(allNewSeries)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({
+ section,
+ isImporting: false,
+ isImported: true
+ }),
+
+ ...data.map((artist) => updateItem({ section: 'artist', ...artist })),
+
+ ...addedIds.map((id) => removeItem({ section, id }))
+ ]));
+
+ dispatch(fetchRootFolders());
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions(
+ set({
+ section,
+ isImporting: false,
+ isImported: true
+ }),
+
+ addedIds.map((id) => updateItem({
+ section,
+ id,
+ importError: xhr
+ }))
+ ));
+ });
+ };
+ }
+};
+
+export default importArtistActionHandlers;
diff --git a/frontend/src/Store/Actions/importArtistActions.js b/frontend/src/Store/Actions/importArtistActions.js
new file mode 100644
index 000000000..7bbf8a78b
--- /dev/null
+++ b/frontend/src/Store/Actions/importArtistActions.js
@@ -0,0 +1,16 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import importArtistActionHandlers from './importArtistActionHandlers';
+
+export const queueLookupArtist = importArtistActionHandlers[types.QUEUE_LOOKUP_ARTIST];
+export const startLookupArtist = importArtistActionHandlers[types.START_LOOKUP_ARTIST];
+export const importArtist = importArtistActionHandlers[types.IMPORT_ARTIST];
+export const clearImportArtist = createAction(types.CLEAR_IMPORT_ARTIST);
+
+export const setImportArtistValue = createAction(types.SET_IMPORT_ARTIST_VALUE, (payload) => {
+ return {
+
+ section: 'importArtist',
+ ...payload
+ };
+});
diff --git a/frontend/src/Store/Actions/interactiveImportActionHandlers.js b/frontend/src/Store/Actions/interactiveImportActionHandlers.js
new file mode 100644
index 000000000..811916bc0
--- /dev/null
+++ b/frontend/src/Store/Actions/interactiveImportActionHandlers.js
@@ -0,0 +1,51 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import createFetchHandler from './Creators/createFetchHandler';
+import * as types from './actionTypes';
+import { set, update } from './baseActions';
+
+const section = 'interactiveImport';
+
+const interactiveImportActionHandlers = {
+ [types.FETCH_INTERACTIVE_IMPORT_ITEMS]: function(payload) {
+ return function(dispatch, getState) {
+ if (!payload.downloadId && !payload.folder) {
+ dispatch(set({ section, error: { message: '`downloadId` or `folder` is required.' } }));
+ return;
+ }
+
+ dispatch(set({ section, isFetching: true }));
+
+ const promise = $.ajax({
+ url: '/manualimport',
+ data: payload
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ };
+ },
+
+ [types.FETCH_INTERACTIVE_IMPORT_ALBUMS]: createFetchHandler('interactiveImportAlbums', '/album')
+};
+
+export default interactiveImportActionHandlers;
diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js
new file mode 100644
index 000000000..2a8da6efb
--- /dev/null
+++ b/frontend/src/Store/Actions/interactiveImportActions.js
@@ -0,0 +1,15 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import interactiveImportActionHandlers from './interactiveImportActionHandlers';
+
+export const fetchInteractiveImportItems = interactiveImportActionHandlers[types.FETCH_INTERACTIVE_IMPORT_ITEMS];
+export const setInteractiveImportSort = createAction(types.SET_INTERACTIVE_IMPORT_SORT);
+export const updateInteractiveImportItem = createAction(types.UPDATE_INTERACTIVE_IMPORT_ITEM);
+export const clearInteractiveImport = createAction(types.CLEAR_INTERACTIVE_IMPORT);
+export const addRecentFolder = createAction(types.ADD_RECENT_FOLDER);
+export const removeRecentFolder = createAction(types.REMOVE_RECENT_FOLDER);
+export const setInteractiveImportMode = createAction(types.SET_INTERACTIVE_IMPORT_MODE);
+
+export const fetchInteractiveImportAlbums = interactiveImportActionHandlers[types.FETCH_INTERACTIVE_IMPORT_ALBUMS];
+export const setInteractiveImportAlbumsSort = createAction(types.SET_INTERACTIVE_IMPORT_ALBUMS_SORT);
+export const clearInteractiveImportAlbums = createAction(types.CLEAR_INTERACTIVE_IMPORT_ALBUMS);
diff --git a/frontend/src/Store/Actions/oAuthActionHandlers.js b/frontend/src/Store/Actions/oAuthActionHandlers.js
new file mode 100644
index 000000000..aa2d5e038
--- /dev/null
+++ b/frontend/src/Store/Actions/oAuthActionHandlers.js
@@ -0,0 +1,81 @@
+/* eslint callback-return: 0 */
+import _ from 'lodash';
+import $ from 'jquery';
+import requestAction from 'Utilities/requestAction';
+import * as types from './actionTypes';
+import { setOAuthValue } from './oAuthActions';
+
+function showOAuthWindow(url) {
+ const deferred = $.Deferred();
+ const selfWindow = window;
+
+ window.open(url);
+
+ selfWindow.onCompleteOauth = function(query, callback) {
+ delete selfWindow.onCompleteOauth;
+
+ const queryParams = {};
+ const splitQuery = query.substring(1).split('&');
+
+ _.each(splitQuery, (param) => {
+ const paramSplit = param.split('=');
+
+ queryParams[paramSplit[0]] = paramSplit[1];
+ });
+
+ callback();
+ deferred.resolve(queryParams);
+ };
+
+ return deferred.promise();
+}
+
+const oAuthActionHandlers = {
+
+ [types.START_OAUTH]: function(payload) {
+ return (dispatch, getState) => {
+ const actionPayload = {
+ action: 'startOAuth',
+ queryParams: { callbackUrl: `${window.location.origin}/oauth.html` },
+ ...payload
+ };
+
+ dispatch(setOAuthValue({
+ authorizing: true
+ }));
+
+ const promise = requestAction(actionPayload)
+ .then((response) => {
+ return showOAuthWindow(response.oauthUrl);
+ })
+ .then((queryParams) => {
+ return requestAction({
+ action: 'getOAuthToken',
+ queryParams,
+ ...payload
+ });
+ })
+ .then((response) => {
+ const {
+ accessToken,
+ accessTokenSecret
+ } = response;
+
+ dispatch(setOAuthValue({
+ authorizing: false,
+ accessToken,
+ accessTokenSecret
+ }));
+ });
+
+ promise.fail(() => {
+ dispatch(setOAuthValue({
+ authorizing: false
+ }));
+ });
+ };
+ }
+
+};
+
+export default oAuthActionHandlers;
diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js
new file mode 100644
index 000000000..93de6f11f
--- /dev/null
+++ b/frontend/src/Store/Actions/oAuthActions.js
@@ -0,0 +1,7 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import oAuthActionHandlers from './oAuthActionHandlers';
+
+export const startOAuth = oAuthActionHandlers[types.START_OAUTH];
+export const setOAuthValue = createAction(types.SET_OAUTH_VALUE);
+export const resetOAuth = createAction(types.RESET_OAUTH);
diff --git a/frontend/src/Store/Actions/organizePreviewActionHandlers.js b/frontend/src/Store/Actions/organizePreviewActionHandlers.js
new file mode 100644
index 000000000..d0901b5ea
--- /dev/null
+++ b/frontend/src/Store/Actions/organizePreviewActionHandlers.js
@@ -0,0 +1,8 @@
+import createFetchHandler from './Creators/createFetchHandler';
+import * as types from './actionTypes';
+
+const organizePreviewActionHandlers = {
+ [types.FETCH_ORGANIZE_PREVIEW]: createFetchHandler('organizePreview', '/rename')
+};
+
+export default organizePreviewActionHandlers;
diff --git a/frontend/src/Store/Actions/organizePreviewActions.js b/frontend/src/Store/Actions/organizePreviewActions.js
new file mode 100644
index 000000000..602028ff4
--- /dev/null
+++ b/frontend/src/Store/Actions/organizePreviewActions.js
@@ -0,0 +1,6 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import organizePreviewActionHandlers from './organizePreviewActionHandlers';
+
+export const fetchOrganizePreview = organizePreviewActionHandlers[types.FETCH_ORGANIZE_PREVIEW];
+export const clearOrganizePreview = createAction(types.CLEAR_ORGANIZE_PREVIEW);
diff --git a/frontend/src/Store/Actions/pathActionHandlers.js b/frontend/src/Store/Actions/pathActionHandlers.js
new file mode 100644
index 000000000..11ebfaf21
--- /dev/null
+++ b/frontend/src/Store/Actions/pathActionHandlers.js
@@ -0,0 +1,43 @@
+import $ from 'jquery';
+import * as types from './actionTypes';
+import { set } from './baseActions';
+import { updatePaths } from './pathActions';
+
+const section = 'paths';
+
+const pathActionHandlers = {
+ [types.FETCH_PATHS](payload) {
+ return (dispatch, getState) => {
+ dispatch(set({ section, isFetching: true }));
+
+ const promise = $.ajax({
+ url: '/filesystem',
+ data: {
+ path: payload.path
+ }
+ });
+
+ promise.done((data) => {
+ dispatch(updatePaths({ path: payload.path, ...data }));
+
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ };
+ }
+};
+
+export default pathActionHandlers;
diff --git a/frontend/src/Store/Actions/pathActions.js b/frontend/src/Store/Actions/pathActions.js
new file mode 100644
index 000000000..fd4e76f02
--- /dev/null
+++ b/frontend/src/Store/Actions/pathActions.js
@@ -0,0 +1,7 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import pathActionHandlers from './pathActionHandlers';
+
+export const fetchPaths = pathActionHandlers[types.FETCH_PATHS];
+export const updatePaths = createAction(types.UPDATE_PATHS);
+export const clearPaths = createAction(types.CLEAR_PATHS);
diff --git a/frontend/src/Store/Actions/queueActionHandlers.js b/frontend/src/Store/Actions/queueActionHandlers.js
new file mode 100644
index 000000000..494942b61
--- /dev/null
+++ b/frontend/src/Store/Actions/queueActionHandlers.js
@@ -0,0 +1,233 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import createFetchHandler from './Creators/createFetchHandler';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import * as types from './actionTypes';
+import { set, updateItem } from './baseActions';
+import { fetchQueue } from './queueActions';
+
+const fetchQueueDetailsHandler = createFetchHandler('details', '/queue/details');
+
+const queueActionHandlers = {
+ [types.FETCH_QUEUE_STATUS]: createFetchHandler('queueStatus', '/queue/status'),
+
+ [types.FETCH_QUEUE_DETAILS]: function(payload) {
+ return function(dispatch, getState) {
+ let params = payload;
+
+ // If the payload params are empty try to get params from state.
+
+ if (params && !_.isEmpty(params)) {
+ dispatch(set({ section: 'details', params }));
+ } else {
+ params = getState().queue.details.params;
+ }
+
+ // Ensure there are params before trying to fetch the queue
+ // so we don't make a bad request to the server.
+
+ if (params && !_.isEmpty(params)) {
+ const fetchFunction = fetchQueueDetailsHandler(params);
+ fetchFunction(dispatch, getState);
+ }
+ };
+ },
+
+ ...createServerSideCollectionHandlers('paged', '/queue', (state) => state.queue, {
+ [serverSideCollectionHandlers.FETCH]: types.FETCH_QUEUE,
+ [serverSideCollectionHandlers.FIRST_PAGE]: types.GOTO_FIRST_QUEUE_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: types.GOTO_PREVIOUS_QUEUE_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: types.GOTO_NEXT_QUEUE_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: types.GOTO_LAST_QUEUE_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: types.GOTO_QUEUE_PAGE,
+ [serverSideCollectionHandlers.SORT]: types.SET_QUEUE_SORT
+ }),
+
+ [types.GRAB_QUEUE_ITEM]: function(payload) {
+ const section = 'paged';
+
+ const {
+ id
+ } = payload;
+
+ return function(dispatch, getState) {
+ dispatch(updateItem({ section, id, isGrabbing: true }));
+
+ const promise = $.ajax({
+ url: `/queue/grab/${id}`,
+ method: 'POST'
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ dispatch(fetchQueue()),
+
+ set({
+ section,
+ isGrabbing: false,
+ grabError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({
+ section,
+ id,
+ isGrabbing: false,
+ grabError: xhr
+ }));
+ });
+ };
+ },
+
+ [types.GRAB_QUEUE_ITEMS]: function(payload) {
+ const section = 'paged';
+
+ const {
+ ids
+ } = payload;
+
+ return function(dispatch, getState) {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section,
+ id,
+ isGrabbing: true
+ });
+ }),
+
+ set({
+ section,
+ isGrabbing: true
+ })
+ ]));
+
+ const promise = $.ajax({
+ url: '/queue/grab/bulk',
+ method: 'POST',
+ dataType: 'json',
+ data: JSON.stringify(payload)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ dispatch(fetchQueue()),
+
+ ...ids.map((id) => {
+ return updateItem({
+ section,
+ id,
+ isGrabbing: false,
+ grabError: null
+ });
+ }),
+
+ set({
+ section,
+ isGrabbing: false,
+ grabError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section,
+ id,
+ isGrabbing: false,
+ grabError: null
+ });
+ }),
+
+ set({ section, isGrabbing: false })
+ ]));
+ });
+ };
+ },
+
+ [types.REMOVE_QUEUE_ITEM]: function(payload) {
+ const section = 'paged';
+
+ const {
+ id,
+ blacklist
+ } = payload;
+
+ return function(dispatch, getState) {
+ dispatch(updateItem({ section, id, isRemoving: true }));
+
+ const promise = $.ajax({
+ url: `/queue/${id}?blacklist=${blacklist}`,
+ method: 'DELETE'
+ });
+
+ promise.done((data) => {
+ dispatch(fetchQueue());
+ });
+
+ promise.fail((xhr) => {
+ dispatch(updateItem({ section, id, isRemoving: false }));
+ });
+ };
+ },
+
+ [types.REMOVE_QUEUE_ITEMS]: function(payload) {
+ const section = 'paged';
+
+ const {
+ ids,
+ blacklist
+ } = payload;
+
+ return function(dispatch, getState) {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section,
+ id,
+ isRemoving: true
+ });
+ }),
+
+ set({ section, isRemoving: true })
+ ]));
+
+ const promise = $.ajax({
+ url: `/queue/bulk?blacklist=${blacklist}`,
+ method: 'DELETE',
+ dataType: 'json',
+ data: JSON.stringify({ ids })
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ set({ section, isRemoving: false }),
+ fetchQueue()
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(batchActions([
+ ...ids.map((id) => {
+ return updateItem({
+ section,
+ id,
+ isRemoving: false
+ });
+ }),
+
+ set({ section, isRemoving: false })
+ ]));
+ });
+ };
+ }
+
+};
+
+export default queueActionHandlers;
diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js
new file mode 100644
index 000000000..51dda41df
--- /dev/null
+++ b/frontend/src/Store/Actions/queueActions.js
@@ -0,0 +1,23 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import queueActionHandlers from './queueActionHandlers';
+
+export const fetchQueueStatus = queueActionHandlers[types.FETCH_QUEUE_STATUS];
+
+export const fetchQueueDetails = queueActionHandlers[types.FETCH_QUEUE_DETAILS];
+export const clearQueueDetails = createAction(types.CLEAR_QUEUE_DETAILS);
+
+export const fetchQueue = queueActionHandlers[types.FETCH_QUEUE];
+export const gotoQueueFirstPage = queueActionHandlers[types.GOTO_FIRST_QUEUE_PAGE];
+export const gotoQueuePreviousPage = queueActionHandlers[types.GOTO_PREVIOUS_QUEUE_PAGE];
+export const gotoQueueNextPage = queueActionHandlers[types.GOTO_NEXT_QUEUE_PAGE];
+export const gotoQueueLastPage = queueActionHandlers[types.GOTO_LAST_QUEUE_PAGE];
+export const gotoQueuePage = queueActionHandlers[types.GOTO_QUEUE_PAGE];
+export const setQueueSort = queueActionHandlers[types.SET_QUEUE_SORT];
+export const setQueueTableOption = createAction(types.SET_QUEUE_TABLE_OPTION);
+export const clearQueue = createAction(types.CLEAR_QUEUE);
+
+export const grabQueueItem = queueActionHandlers[types.GRAB_QUEUE_ITEM];
+export const grabQueueItems = queueActionHandlers[types.GRAB_QUEUE_ITEMS];
+export const removeQueueItem = queueActionHandlers[types.REMOVE_QUEUE_ITEM];
+export const removeQueueItems = queueActionHandlers[types.REMOVE_QUEUE_ITEMS];
diff --git a/frontend/src/Store/Actions/releaseActionHandlers.js b/frontend/src/Store/Actions/releaseActionHandlers.js
new file mode 100644
index 000000000..15ebaa2b0
--- /dev/null
+++ b/frontend/src/Store/Actions/releaseActionHandlers.js
@@ -0,0 +1,47 @@
+import $ from 'jquery';
+import createFetchHandler from './Creators/createFetchHandler';
+import * as types from './actionTypes';
+import { updateRelease } from './releaseActions';
+
+const section = 'releases';
+
+const releaseActionHandlers = {
+ [types.FETCH_RELEASES]: createFetchHandler(section, '/release'),
+
+ [types.GRAB_RELEASE]: function(payload) {
+ return function(dispatch, getState) {
+ const guid = payload.guid;
+
+ dispatch(updateRelease({ guid, isGrabbing: true }));
+
+ const promise = $.ajax({
+ url: '/release',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify(payload)
+ });
+
+ promise.done((data) => {
+ dispatch(updateRelease({
+ guid,
+ isGrabbing: false,
+ isGrabbed: true,
+ grabError: null
+ }));
+ });
+
+ promise.fail((xhr) => {
+ const grabError = xhr.responseJSON && xhr.responseJSON.message || 'Failed to add to download queue';
+
+ dispatch(updateRelease({
+ guid,
+ isGrabbing: false,
+ isGrabbed: false,
+ grabError
+ }));
+ });
+ };
+ }
+};
+
+export default releaseActionHandlers;
diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js
new file mode 100644
index 000000000..580ffe804
--- /dev/null
+++ b/frontend/src/Store/Actions/releaseActions.js
@@ -0,0 +1,9 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import releaseActionHandlers from './releaseActionHandlers';
+
+export const fetchReleases = releaseActionHandlers[types.FETCH_RELEASES];
+export const setReleasesSort = createAction(types.SET_RELEASES_SORT);
+export const clearReleases = createAction(types.CLEAR_RELEASES);
+export const grabRelease = releaseActionHandlers[types.GRAB_RELEASE];
+export const updateRelease = createAction(types.UPDATE_RELEASE);
diff --git a/frontend/src/Store/Actions/rootFolderActionHandlers.js b/frontend/src/Store/Actions/rootFolderActionHandlers.js
new file mode 100644
index 000000000..bf0800f31
--- /dev/null
+++ b/frontend/src/Store/Actions/rootFolderActionHandlers.js
@@ -0,0 +1,60 @@
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import * as types from './actionTypes';
+import createFetchHandler from './Creators/createFetchHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import { set, updateItem } from './baseActions';
+
+const section = 'rootFolders';
+
+const rootFolderActionHandlers = {
+ [types.FETCH_ROOT_FOLDERS]: createFetchHandler('rootFolders', '/rootFolder'),
+
+ [types.DELETE_ROOT_FOLDER]: createRemoveItemHandler(
+ 'rootFolders',
+ '/rootFolder',
+ (state) => state.rootFolders),
+
+ [types.ADD_ROOT_FOLDER]: function(payload) {
+ return function(dispatch, getState) {
+ const path = payload.path;
+
+ dispatch(set({
+ section,
+ isSaving: true
+ }));
+
+ const promise = $.ajax({
+ url: '/rootFolder',
+ method: 'POST',
+ data: JSON.stringify({ path }),
+ dataType: 'json'
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ updateItem({
+ section,
+ ...data
+ }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ };
+ }
+};
+
+export default rootFolderActionHandlers;
diff --git a/frontend/src/Store/Actions/rootFolderActions.js b/frontend/src/Store/Actions/rootFolderActions.js
new file mode 100644
index 000000000..0d0b8112a
--- /dev/null
+++ b/frontend/src/Store/Actions/rootFolderActions.js
@@ -0,0 +1,6 @@
+import * as types from './actionTypes';
+import rootFolderActionHandlers from './rootFolderActionHandlers';
+
+export const fetchRootFolders = rootFolderActionHandlers[types.FETCH_ROOT_FOLDERS];
+export const addRootFolder = rootFolderActionHandlers[types.ADD_ROOT_FOLDER];
+export const deleteRootFolder = rootFolderActionHandlers[types.DELETE_ROOT_FOLDER];
diff --git a/frontend/src/Store/Actions/settingsActionHandlers.js b/frontend/src/Store/Actions/settingsActionHandlers.js
new file mode 100644
index 000000000..abf1bdd90
--- /dev/null
+++ b/frontend/src/Store/Actions/settingsActionHandlers.js
@@ -0,0 +1,273 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import * as types from './actionTypes';
+import createFetchHandler from './Creators/createFetchHandler';
+import createFetchSchemaHandler from './Creators/createFetchSchemaHandler';
+import createSaveHandler from './Creators/createSaveHandler';
+import createSaveProviderHandler, { createCancelSaveProviderHandler } from './Creators/createSaveProviderHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import createTestProviderHandler, { createCancelTestProviderHandler } from './Creators/createTestProviderHandler';
+import { set, update, clearPendingChanges } from './baseActions';
+
+const settingsActionHandlers = {
+ [types.FETCH_UI_SETTINGS]: createFetchHandler('ui', '/config/ui'),
+ [types.SAVE_UI_SETTINGS]: createSaveHandler('ui', '/config/ui', (state) => state.settings.ui),
+
+ [types.FETCH_MEDIA_MANAGEMENT_SETTINGS]: createFetchHandler('mediaManagement', '/config/mediamanagement'),
+ [types.SAVE_MEDIA_MANAGEMENT_SETTINGS]: createSaveHandler('mediaManagement', '/config/mediamanagement', (state) => state.settings.mediaManagement),
+
+ [types.FETCH_NAMING_SETTINGS]: createFetchHandler('naming', '/config/naming'),
+ [types.SAVE_NAMING_SETTINGS]: createSaveHandler('naming', '/config/naming', (state) => state.settings.naming),
+
+ [types.FETCH_NAMING_EXAMPLES]: function(payload) {
+ const section = 'namingExamples';
+
+ return function(dispatch, getState) {
+ dispatch(set({ section, isFetching: true }));
+
+ const naming = getState().settings.naming;
+
+ const promise = $.ajax({
+ url: '/config/naming/examples',
+ data: Object.assign({}, naming.item, naming.pendingChanges)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+
+ set({
+ section,
+ isFetching: false,
+ isPopulated: true,
+ error: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isFetching: false,
+ isPopulated: false,
+ error: xhr
+ }));
+ });
+ };
+ },
+
+ [types.REORDER_DELAY_PROFILE]: function(payload) {
+ const section = 'delayProfiles';
+
+ return function(dispatch, getState) {
+ const { id, moveIndex } = payload;
+ const moveOrder = moveIndex + 1;
+ const delayProfiles = getState().settings.delayProfiles.items;
+ const moving = _.find(delayProfiles, { id });
+
+ // Don't move if the order hasn't changed
+ if (moving.order === moveOrder) {
+ return;
+ }
+
+ const after = moveIndex > 0 ? _.find(delayProfiles, { order: moveIndex }) : null;
+ const afterQueryParam = after ? `after=${after.id}` : '';
+
+ const promise = $.ajax({
+ method: 'PUT',
+ url: `/delayprofile/reorder/${id}?${afterQueryParam}`
+ });
+
+ promise.done((data) => {
+ dispatch(update({ section, data }));
+ });
+ };
+ },
+
+ [types.FETCH_QUALITY_PROFILES]: createFetchHandler('qualityProfiles', '/qualityprofile'),
+ [types.FETCH_QUALITY_PROFILE_SCHEMA]: createFetchSchemaHandler('qualityProfiles', '/qualityprofile/schema'),
+
+ [types.SAVE_QUALITY_PROFILE]: createSaveProviderHandler(
+ 'qualityProfiles',
+ '/qualityprofile',
+ (state) => state.settings.qualityProfiles),
+
+ [types.DELETE_QUALITY_PROFILE]: createRemoveItemHandler(
+ 'qualityProfiles',
+ '/qualityprofile',
+ (state) => state.settings.qualityProfiles),
+
+ [types.FETCH_LANGUAGE_PROFILES]: createFetchHandler('languageProfiles', '/languageprofile'),
+ [types.FETCH_LANGUAGE_PROFILE_SCHEMA]: createFetchSchemaHandler('languageProfiles', '/languageprofile/schema'),
+
+ [types.SAVE_LANGUAGE_PROFILE]: createSaveProviderHandler(
+ 'languageProfiles',
+ '/languageprofile',
+ (state) => state.settings.languageProfiles),
+
+ [types.DELETE_LANGUAGE_PROFILE]: createRemoveItemHandler(
+ 'languageProfiles',
+ '/languageprofile',
+ (state) => state.settings.languageProfiles),
+
+ [types.FETCH_DELAY_PROFILES]: createFetchHandler('delayProfiles', '/delayprofile'),
+
+ [types.SAVE_DELAY_PROFILE]: createSaveProviderHandler(
+ 'delayProfiles',
+ '/delayprofile',
+ (state) => state.settings.delayProfiles),
+
+ [types.DELETE_DELAY_PROFILE]: createRemoveItemHandler(
+ 'delayProfiles',
+ '/delayprofile',
+ (state) => state.settings.delayProfiles),
+
+ [types.FETCH_QUALITY_DEFINITIONS]: createFetchHandler('qualityDefinitions', '/qualitydefinition'),
+ [types.SAVE_QUALITY_DEFINITIONS]: createSaveHandler('qualityDefinitions', '/qualitydefinition', (state) => state.settings.qualitydefinitions),
+
+ [types.SAVE_QUALITY_DEFINITIONS]: function() {
+ const section = 'qualityDefinitions';
+
+ return function(dispatch, getState) {
+ const qualityDefinitions = getState().settings.qualityDefinitions;
+
+ const upatedDefinitions = Object.keys(qualityDefinitions.pendingChanges).map((key) => {
+ const id = parseInt(key);
+ const pendingChanges = qualityDefinitions.pendingChanges[id] || {};
+ const item = _.find(qualityDefinitions.items, { id });
+
+ return Object.assign({}, item, pendingChanges);
+ });
+
+ // If there is nothing to save don't bother isSaving
+ if (!upatedDefinitions || !upatedDefinitions.length) {
+ return;
+ }
+
+ const promise = $.ajax({
+ method: 'PUT',
+ url: '/qualityDefinition/update',
+ data: JSON.stringify(upatedDefinitions)
+ });
+
+ promise.done((data) => {
+ dispatch(batchActions([
+ update({ section, data }),
+ clearPendingChanges({ section: 'qualityDefinitions' })
+ ]));
+ });
+ };
+ },
+
+ [types.FETCH_INDEXERS]: createFetchHandler('indexers', '/indexer'),
+ [types.FETCH_INDEXER_SCHEMA]: createFetchSchemaHandler('indexers', '/indexer/schema'),
+
+ [types.SAVE_INDEXER]: createSaveProviderHandler(
+ 'indexers',
+ '/indexer',
+ (state) => state.settings.indexers),
+
+ [types.CANCEL_SAVE_INDEXER]: createCancelSaveProviderHandler('indexers'),
+
+ [types.DELETE_INDEXER]: createRemoveItemHandler(
+ 'indexers',
+ '/indexer',
+ (state) => state.settings.indexers),
+
+ [types.TEST_INDEXER]: createTestProviderHandler(
+ 'indexers',
+ '/indexer',
+ (state) => state.settings.indexers),
+
+ [types.CANCEL_TEST_INDEXER]: createCancelTestProviderHandler('indexers'),
+
+ [types.FETCH_INDEXER_OPTIONS]: createFetchHandler('indexerOptions', '/config/indexer'),
+ [types.SAVE_INDEXER_OPTIONS]: createSaveHandler('indexerOptions', '/config/indexer', (state) => state.settings.indexerOptions),
+
+ [types.FETCH_RESTRICTIONS]: createFetchHandler('restrictions', '/restriction'),
+
+ [types.SAVE_RESTRICTION]: createSaveProviderHandler(
+ 'restrictions',
+ '/restriction',
+ (state) => state.settings.restrictions),
+
+ [types.DELETE_RESTRICTION]: createRemoveItemHandler(
+ 'restrictions',
+ '/restriction',
+ (state) => state.settings.restrictions),
+
+ [types.FETCH_DOWNLOAD_CLIENTS]: createFetchHandler('downloadClients', '/downloadclient'),
+ [types.FETCH_DOWNLOAD_CLIENT_SCHEMA]: createFetchSchemaHandler('downloadClients', '/downloadclient/schema'),
+
+ [types.SAVE_DOWNLOAD_CLIENT]: createSaveProviderHandler(
+ 'downloadClients',
+ '/downloadclient',
+ (state) => state.settings.downloadClients),
+
+ [types.CANCEL_SAVE_DOWNLOAD_CLIENT]: createCancelSaveProviderHandler('downloadClients'),
+
+ [types.DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(
+ 'downloadClients',
+ '/downloadclient',
+ (state) => state.settings.downloadClients),
+
+ [types.TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(
+ 'downloadClients',
+ '/downloadclient',
+ (state) => state.settings.downloadClients),
+
+ [types.CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler('downloadClients'),
+
+ [types.FETCH_DOWNLOAD_CLIENT_OPTIONS]: createFetchHandler('downloadClientOptions', '/config/downloadclient'),
+ [types.SAVE_DOWNLOAD_CLIENT_OPTIONS]: createSaveHandler('downloadClientOptions', '/config/downloadclient', (state) => state.settings.downloadClientOptions),
+
+ [types.FETCH_REMOTE_PATH_MAPPINGS]: createFetchHandler('remotePathMappings', '/remotepathmapping'),
+
+ [types.SAVE_REMOTE_PATH_MAPPING]: createSaveProviderHandler(
+ 'remotePathMappings',
+ '/remotepathmapping',
+ (state) => state.settings.remotePathMappings),
+
+ [types.DELETE_REMOTE_PATH_MAPPING]: createRemoveItemHandler(
+ 'remotePathMappings',
+ '/remotepathmapping',
+ (state) => state.settings.remotePathMappings),
+
+ [types.FETCH_NOTIFICATIONS]: createFetchHandler('notifications', '/notification'),
+ [types.FETCH_NOTIFICATION_SCHEMA]: createFetchSchemaHandler('notifications', '/notification/schema'),
+
+ [types.SAVE_NOTIFICATION]: createSaveProviderHandler(
+ 'notifications',
+ '/notification',
+ (state) => state.settings.notifications),
+
+ [types.CANCEL_SAVE_NOTIFICATION]: createCancelSaveProviderHandler('notifications'),
+
+ [types.DELETE_NOTIFICATION]: createRemoveItemHandler(
+ 'notifications',
+ '/notification',
+ (state) => state.settings.notifications),
+
+ [types.TEST_NOTIFICATION]: createTestProviderHandler(
+ 'notifications',
+ '/notification',
+ (state) => state.settings.notifications),
+
+ [types.CANCEL_TEST_NOTIFICATION]: createCancelTestProviderHandler('notifications'),
+
+ [types.FETCH_METADATA]: createFetchHandler('metadata', '/metadata'),
+
+ [types.SAVE_METADATA]: createSaveProviderHandler(
+ 'metadata',
+ '/metadata',
+ (state) => state.settings.metadata),
+
+ [types.FETCH_METADATA_PROVIDER]: createFetchHandler('metadataProvider', '/config/metadataProvider'),
+ [types.SAVE_METADATA_PROVIDER]: createSaveHandler('metadataProvider', '/config/metadataProvider', (state) => state.settings.metadataProvider),
+
+ [types.FETCH_GENERAL_SETTINGS]: createFetchHandler('general', '/config/host'),
+ [types.SAVE_GENERAL_SETTINGS]: createSaveHandler('general', '/config/host', (state) => state.settings.general)
+};
+
+export default settingsActionHandlers;
diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js
new file mode 100644
index 000000000..64acc7926
--- /dev/null
+++ b/frontend/src/Store/Actions/settingsActions.js
@@ -0,0 +1,222 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import settingsActionHandlers from './settingsActionHandlers';
+
+export const toggleAdvancedSettings = createAction(types.TOGGLE_ADVANCED_SETTINGS);
+
+export const fetchUISettings = settingsActionHandlers[types.FETCH_UI_SETTINGS];
+export const saveUISettings = settingsActionHandlers[types.SAVE_UI_SETTINGS];
+export const setUISettingsValue = createAction(types.SET_UI_SETTINGS_VALUE, (payload) => {
+ return {
+ section: 'ui',
+ ...payload
+ };
+});
+
+export const fetchMediaManagementSettings = settingsActionHandlers[types.FETCH_MEDIA_MANAGEMENT_SETTINGS];
+export const saveMediaManagementSettings = settingsActionHandlers[types.SAVE_MEDIA_MANAGEMENT_SETTINGS];
+export const setMediaManagementSettingsValue = createAction(types.SET_MEDIA_MANAGEMENT_SETTINGS_VALUE, (payload) => {
+ return {
+ section: 'mediaManagement',
+ ...payload
+ };
+});
+
+export const fetchNamingSettings = settingsActionHandlers[types.FETCH_NAMING_SETTINGS];
+export const saveNamingSettings = settingsActionHandlers[types.SAVE_NAMING_SETTINGS];
+export const setNamingSettingsValue = createAction(types.SET_NAMING_SETTINGS_VALUE, (payload) => {
+ return {
+ section: 'naming',
+ ...payload
+ };
+});
+
+export const fetchNamingExamples = settingsActionHandlers[types.FETCH_NAMING_EXAMPLES];
+
+export const fetchQualityProfiles = settingsActionHandlers[types.FETCH_QUALITY_PROFILES];
+export const fetchQualityProfileSchema = settingsActionHandlers[types.FETCH_QUALITY_PROFILE_SCHEMA];
+export const saveQualityProfile = settingsActionHandlers[types.SAVE_QUALITY_PROFILE];
+export const deleteQualityProfile = settingsActionHandlers[types.DELETE_QUALITY_PROFILE];
+
+export const setQualityProfileValue = createAction(types.SET_QUALITY_PROFILE_VALUE, (payload) => {
+ return {
+ section: 'qualityProfiles',
+ ...payload
+ };
+});
+
+export const fetchLanguageProfiles = settingsActionHandlers[types.FETCH_LANGUAGE_PROFILES];
+export const fetchLanguageProfileSchema = settingsActionHandlers[types.FETCH_LANGUAGE_PROFILE_SCHEMA];
+export const saveLanguageProfile = settingsActionHandlers[types.SAVE_LANGUAGE_PROFILE];
+export const deleteLanguageProfile = settingsActionHandlers[types.DELETE_LANGUAGE_PROFILE];
+
+export const setLanguageProfileValue = createAction(types.SET_LANGUAGE_PROFILE_VALUE, (payload) => {
+ return {
+ section: 'languageProfiles',
+ ...payload
+ };
+});
+
+export const fetchDelayProfiles = settingsActionHandlers[types.FETCH_DELAY_PROFILES];
+export const saveDelayProfile = settingsActionHandlers[types.SAVE_DELAY_PROFILE];
+export const deleteDelayProfile = settingsActionHandlers[types.DELETE_DELAY_PROFILE];
+export const reorderDelayProfile = settingsActionHandlers[types.REORDER_DELAY_PROFILE];
+
+export const setDelayProfileValue = createAction(types.SET_DELAY_PROFILE_VALUE, (payload) => {
+ return {
+ section: 'delayProfiles',
+ ...payload
+ };
+});
+
+export const fetchQualityDefinitions = settingsActionHandlers[types.FETCH_QUALITY_DEFINITIONS];
+export const saveQualityDefinitions = settingsActionHandlers[types.SAVE_QUALITY_DEFINITIONS];
+
+export const setQualityDefinitionValue = createAction(types.SET_QUALITY_DEFINITION_VALUE);
+
+export const fetchIndexers = settingsActionHandlers[types.FETCH_INDEXERS];
+export const fetchIndexerSchema = settingsActionHandlers[types.FETCH_INDEXER_SCHEMA];
+export const selectIndexerSchema = createAction(types.SELECT_INDEXER_SCHEMA);
+
+export const saveIndexer = settingsActionHandlers[types.SAVE_INDEXER];
+export const cancelSaveIndexer = settingsActionHandlers[types.CANCEL_SAVE_INDEXER];
+export const deleteIndexer = settingsActionHandlers[types.DELETE_INDEXER];
+export const testIndexer = settingsActionHandlers[types.TEST_INDEXER];
+export const cancelTestIndexer = settingsActionHandlers[types.CANCEL_TEST_INDEXER];
+
+export const setIndexerValue = createAction(types.SET_INDEXER_VALUE, (payload) => {
+ return {
+ section: 'indexers',
+ ...payload
+ };
+});
+
+export const setIndexerFieldValue = createAction(types.SET_INDEXER_FIELD_VALUE, (payload) => {
+ return {
+ section: 'indexers',
+ ...payload
+ };
+});
+
+export const fetchIndexerOptions = settingsActionHandlers[types.FETCH_INDEXER_OPTIONS];
+export const saveIndexerOptions = settingsActionHandlers[types.SAVE_INDEXER_OPTIONS];
+export const setIndexerOptionsValue = createAction(types.SET_INDEXER_OPTIONS_VALUE, (payload) => {
+ return {
+ section: 'indexerOptions',
+ ...payload
+ };
+});
+
+export const fetchRestrictions = settingsActionHandlers[types.FETCH_RESTRICTIONS];
+export const saveRestriction = settingsActionHandlers[types.SAVE_RESTRICTION];
+export const deleteRestriction = settingsActionHandlers[types.DELETE_RESTRICTION];
+
+export const setRestrictionValue = createAction(types.SET_RESTRICTION_VALUE, (payload) => {
+ return {
+ section: 'restrictions',
+ ...payload
+ };
+});
+
+export const fetchDownloadClients = settingsActionHandlers[types.FETCH_DOWNLOAD_CLIENTS];
+export const fetchDownloadClientSchema = settingsActionHandlers[types.FETCH_DOWNLOAD_CLIENT_SCHEMA];
+export const selectDownloadClientSchema = createAction(types.SELECT_DOWNLOAD_CLIENT_SCHEMA);
+
+export const saveDownloadClient = settingsActionHandlers[types.SAVE_DOWNLOAD_CLIENT];
+export const cancelSaveDownloadClient = settingsActionHandlers[types.CANCEL_SAVE_DOWNLOAD_CLIENT];
+export const deleteDownloadClient = settingsActionHandlers[types.DELETE_DOWNLOAD_CLIENT];
+export const testDownloadClient = settingsActionHandlers[types.TEST_DOWNLOAD_CLIENT];
+export const cancelTestDownloadClient = settingsActionHandlers[types.CANCEL_TEST_DOWNLOAD_CLIENT];
+
+export const setDownloadClientValue = createAction(types.SET_DOWNLOAD_CLIENT_VALUE, (payload) => {
+ return {
+ section: 'downloadClients',
+ ...payload
+ };
+});
+
+export const setDownloadClientFieldValue = createAction(types.SET_DOWNLOAD_CLIENT_FIELD_VALUE, (payload) => {
+ return {
+ section: 'downloadClients',
+ ...payload
+ };
+});
+
+export const fetchDownloadClientOptions = settingsActionHandlers[types.FETCH_DOWNLOAD_CLIENT_OPTIONS];
+export const saveDownloadClientOptions = settingsActionHandlers[types.SAVE_DOWNLOAD_CLIENT_OPTIONS];
+export const setDownloadClientOptionsValue = createAction(types.SET_DOWNLOAD_CLIENT_OPTIONS_VALUE, (payload) => {
+ return {
+ section: 'downloadClientOptions',
+ ...payload
+ };
+});
+
+export const fetchRemotePathMappings = settingsActionHandlers[types.FETCH_REMOTE_PATH_MAPPINGS];
+export const saveRemotePathMapping = settingsActionHandlers[types.SAVE_REMOTE_PATH_MAPPING];
+export const deleteRemotePathMapping = settingsActionHandlers[types.DELETE_REMOTE_PATH_MAPPING];
+
+export const setRemotePathMappingValue = createAction(types.SET_REMOTE_PATH_MAPPING_VALUE, (payload) => {
+ return {
+ section: 'remotePathMappings',
+ ...payload
+ };
+});
+
+export const fetchNotifications = settingsActionHandlers[types.FETCH_NOTIFICATIONS];
+export const fetchNotificationSchema = settingsActionHandlers[types.FETCH_NOTIFICATION_SCHEMA];
+export const selectNotificationSchema = createAction(types.SELECT_NOTIFICATION_SCHEMA);
+
+export const saveNotification = settingsActionHandlers[types.SAVE_NOTIFICATION];
+export const cancelSaveNotification = settingsActionHandlers[types.CANCEL_SAVE_NOTIFICATION];
+export const deleteNotification = settingsActionHandlers[types.DELETE_NOTIFICATION];
+export const testNotification = settingsActionHandlers[types.TEST_NOTIFICATION];
+export const cancelTestNotification = settingsActionHandlers[types.CANCEL_TEST_NOTIFICATION];
+
+export const setNotificationValue = createAction(types.SET_NOTIFICATION_VALUE, (payload) => {
+ return {
+ section: 'notifications',
+ ...payload
+ };
+});
+
+export const setNotificationFieldValue = createAction(types.SET_NOTIFICATION_FIELD_VALUE, (payload) => {
+ return {
+ section: 'notifications',
+ ...payload
+ };
+});
+
+export const fetchMetadata = settingsActionHandlers[types.FETCH_METADATA];
+export const saveMetadata = settingsActionHandlers[types.SAVE_METADATA];
+
+export const setMetadataValue = createAction(types.SET_METADATA_VALUE, (payload) => {
+ return {
+ section: 'metadata',
+ ...payload
+ };
+});
+
+export const setMetadataFieldValue = createAction(types.SET_METADATA_FIELD_VALUE, (payload) => {
+ return {
+ section: 'metadata',
+ ...payload
+ };
+});
+
+export const fetchMetadataProvider = settingsActionHandlers[types.FETCH_METADATA_PROVIDER];
+export const saveMetadataProvider = settingsActionHandlers[types.SAVE_METADATA_PROVIDER];
+export const setMetadataProviderValue = createAction(types.SET_METADATA_PROVIDER_VALUE, (payload) => {
+ return {
+ section: 'metadataProvider',
+ ...payload
+ };
+});
+
+export const fetchGeneralSettings = settingsActionHandlers[types.FETCH_GENERAL_SETTINGS];
+export const saveGeneralSettings = settingsActionHandlers[types.SAVE_GENERAL_SETTINGS];
+export const setGeneralSettingsValue = createAction(types.SET_GENERAL_SETTINGS_VALUE, (payload) => {
+ return {
+ section: 'general',
+ ...payload
+ };
+});
diff --git a/frontend/src/Store/Actions/systemActionHandlers.js b/frontend/src/Store/Actions/systemActionHandlers.js
new file mode 100644
index 000000000..d40674da3
--- /dev/null
+++ b/frontend/src/Store/Actions/systemActionHandlers.js
@@ -0,0 +1,48 @@
+import $ from 'jquery';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import * as types from './actionTypes';
+import createFetchHandler from './Creators/createFetchHandler';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+
+const systemActionHandlers = {
+ [types.FETCH_STATUS]: createFetchHandler('status', '/system/status'),
+ [types.FETCH_HEALTH]: createFetchHandler('health', '/health'),
+ [types.FETCH_DISK_SPACE]: createFetchHandler('diskSpace', '/diskspace'),
+ [types.FETCH_TASK]: createFetchHandler('tasks', '/system/task'),
+ [types.FETCH_TASKS]: createFetchHandler('tasks', '/system/task'),
+ [types.FETCH_BACKUPS]: createFetchHandler('backups', '/system/backup'),
+ [types.FETCH_UPDATES]: createFetchHandler('updates', '/update'),
+ [types.FETCH_LOG_FILES]: createFetchHandler('logFiles', '/log/file'),
+ [types.FETCH_UPDATE_LOG_FILES]: createFetchHandler('updateLogFiles', '/log/file/update'),
+
+ ...createServerSideCollectionHandlers('logs', '/log', (state) => state.system, {
+ [serverSideCollectionHandlers.FETCH]: types.FETCH_LOGS,
+ [serverSideCollectionHandlers.FIRST_PAGE]: types.GOTO_FIRST_LOGS_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: types.GOTO_PREVIOUS_LOGS_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: types.GOTO_NEXT_LOGS_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: types.GOTO_LAST_LOGS_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: types.GOTO_LOGS_PAGE,
+ [serverSideCollectionHandlers.SORT]: types.SET_LOGS_SORT,
+ [serverSideCollectionHandlers.FILTER]: types.SET_LOGS_FILTER
+ }),
+
+ [types.RESTART]: function() {
+ return function() {
+ $.ajax({
+ url: '/system/restart',
+ method: 'POST'
+ });
+ };
+ },
+
+ [types.SHUTDOWN]: function() {
+ return function() {
+ $.ajax({
+ url: '/system/shutdown',
+ method: 'POST'
+ });
+ };
+ }
+};
+
+export default systemActionHandlers;
diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js
new file mode 100644
index 000000000..b5614d2d3
--- /dev/null
+++ b/frontend/src/Store/Actions/systemActions.js
@@ -0,0 +1,28 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import systemActionHandlers from './systemActionHandlers';
+
+export const fetchStatus = systemActionHandlers[types.FETCH_STATUS];
+export const fetchHealth = systemActionHandlers[types.FETCH_HEALTH];
+export const fetchDiskSpace = systemActionHandlers[types.FETCH_DISK_SPACE];
+
+export const fetchTask = systemActionHandlers[types.FETCH_TASK];
+export const fetchTasks = systemActionHandlers[types.FETCH_TASKS];
+export const fetchBackups = systemActionHandlers[types.FETCH_BACKUPS];
+export const fetchUpdates = systemActionHandlers[types.FETCH_UPDATES];
+
+export const fetchLogs = systemActionHandlers[types.FETCH_LOGS];
+export const gotoLogsFirstPage = systemActionHandlers[types.GOTO_FIRST_LOGS_PAGE];
+export const gotoLogsPreviousPage = systemActionHandlers[types.GOTO_PREVIOUS_LOGS_PAGE];
+export const gotoLogsNextPage = systemActionHandlers[types.GOTO_NEXT_LOGS_PAGE];
+export const gotoLogsLastPage = systemActionHandlers[types.GOTO_LAST_LOGS_PAGE];
+export const gotoLogsPage = systemActionHandlers[types.GOTO_LOGS_PAGE];
+export const setLogsSort = systemActionHandlers[types.SET_LOGS_SORT];
+export const setLogsFilter = systemActionHandlers[types.SET_LOGS_FILTER];
+export const setLogsTableOption = createAction(types.SET_LOGS_TABLE_OPTION);
+
+export const fetchLogFiles = systemActionHandlers[types.FETCH_LOG_FILES];
+export const fetchUpdateLogFiles = systemActionHandlers[types.FETCH_UPDATE_LOG_FILES];
+
+export const restart = systemActionHandlers[types.RESTART];
+export const shutdown = systemActionHandlers[types.SHUTDOWN];
diff --git a/frontend/src/Store/Actions/tagActionHandlers.js b/frontend/src/Store/Actions/tagActionHandlers.js
new file mode 100644
index 000000000..c4e007f6c
--- /dev/null
+++ b/frontend/src/Store/Actions/tagActionHandlers.js
@@ -0,0 +1,28 @@
+import $ from 'jquery';
+import * as types from './actionTypes';
+import { update } from './baseActions';
+import createFetchHandler from './Creators/createFetchHandler';
+
+const tagActionHandlers = {
+ [types.FETCH_TAGS]: createFetchHandler('tags', '/tag'),
+
+ [types.ADD_TAG]: function(payload) {
+ return (dispatch, getState) => {
+ const promise = $.ajax({
+ url: '/tag',
+ method: 'POST',
+ data: JSON.stringify(payload.tag)
+ });
+
+ promise.done((data) => {
+ const tags = getState().tags.items.slice();
+ tags.push(data);
+
+ dispatch(update({ section: 'tags', data: tags }));
+ payload.onTagCreated(data);
+ });
+ };
+ }
+};
+
+export default tagActionHandlers;
diff --git a/frontend/src/Store/Actions/tagActions.js b/frontend/src/Store/Actions/tagActions.js
new file mode 100644
index 000000000..45f0141ce
--- /dev/null
+++ b/frontend/src/Store/Actions/tagActions.js
@@ -0,0 +1,5 @@
+import * as types from './actionTypes';
+import tagActionHandlers from './tagActionHandlers';
+
+export const fetchTags = tagActionHandlers[types.FETCH_TAGS];
+export const addTag = tagActionHandlers[types.ADD_TAG];
diff --git a/frontend/src/Store/Actions/trackActionHandlers.js b/frontend/src/Store/Actions/trackActionHandlers.js
new file mode 100644
index 000000000..efda3ae40
--- /dev/null
+++ b/frontend/src/Store/Actions/trackActionHandlers.js
@@ -0,0 +1,11 @@
+import createFetchHandler from './Creators/createFetchHandler';
+import * as types from './actionTypes';
+
+const section = 'tracks';
+
+const trackActionHandlers = {
+ [types.FETCH_TRACKS]: createFetchHandler(section, '/track')
+
+};
+
+export default trackActionHandlers;
diff --git a/frontend/src/Store/Actions/trackActions.js b/frontend/src/Store/Actions/trackActions.js
new file mode 100644
index 000000000..57d503c12
--- /dev/null
+++ b/frontend/src/Store/Actions/trackActions.js
@@ -0,0 +1,8 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import trackActionHandlers from './trackActionHandlers';
+
+export const fetchTracks = trackActionHandlers[types.FETCH_TRACKS];
+export const setTracksSort = createAction(types.SET_TRACKS_SORT);
+export const setTracksTableOption = createAction(types.SET_TRACKS_TABLE_OPTION);
+export const clearTracks = createAction(types.CLEAR_TRACKS);
diff --git a/frontend/src/Store/Actions/trackFileActionHandlers.js b/frontend/src/Store/Actions/trackFileActionHandlers.js
new file mode 100644
index 000000000..bbb09a39e
--- /dev/null
+++ b/frontend/src/Store/Actions/trackFileActionHandlers.js
@@ -0,0 +1,164 @@
+import _ from 'lodash';
+import $ from 'jquery';
+import { batchActions } from 'redux-batched-actions';
+import episodeEntities from 'Album/episodeEntities';
+import createFetchHandler from './Creators/createFetchHandler';
+import createRemoveItemHandler from './Creators/createRemoveItemHandler';
+import * as types from './actionTypes';
+import { set, removeItem, updateItem } from './baseActions';
+
+const section = 'trackFiles';
+const deleteTrackFile = createRemoveItemHandler(section, '/trackFile');
+
+const trackFileActionHandlers = {
+ [types.FETCH_TRACK_FILES]: createFetchHandler(section, '/trackFile'),
+
+ [types.DELETE_TRACK_FILE]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ id: trackFileId,
+ episodeEntity = episodeEntities.EPISODES
+ } = payload;
+
+ const episodeSection = _.last(episodeEntity.split('.'));
+
+ const deletePromise = deleteTrackFile(payload)(dispatch, getState);
+
+ deletePromise.done(() => {
+ const episodes = getState().episodes.items;
+ const episodesWithRemovedFiles = _.filter(episodes, { trackFileId });
+
+ dispatch(batchActions([
+ ...episodesWithRemovedFiles.map((episode) => {
+ return updateItem({
+ section: episodeSection,
+ ...episode,
+ trackFileId: 0,
+ hasFile: false
+ });
+ })
+ ]));
+ });
+ };
+ },
+
+ [types.DELETE_TRACK_FILES]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ trackFileIds
+ } = payload;
+
+ dispatch(set({ section, isDeleting: true }));
+
+ const promise = $.ajax({
+ url: '/trackFile/bulk',
+ method: 'DELETE',
+ dataType: 'json',
+ data: JSON.stringify({ trackFileIds })
+ });
+
+ promise.done(() => {
+ const episodes = getState().episodes.items;
+ const episodesWithRemovedFiles = trackFileIds.reduce((acc, trackFileId) => {
+ acc.push(..._.filter(episodes, { trackFileId }));
+
+ return acc;
+ }, []);
+
+ dispatch(batchActions([
+ ...trackFileIds.map((id) => {
+ return removeItem({ section, id });
+ }),
+
+ ...episodesWithRemovedFiles.map((episode) => {
+ return updateItem({
+ section: 'episodes',
+ ...episode,
+ trackFileId: 0,
+ hasFile: false
+ });
+ }),
+
+ set({
+ section,
+ isDeleting: false,
+ deleteError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isDeleting: false,
+ deleteError: xhr
+ }));
+ });
+ };
+ },
+
+ [types.UPDATE_TRACK_FILES]: function(payload) {
+ return function(dispatch, getState) {
+ const {
+ trackFileIds,
+ language,
+ quality
+ } = payload;
+
+ dispatch(set({ section, isSaving: true }));
+
+ const data = {
+ trackFileIds
+ };
+
+ if (language) {
+ data.language = language;
+ }
+
+ if (quality) {
+ data.quality = quality;
+ }
+
+ const promise = $.ajax({
+ url: '/trackFile/editor',
+ method: 'PUT',
+ dataType: 'json',
+ data: JSON.stringify(data)
+ });
+
+ promise.done(() => {
+ dispatch(batchActions([
+ ...trackFileIds.map((id) => {
+ const props = {};
+
+ if (language) {
+ props.language = language;
+ }
+
+ if (quality) {
+ props.quality = quality;
+ }
+
+ return updateItem({ section, id, ...props });
+ }),
+
+ set({
+ section,
+ isSaving: false,
+ saveError: null
+ })
+ ]));
+ });
+
+ promise.fail((xhr) => {
+ dispatch(set({
+ section,
+ isSaving: false,
+ saveError: xhr
+ }));
+ });
+ };
+ }
+};
+
+export default trackFileActionHandlers;
diff --git a/frontend/src/Store/Actions/trackFileActions.js b/frontend/src/Store/Actions/trackFileActions.js
new file mode 100644
index 000000000..fa4b6544a
--- /dev/null
+++ b/frontend/src/Store/Actions/trackFileActions.js
@@ -0,0 +1,9 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import trackFileActionHandlers from './trackFileActionHandlers';
+
+export const fetchTrackFiles = trackFileActionHandlers[types.FETCH_TRACK_FILES];
+export const deleteTrackFile = trackFileActionHandlers[types.DELETE_TRACK_FILE];
+export const deleteTrackFiles = trackFileActionHandlers[types.DELETE_TRACK_FILES];
+export const updateTrackFiles = trackFileActionHandlers[types.UPDATE_TRACK_FILES];
+export const clearTrackFiles = createAction(types.CLEAR_TRACK_FILES);
diff --git a/frontend/src/Store/Actions/wantedActionHandlers.js b/frontend/src/Store/Actions/wantedActionHandlers.js
new file mode 100644
index 000000000..60cb121b4
--- /dev/null
+++ b/frontend/src/Store/Actions/wantedActionHandlers.js
@@ -0,0 +1,34 @@
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import createBatchToggleAlbumMonitoredHandler from './Creators/createBatchToggleAlbumMonitoredHandler';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import * as types from './actionTypes';
+
+const wantedActionHandlers = {
+ ...createServerSideCollectionHandlers('missing', '/wanted/missing', (state) => state.wanted, {
+ [serverSideCollectionHandlers.FETCH]: types.FETCH_MISSING,
+ [serverSideCollectionHandlers.FIRST_PAGE]: types.GOTO_FIRST_MISSING_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: types.GOTO_PREVIOUS_MISSING_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: types.GOTO_NEXT_MISSING_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: types.GOTO_LAST_MISSING_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: types.GOTO_MISSING_PAGE,
+ [serverSideCollectionHandlers.SORT]: types.SET_MISSING_SORT,
+ [serverSideCollectionHandlers.FILTER]: types.SET_MISSING_FILTER
+ }),
+
+ [types.BATCH_TOGGLE_MISSING_ALBUMS]: createBatchToggleAlbumMonitoredHandler('missing', (state) => state.wanted.missing),
+
+ ...createServerSideCollectionHandlers('cutoffUnmet', '/wanted/cutoff', (state) => state.wanted, {
+ [serverSideCollectionHandlers.FETCH]: types.FETCH_CUTOFF_UNMET,
+ [serverSideCollectionHandlers.FIRST_PAGE]: types.GOTO_FIRST_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: types.GOTO_PREVIOUS_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: types.GOTO_NEXT_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: types.GOTO_LAST_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: types.GOTO_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.SORT]: types.SET_CUTOFF_UNMET_SORT,
+ [serverSideCollectionHandlers.FILTER]: types.SET_CUTOFF_UNMET_FILTER
+ }),
+
+ [types.BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS]: createBatchToggleAlbumMonitoredHandler('cutoffUnmet', (state) => state.wanted.cutoffUnmet)
+};
+
+export default wantedActionHandlers;
diff --git a/frontend/src/Store/Actions/wantedActions.js b/frontend/src/Store/Actions/wantedActions.js
new file mode 100644
index 000000000..75aac6e60
--- /dev/null
+++ b/frontend/src/Store/Actions/wantedActions.js
@@ -0,0 +1,35 @@
+import { createAction } from 'redux-actions';
+import * as types from './actionTypes';
+import wantedActionHandlers from './wantedActionHandlers';
+
+//
+// Missing
+
+export const fetchMissing = wantedActionHandlers[types.FETCH_MISSING];
+export const gotoMissingFirstPage = wantedActionHandlers[types.GOTO_FIRST_MISSING_PAGE];
+export const gotoMissingPreviousPage = wantedActionHandlers[types.GOTO_PREVIOUS_MISSING_PAGE];
+export const gotoMissingNextPage = wantedActionHandlers[types.GOTO_NEXT_MISSING_PAGE];
+export const gotoMissingLastPage = wantedActionHandlers[types.GOTO_LAST_MISSING_PAGE];
+export const gotoMissingPage = wantedActionHandlers[types.GOTO_MISSING_PAGE];
+export const setMissingSort = wantedActionHandlers[types.SET_MISSING_SORT];
+export const setMissingFilter = wantedActionHandlers[types.SET_MISSING_FILTER];
+export const setMissingTableOption = createAction(types.SET_MISSING_TABLE_OPTION);
+export const clearMissing = createAction(types.CLEAR_MISSING);
+
+export const batchToggleMissingAlbums = wantedActionHandlers[types.BATCH_TOGGLE_MISSING_ALBUMS];
+
+//
+// Cutoff Unmet
+
+export const fetchCutoffUnmet = wantedActionHandlers[types.FETCH_CUTOFF_UNMET];
+export const gotoCutoffUnmetFirstPage = wantedActionHandlers[types.GOTO_FIRST_CUTOFF_UNMET_PAGE];
+export const gotoCutoffUnmetPreviousPage = wantedActionHandlers[types.GOTO_PREVIOUS_CUTOFF_UNMET_PAGE];
+export const gotoCutoffUnmetNextPage = wantedActionHandlers[types.GOTO_NEXT_CUTOFF_UNMET_PAGE];
+export const gotoCutoffUnmetLastPage = wantedActionHandlers[types.GOTO_LAST_CUTOFF_UNMET_PAGE];
+export const gotoCutoffUnmetPage = wantedActionHandlers[types.GOTO_CUTOFF_UNMET_PAGE];
+export const setCutoffUnmetSort = wantedActionHandlers[types.SET_CUTOFF_UNMET_SORT];
+export const setCutoffUnmetFilter = wantedActionHandlers[types.SET_CUTOFF_UNMET_FILTER];
+export const setCutoffUnmetTableOption= createAction(types.SET_CUTOFF_UNMET_TABLE_OPTION);
+export const clearCutoffUnmet= createAction(types.CLEAR_CUTOFF_UNMET);
+
+export const batchToggleCutoffUnmetAlbums = wantedActionHandlers[types.BATCH_TOGGLE_CUTOFF_UNMET_ALBUMS];
diff --git a/frontend/src/Store/Middleware/middlewares.js b/frontend/src/Store/Middleware/middlewares.js
new file mode 100644
index 000000000..5cf93b2a6
--- /dev/null
+++ b/frontend/src/Store/Middleware/middlewares.js
@@ -0,0 +1,22 @@
+import { applyMiddleware, compose } from 'redux';
+import thunk from 'redux-thunk';
+import { routerMiddleware } from 'react-router-redux';
+import sentryMiddleware from './sentryMiddleware';
+import persistState from './persistState';
+
+export default function(history) {
+ const middlewares = [];
+ const ravenMiddleware = sentryMiddleware();
+
+ if (ravenMiddleware) {
+ middlewares.push(ravenMiddleware);
+ }
+
+ middlewares.push(routerMiddleware(history));
+ middlewares.push(thunk);
+
+ return compose(
+ applyMiddleware(...middlewares),
+ persistState
+ );
+}
diff --git a/frontend/src/Store/Middleware/persistState.js b/frontend/src/Store/Middleware/persistState.js
new file mode 100644
index 000000000..456efc470
--- /dev/null
+++ b/frontend/src/Store/Middleware/persistState.js
@@ -0,0 +1,121 @@
+import _ from 'lodash';
+import persistState from 'redux-localstorage';
+import * as addArtistReducers from 'Store/Reducers/addArtistReducers';
+import * as episodeReducers from 'Store/Reducers/episodeReducers';
+import * as trackReducers from 'Store/Reducers/trackReducers';
+import * as artistIndexReducers from 'Store/Reducers/artistIndexReducers';
+import * as artistEditorReducers from 'Store/Reducers/artistEditorReducers';
+import * as albumStudioReducers from 'Store/Reducers/albumStudioReducers';
+import * as calendarReducers from 'Store/Reducers/calendarReducers';
+import * as historyReducers from 'Store/Reducers/historyReducers';
+import * as blacklistReducers from 'Store/Reducers/blacklistReducers';
+import * as wantedReducers from 'Store/Reducers/wantedReducers';
+import * as settingsReducers from 'Store/Reducers/settingsReducers';
+import * as systemReducers from 'Store/Reducers/systemReducers';
+import * as interactiveImportReducers from 'Store/Reducers/interactiveImportReducers';
+import * as queueReducers from 'Store/Reducers/queueReducers';
+
+const reducers = [
+ addArtistReducers,
+ episodeReducers,
+ trackReducers,
+ artistIndexReducers,
+ artistEditorReducers,
+ albumStudioReducers,
+ calendarReducers,
+ historyReducers,
+ blacklistReducers,
+ wantedReducers,
+ settingsReducers,
+ systemReducers,
+ interactiveImportReducers,
+ queueReducers
+];
+
+const columnPaths = [];
+
+const paths = _.reduce(reducers, (acc, reducer) => {
+ reducer.persistState.forEach((path) => {
+ if (path.match(/\.columns$/)) {
+ columnPaths.push(path);
+ }
+
+ acc.push(path);
+ });
+
+ return acc;
+}, []);
+
+function mergeColumns(path, initialState, persistedState, computedState) {
+ const initialColumns = _.get(initialState, path);
+ const persistedColumns = _.get(persistedState, path);
+
+ if (!persistedColumns || !persistedColumns.length) {
+ return;
+ }
+
+ const columns = [];
+
+ initialColumns.forEach((initialColumn) => {
+ const persistedColumnIndex = _.findIndex(persistedColumns, { name: initialColumn.name });
+ const column = Object.assign({}, initialColumn);
+ const persistedColumn = persistedColumnIndex > -1 ? persistedColumns[persistedColumnIndex] : undefined;
+
+ if (persistedColumn) {
+ column.isVisible = persistedColumn.isVisible;
+ }
+
+ // If there is a persisted column, it's index doesn't exceed the column list
+ // and it's modifiable, insert it in the proper position.
+
+ if (persistedColumn && columns.length - 1 > persistedColumnIndex && persistedColumn.isModifiable !== false) {
+ columns.splice(persistedColumnIndex, 0, column);
+ } else {
+ columns.push(column);
+ }
+
+ // Set the columns in the persisted state
+ _.set(computedState, path, columns);
+ });
+}
+
+function slicer(paths_) {
+ return (state) => {
+ const subset = {};
+
+ paths_.forEach((path) => {
+ _.set(subset, path, _.get(state, path));
+ });
+
+ return subset;
+ };
+}
+
+function serialize(obj) {
+ return JSON.stringify(obj, null, 2);
+}
+
+function merge(initialState, persistedState) {
+ if (!persistedState) {
+ return initialState;
+ }
+
+ const computedState = {};
+
+ _.merge(computedState, initialState, persistedState);
+
+ columnPaths.forEach((columnPath) => {
+ mergeColumns(columnPath, initialState, persistedState, computedState);
+ });
+
+ return computedState;
+}
+
+const config = {
+ slicer,
+ serialize,
+ merge,
+ key: 'sonarr'
+};
+
+export default persistState(paths, config);
diff --git a/frontend/src/Store/Middleware/sentryMiddleware.js b/frontend/src/Store/Middleware/sentryMiddleware.js
new file mode 100644
index 000000000..1608d19ca
--- /dev/null
+++ b/frontend/src/Store/Middleware/sentryMiddleware.js
@@ -0,0 +1,48 @@
+import _ from 'lodash';
+import Raven from 'raven-js';
+import createRavenMiddleware from 'raven-for-redux';
+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.culprit = cleanseUrl(result.culprit);
+ result.request.url = cleanseUrl(result.request.url);
+
+ return result;
+}
+
+export default function sentryMiddleware() {
+ const {
+ analytics,
+ branch,
+ version,
+ release,
+ isProduction
+ } = window.Sonarr;
+
+ if (!analytics) {
+ return;
+ }
+
+ const dsn = isProduction ? 'https://c3a5b33e08de4e18b7d0505e942dbc95@sentry.io/216290' :
+ 'https://c3a5b33e08de4e18b7d0505e942dbc95@sentry.io/216290';
+
+ Raven.config(dsn).install();
+
+ return createRavenMiddleware(Raven, {
+ environment: isProduction ? 'production' : 'development',
+ release,
+ tags: {
+ branch,
+ version
+ },
+ dataCallback: cleanseData
+ });
+}
diff --git a/frontend/src/Store/Reducers/Creators/createAddItemReducer.js b/frontend/src/Store/Reducers/Creators/createAddItemReducer.js
new file mode 100644
index 000000000..d0e75c758
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createAddItemReducer.js
@@ -0,0 +1,23 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createAddItemReducer(section) {
+ return (state, { payload }) => {
+ const {
+ section: payloadSection,
+ ...otherProps
+ } = payload;
+
+ if (section === payloadSection) {
+ const newState = getSectionState(state, section);
+
+ newState.items = [...newState.items, { ...otherProps }];
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createAddItemReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createClearPendingChangesReducer.js b/frontend/src/Store/Reducers/Creators/createClearPendingChangesReducer.js
new file mode 100644
index 000000000..6ff6e7b25
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createClearPendingChangesReducer.js
@@ -0,0 +1,21 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createClearPendingChangesReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = {};
+
+ if (newState.hasOwnProperty('saveError')) {
+ newState.saveError = null;
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createClearPendingChangesReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createClearReducer.js b/frontend/src/Store/Reducers/Creators/createClearReducer.js
new file mode 100644
index 000000000..2952973a9
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createClearReducer.js
@@ -0,0 +1,12 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createClearReducer(section, defaultState) {
+ return (state) => {
+ const newState = Object.assign(getSectionState(state, section), defaultState);
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createClearReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createReducers.js b/frontend/src/Store/Reducers/Creators/createReducers.js
new file mode 100644
index 000000000..13ed584e8
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createReducers.js
@@ -0,0 +1,20 @@
+function createReducers(sections, createReducer) {
+ const reducers = {};
+
+ sections.forEach((section) => {
+ reducers[section] = createReducer(section);
+ });
+
+ return (state, action) => {
+ const section = action.payload.section;
+ const reducer = reducers[section];
+
+ if (reducer) {
+ return reducer(state, action);
+ }
+
+ return state;
+ };
+}
+
+export default createReducers;
diff --git a/frontend/src/Store/Reducers/Creators/createRemoveItemReducer.js b/frontend/src/Store/Reducers/Creators/createRemoveItemReducer.js
new file mode 100644
index 000000000..c09655b0c
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createRemoveItemReducer.js
@@ -0,0 +1,20 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createRemoveItemReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const newState = getSectionState(state, section);
+
+ newState.items = [...newState.items];
+ _.remove(newState.items, { id: payload.id });
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createRemoveItemReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createSetClientSideCollectionFilterReducer.js b/frontend/src/Store/Reducers/Creators/createSetClientSideCollectionFilterReducer.js
new file mode 100644
index 000000000..d756a736e
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createSetClientSideCollectionFilterReducer.js
@@ -0,0 +1,17 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import { filterTypes } from 'Helpers/Props';
+
+function createSetClientSideCollectionFilterReducer(section) {
+ return (state, { payload }) => {
+ const newState = getSectionState(state, section);
+
+ newState.filterKey = payload.filterKey;
+ newState.filterValue = payload.filterValue;
+ newState.filterType = payload.filterType || filterTypes.EQUAL;
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createSetClientSideCollectionFilterReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createSetClientSideCollectionSortReducer.js b/frontend/src/Store/Reducers/Creators/createSetClientSideCollectionSortReducer.js
new file mode 100644
index 000000000..1bc048a80
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createSetClientSideCollectionSortReducer.js
@@ -0,0 +1,29 @@
+import { sortDirections } from 'Helpers/Props';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetClientSideCollectionSortReducer(section) {
+ return (state, { payload }) => {
+ const newState = getSectionState(state, section);
+
+ const sortKey = payload.sortKey || newState.sortKey;
+ let sortDirection = payload.sortDirection;
+
+ if (!sortDirection) {
+ if (payload.sortKey === newState.sortKey) {
+ sortDirection = newState.sortDirection === sortDirections.ASCENDING ?
+ sortDirections.DESCENDING :
+ sortDirections.ASCENDING;
+ } else {
+ sortDirection = newState.sortDirection;
+ }
+ }
+
+ newState.sortKey = sortKey;
+ newState.sortDirection = sortDirection;
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createSetClientSideCollectionSortReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createSetProviderFieldValueReducer.js b/frontend/src/Store/Reducers/Creators/createSetProviderFieldValueReducer.js
new file mode 100644
index 000000000..3af58dd3b
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createSetProviderFieldValueReducer.js
@@ -0,0 +1,23 @@
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetProviderFieldValueReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const { name, value } = payload;
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = Object.assign({}, newState.pendingChanges);
+ const fields = Object.assign({}, newState.pendingChanges.fields || {});
+
+ fields[name] = value;
+
+ newState.pendingChanges.fields = fields;
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createSetProviderFieldValueReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createSetReducer.js b/frontend/src/Store/Reducers/Creators/createSetReducer.js
new file mode 100644
index 000000000..2c2126c03
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createSetReducer.js
@@ -0,0 +1,23 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+const blacklistedProperties = [
+ 'section',
+ 'id'
+];
+
+function createSetReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const newState = Object.assign(getSectionState(state, section),
+ _.omit(payload, blacklistedProperties));
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createSetReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createSetSettingValueReducer.js b/frontend/src/Store/Reducers/Creators/createSetSettingValueReducer.js
new file mode 100644
index 000000000..33ac23044
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createSetSettingValueReducer.js
@@ -0,0 +1,36 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createSetSettingValueReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const { name, value } = payload;
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = Object.assign({}, newState.pendingChanges);
+
+ const currentValue = newState.item ? newState.item[name] : null;
+ const pendingState = newState.pendingChanges;
+
+ let parsedValue = null;
+
+ if (_.isNumber(currentValue)) {
+ parsedValue = parseInt(value);
+ } else {
+ parsedValue = value;
+ }
+
+ if (currentValue === parsedValue) {
+ delete pendingState[name];
+ } else {
+ pendingState[name] = parsedValue;
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createSetSettingValueReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createSetTableOptionReducer.js b/frontend/src/Store/Reducers/Creators/createSetTableOptionReducer.js
new file mode 100644
index 000000000..f353be97c
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createSetTableOptionReducer.js
@@ -0,0 +1,20 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+const whitelistedProperties = [
+ 'pageSize',
+ 'columns'
+];
+
+function createSetTableOptionReducer(section) {
+ return (state, { payload }) => {
+ const newState = Object.assign(
+ getSectionState(state, section),
+ _.pick(payload, whitelistedProperties));
+
+ return updateSectionState(state, section, newState);
+ };
+}
+
+export default createSetTableOptionReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createUpdateItemReducer.js b/frontend/src/Store/Reducers/Creators/createUpdateItemReducer.js
new file mode 100644
index 000000000..aba730afd
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createUpdateItemReducer.js
@@ -0,0 +1,36 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createUpdateItemReducer(section, idProp = 'id') {
+ return (state, { payload }) => {
+ const {
+ section: payloadSection,
+ updateOnly = false,
+ ...otherProps
+ } = payload;
+
+ if (section === payloadSection) {
+ const newState = getSectionState(state, section);
+ const items = newState.items;
+ const index = _.findIndex(items, { [idProp]: payload[idProp] });
+
+ newState.items = [...items];
+
+ // TODO: Move adding to it's own reducer
+ if (index >= 0) {
+ const item = items[index];
+
+ newState.items.splice(index, 1, { ...item, ...otherProps });
+ } else if (!updateOnly) {
+ newState.items.push({ ...otherProps });
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createUpdateItemReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createUpdateReducer.js b/frontend/src/Store/Reducers/Creators/createUpdateReducer.js
new file mode 100644
index 000000000..ea566ad9b
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createUpdateReducer.js
@@ -0,0 +1,23 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createUpdateReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const newState = getSectionState(state, section);
+
+ if (_.isArray(payload.data)) {
+ newState.items = payload.data;
+ } else {
+ newState.item = payload.data;
+ }
+
+ return updateSectionState(state, section, newState);
+ }
+
+ return state;
+ };
+}
+
+export default createUpdateReducer;
diff --git a/frontend/src/Store/Reducers/Creators/createUpdateServerSideCollectionReducer.js b/frontend/src/Store/Reducers/Creators/createUpdateServerSideCollectionReducer.js
new file mode 100644
index 000000000..235a1016a
--- /dev/null
+++ b/frontend/src/Store/Reducers/Creators/createUpdateServerSideCollectionReducer.js
@@ -0,0 +1,24 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function createUpdateServerSideCollectionReducer(section) {
+ return (state, { payload }) => {
+ if (section === payload.section) {
+ const data = payload.data;
+ const newState = getSectionState(state, section);
+
+ const serverState = _.omit(data, ['records']);
+ const calculatedState = {
+ totalPages: Math.max(Math.ceil(data.totalRecords / data.pageSize), 1),
+ items: data.records
+ };
+
+ return updateSectionState(state, section, Object.assign(newState, serverState, calculatedState));
+ }
+
+ return state;
+ };
+}
+
+export default createUpdateServerSideCollectionReducer;
diff --git a/frontend/src/Store/Reducers/addArtistReducers.js b/frontend/src/Store/Reducers/addArtistReducers.js
new file mode 100644
index 000000000..951d362ef
--- /dev/null
+++ b/frontend/src/Store/Reducers/addArtistReducers.js
@@ -0,0 +1,69 @@
+import { handleActions } from 'redux-actions';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createSetSettingValueReducer from './Creators/createSetSettingValueReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isAdding: false,
+ isAdded: false,
+ addError: null,
+ items: [],
+
+ defaults: {
+ rootFolderPath: '',
+ monitor: 'allEpisodes',
+ qualityProfileId: 0,
+ languageProfileId: 0,
+ primaryAlbumTypes: ['Album', 'EP'],
+ secondaryAlbumTypes: ['Studio'],
+ albumFolder: true,
+ tags: []
+ }
+};
+
+export const persistState = [
+ 'addArtist.defaults'
+];
+
+const reducerSection = 'addArtist';
+
+const addArtistReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+ [types.REMOVE_ITEM]: createRemoveItemReducer(reducerSection),
+
+ [types.SET_ADD_ARTIST_VALUE]: createSetSettingValueReducer(reducerSection),
+
+ [types.SET_ADD_ARTIST_DEFAULT]: function(state, { payload }) {
+ const newState = getSectionState(state, reducerSection);
+
+ newState.defaults = {
+ ...newState.defaults,
+ ...payload
+ };
+
+ return updateSectionState(state, reducerSection, newState);
+ },
+
+ [types.CLEAR_ADD_ARTIST]: function(state) {
+ const {
+ defaults,
+ ...otherDefaultState
+ } = defaultState;
+
+ return Object.assign({}, state, otherDefaultState);
+ }
+
+}, defaultState);
+
+export default addArtistReducers;
diff --git a/frontend/src/Store/Reducers/albumHistoryReducers.js b/frontend/src/Store/Reducers/albumHistoryReducers.js
new file mode 100644
index 000000000..44b89b1ee
--- /dev/null
+++ b/frontend/src/Store/Reducers/albumHistoryReducers.js
@@ -0,0 +1,26 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+const reducerSection = 'albumHistory';
+
+const albumHistoryReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+
+ [types.CLEAR_ALBUM_HISTORY]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState);
+
+export default albumHistoryReducers;
diff --git a/frontend/src/Store/Reducers/albumStudioReducers.js b/frontend/src/Store/Reducers/albumStudioReducers.js
new file mode 100644
index 000000000..658d02a4e
--- /dev/null
+++ b/frontend/src/Store/Reducers/albumStudioReducers.js
@@ -0,0 +1,39 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetClientSideCollectionSortReducer from './Creators/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/createSetClientSideCollectionFilterReducer';
+
+export const defaultState = {
+ isSaving: false,
+ saveError: null,
+ sortKey: 'sortName',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortName',
+ secondarySortDirection: sortDirections.ASCENDING,
+ filterKey: null,
+ filterValue: null,
+ filterType: filterTypes.EQUAL
+};
+
+export const persistState = [
+ 'albumStudio.sortKey',
+ 'albumStudio.sortDirection',
+ 'albumStudio.filterKey',
+ 'albumStudio.filterValue',
+ 'albumStudio.filterType'
+];
+
+const reducerSection = 'albumStudio';
+
+const albumStudioReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+
+ [types.SET_ALBUM_STUDIO_SORT]: createSetClientSideCollectionSortReducer(reducerSection),
+ [types.SET_ALBUM_STUDIO_FILTER]: createSetClientSideCollectionFilterReducer(reducerSection)
+
+}, defaultState);
+
+export default albumStudioReducers;
diff --git a/frontend/src/Store/Reducers/appReducers.js b/frontend/src/Store/Reducers/appReducers.js
new file mode 100644
index 000000000..f574495e3
--- /dev/null
+++ b/frontend/src/Store/Reducers/appReducers.js
@@ -0,0 +1,74 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+
+function getDimensions(width, height) {
+ const dimensions = {
+ width,
+ height,
+ isExtraSmallScreen: width <= 480,
+ isSmallScreen: width <= 768,
+ isMediumScreen: width <= 992,
+ isLargeScreen: width <= 1200
+ };
+
+ return dimensions;
+}
+
+export const defaultState = {
+ dimensions: getDimensions(window.innerWidth, window.innerHeight),
+ messages: {
+ items: []
+ },
+ version: window.Sonarr.version,
+ isUpdated: false,
+ isConnected: true,
+ isReconnecting: false,
+ isDisconnected: false,
+ isSidebarVisible: !getDimensions(window.innerWidth, window.innerHeight).isSmallScreen
+};
+
+const appReducers = handleActions({
+
+ [types.SAVE_DIMENSIONS]: function(state, { payload }) {
+ const {
+ width,
+ height
+ } = payload;
+
+ const dimensions = getDimensions(width, height);
+
+ return Object.assign({}, state, { dimensions });
+ },
+
+ [types.SHOW_MESSAGE]: createUpdateItemReducer('messages'),
+ [types.HIDE_MESSAGE]: createRemoveItemReducer('messages'),
+
+ [types.SET_APP_VALUE]: createSetReducer('app'),
+ [types.SET_VERSION]: function(state, { payload }) {
+ const version = payload.version;
+
+ const newState = {
+ version
+ };
+
+ if (state.version !== version) {
+ newState.isUpdated = true;
+ }
+
+ return Object.assign({}, state, newState);
+ },
+
+ [types.SET_IS_SIDEBAR_VISIBLE]: function(state, { payload }) {
+ const newState = {
+ isSidebarVisible: payload.isSidebarVisible
+ };
+
+ return Object.assign({}, state, newState);
+ }
+
+}, defaultState);
+
+export default appReducers;
diff --git a/frontend/src/Store/Reducers/artistEditorReducers.js b/frontend/src/Store/Reducers/artistEditorReducers.js
new file mode 100644
index 000000000..6eaa07fa4
--- /dev/null
+++ b/frontend/src/Store/Reducers/artistEditorReducers.js
@@ -0,0 +1,41 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetClientSideCollectionSortReducer from './Creators/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/createSetClientSideCollectionFilterReducer';
+
+export const defaultState = {
+ isSaving: false,
+ saveError: null,
+ isDeleting: false,
+ deleteError: null,
+ sortKey: 'sortName',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortName',
+ secondarySortDirection: sortDirections.ASCENDING,
+ filterKey: null,
+ filterValue: null,
+ filterType: filterTypes.EQUAL
+};
+
+export const persistState = [
+ 'artistEditor.sortKey',
+ 'artistEditor.sortDirection',
+ 'artistEditor.filterKey',
+ 'artistEditor.filterValue',
+ 'artistEditor.filterType'
+];
+
+const reducerSection = 'artistEditor';
+
+const artistEditorReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+
+ [types.SET_ARTIST_EDITOR_SORT]: createSetClientSideCollectionSortReducer(reducerSection),
+ [types.SET_ARTIST_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(reducerSection)
+
+}, defaultState);
+
+export default artistEditorReducers;
diff --git a/frontend/src/Store/Reducers/artistIndexReducers.js b/frontend/src/Store/Reducers/artistIndexReducers.js
new file mode 100644
index 000000000..cdc45e7e8
--- /dev/null
+++ b/frontend/src/Store/Reducers/artistIndexReducers.js
@@ -0,0 +1,247 @@
+import moment from 'moment';
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createSetClientSideCollectionSortReducer from './Creators/createSetClientSideCollectionSortReducer';
+import createSetClientSideCollectionFilterReducer from './Creators/createSetClientSideCollectionFilterReducer';
+
+export const defaultState = {
+ sortKey: 'sortName',
+ sortDirection: sortDirections.ASCENDING,
+ secondarySortKey: 'sortName',
+ secondarySortDirection: sortDirections.ASCENDING,
+ filterKey: null,
+ filterValue: null,
+ filterType: filterTypes.EQUAL,
+ view: 'posters',
+
+ posterOptions: {
+ detailedProgressBar: false,
+ size: 'large',
+ showTitle: false,
+ showQualityProfile: true
+ },
+
+ bannerOptions: {
+ detailedProgressBar: false,
+ size: 'large',
+ showTitle: false,
+ showQualityProfile: true
+ },
+
+ overviewOptions: {
+ detailedProgressBar: false,
+ size: 'medium',
+ showNetwork: true,
+ showQualityProfile: true,
+ showPreviousAiring: false,
+ showAdded: false,
+ showAlbumCount: true,
+ showPath: false,
+ showSizeOnDisk: false
+ },
+
+ columns: [
+ {
+ name: 'status',
+ columnLabel: 'Status',
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'sortName',
+ label: 'Artist Name',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'artistType',
+ label: 'Type',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'qualityProfileId',
+ label: 'Quality Profile',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'languageProfileId',
+ label: 'Language Profile',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'nextAiring',
+ label: 'Next Airing',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'previousAiring',
+ label: 'Previous Airing',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'added',
+ label: 'Added',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'albumCount',
+ label: 'Albums',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'trackProgress',
+ label: 'Tracks',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'trackCount',
+ label: 'Track Count',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'latestAlbum',
+ label: 'Latest Album',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'sizeOnDisk',
+ label: 'Size on Disk',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'tags',
+ label: 'Tags',
+ isSortable: false,
+ isVisible: false
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ sortPredicates: {
+ nextAiring: function(item, direction) {
+ const nextAiring = item.nextAiring;
+
+ if (nextAiring) {
+ return moment(nextAiring).unix();
+ }
+
+ if (direction === sortDirections.DESCENDING) {
+ return 0;
+ }
+
+ return Number.MAX_VALUE;
+ },
+
+ trackProgress: function(item) {
+ const {
+ trackCount = 0,
+ trackFileCount
+ } = item;
+
+ const progress = trackCount ? trackFileCount / trackCount * 100 : 100;
+
+ return progress + trackCount / 1000000;
+ }
+ },
+
+ filterPredicates: {
+ missing: function(item) {
+ return item.trackCount - item.trackFileCount > 0;
+ }
+ }
+};
+
+export const persistState = [
+ 'artistIndex.sortKey',
+ 'artistIndex.sortDirection',
+ 'artistIndex.filterKey',
+ 'artistIndex.filterValue',
+ 'artistIndex.filterType',
+ 'artistIndex.view',
+ 'artistIndex.columns',
+ 'artistIndex.posterOptions',
+ 'artistIndex.bannerOptions',
+ 'artistIndex.overviewOptions'
+];
+
+const reducerSection = 'artistIndex';
+
+const artistIndexReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+
+ [types.SET_ARTIST_SORT]: createSetClientSideCollectionSortReducer(reducerSection),
+ [types.SET_ARTIST_FILTER]: createSetClientSideCollectionFilterReducer(reducerSection),
+
+ [types.SET_ARTIST_VIEW]: function(state, { payload }) {
+ return Object.assign({}, state, { view: payload.view });
+ },
+
+ [types.SET_ARTIST_TABLE_OPTION]: createSetTableOptionReducer(reducerSection),
+
+ [types.SET_ARTIST_POSTER_OPTION]: function(state, { payload }) {
+ const posterOptions = state.posterOptions;
+
+ return {
+ ...state,
+ posterOptions: {
+ ...posterOptions,
+ ...payload
+ }
+ };
+ },
+
+ [types.SET_ARTIST_BANNER_OPTION]: function(state, { payload }) {
+ const bannerOptions = state.bannerOptions;
+
+ return {
+ ...state,
+ bannerOptions: {
+ ...bannerOptions,
+ ...payload
+ }
+ };
+ },
+
+ [types.SET_ARTIST_OVERVIEW_OPTION]: function(state, { payload }) {
+ const overviewOptions = state.overviewOptions;
+
+ return {
+ ...state,
+ overviewOptions: {
+ ...overviewOptions,
+ ...payload
+ }
+ };
+ }
+
+}, defaultState);
+
+export default artistIndexReducers;
diff --git a/frontend/src/Store/Reducers/artistReducers.js b/frontend/src/Store/Reducers/artistReducers.js
new file mode 100644
index 000000000..c60b6d9e6
--- /dev/null
+++ b/frontend/src/Store/Reducers/artistReducers.js
@@ -0,0 +1,37 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetSettingValueReducer from './Creators/createSetSettingValueReducer';
+import createClearPendingChangesReducer from './Creators/createClearPendingChangesReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ sortKey: 'sortName',
+ sortDirection: sortDirections.ASCENDING,
+ pendingChanges: {}
+};
+
+const reducerSection = 'artist';
+
+const artistReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+ [types.REMOVE_ITEM]: createRemoveItemReducer(reducerSection),
+
+ [types.SET_ARTIST_VALUE]: createSetSettingValueReducer(reducerSection),
+ [types.CLEAR_PENDING_CHANGES]: createClearPendingChangesReducer(reducerSection)
+
+}, defaultState);
+
+export default artistReducers;
diff --git a/frontend/src/Store/Reducers/blacklistReducers.js b/frontend/src/Store/Reducers/blacklistReducers.js
new file mode 100644
index 000000000..bee1f12d6
--- /dev/null
+++ b/frontend/src/Store/Reducers/blacklistReducers.js
@@ -0,0 +1,80 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateServerSideCollectionReducer from './Creators/createUpdateServerSideCollectionReducer';
+
+const reducerSection = 'blacklist';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING,
+ error: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'artist.sortName',
+ label: 'Artist Name',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'sourceTitle',
+ label: 'Source Title',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isVisible: false
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isVisible: true
+ },
+ {
+ name: 'date',
+ label: 'Date',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'indexer',
+ label: 'Indexer',
+ isSortable: true,
+ isVisible: false
+ },
+ {
+ name: 'details',
+ columnLabel: 'Details',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'blacklist.pageSize',
+ 'blacklist.sortKey',
+ 'blacklist.sortDirection',
+ 'blacklist.columns'
+];
+
+const blacklistReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_SERVER_SIDE_COLLECTION]: createUpdateServerSideCollectionReducer(reducerSection),
+ [types.SET_BLACKLIST_TABLE_OPTION]: createSetTableOptionReducer(reducerSection)
+
+}, defaultState);
+
+export default blacklistReducers;
diff --git a/frontend/src/Store/Reducers/calendarReducers.js b/frontend/src/Store/Reducers/calendarReducers.js
new file mode 100644
index 000000000..4dea88e97
--- /dev/null
+++ b/frontend/src/Store/Reducers/calendarReducers.js
@@ -0,0 +1,48 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ start: null,
+ end: null,
+ dates: [],
+ dayCount: 7,
+ view: window.innerWidth > 768 ? 'week' : 'day',
+ unmonitored: false,
+ showUpcoming: true,
+ error: null,
+ items: []
+};
+
+export const persistState = [
+ 'calendar.view',
+ 'calendar.unmonitored',
+ 'calendar.showUpcoming'
+];
+
+const section = 'calendar';
+
+const calendarReducers = handleActions({
+
+ [types.SET]: createSetReducer(section),
+ [types.UPDATE]: createUpdateReducer(section),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(section),
+
+ [types.CLEAR_CALENDAR]: (state) => {
+ const {
+ view,
+ unmonitored,
+ showUpcoming,
+ ...otherDefaultState
+ } = defaultState;
+
+ return Object.assign({}, state, otherDefaultState);
+ }
+
+}, defaultState);
+
+export default calendarReducers;
diff --git a/frontend/src/Store/Reducers/captchaReducers.js b/frontend/src/Store/Reducers/captchaReducers.js
new file mode 100644
index 000000000..67372839f
--- /dev/null
+++ b/frontend/src/Store/Reducers/captchaReducers.js
@@ -0,0 +1,32 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+export const defaultState = {
+ refreshing: false,
+ token: null,
+ siteKey: null,
+ secretToken: null,
+ ray: null,
+ stoken: null,
+ responseUrl: null
+};
+
+const section = 'captcha';
+
+const captchaReducers = handleActions({
+
+ [types.SET_CAPTCHA_VALUE]: function(state, { payload }) {
+ const newState = Object.assign(getSectionState(state, section), payload);
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [types.RESET_CAPTCHA]: function(state) {
+ return updateSectionState(state, section, defaultState);
+ }
+
+}, defaultState);
+
+export default captchaReducers;
diff --git a/frontend/src/Store/Reducers/commandReducers.js b/frontend/src/Store/Reducers/commandReducers.js
new file mode 100644
index 000000000..b2b474e65
--- /dev/null
+++ b/frontend/src/Store/Reducers/commandReducers.js
@@ -0,0 +1,64 @@
+import _ from 'lodash';
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ handlers: {}
+};
+
+const reducerSection = 'commands';
+
+const commandReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+
+ [types.ADD_COMMAND]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+ newState.items = [...state.items, payload];
+
+ return newState;
+ },
+
+ [types.REMOVE_COMMAND]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+ newState.items = [...state.items];
+
+ const index = _.findIndex(newState.items, { id: payload.id });
+
+ if (index > -1) {
+ newState.items.splice(index, 1);
+ }
+
+ return newState;
+ },
+
+ [types.REGISTER_FINISH_COMMAND_HANDLER]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+
+ newState.handlers[payload.key] = {
+ name: payload.name,
+ handler: payload.handler
+ };
+
+ return newState;
+ },
+
+ [types.UNREGISTER_FINISH_COMMAND_HANDLER]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+ delete newState.handlers[payload.key];
+
+ return newState;
+ }
+
+}, defaultState);
+
+export default commandReducers;
diff --git a/frontend/src/Store/Reducers/episodeReducers.js b/frontend/src/Store/Reducers/episodeReducers.js
new file mode 100644
index 000000000..479ea3fe0
--- /dev/null
+++ b/frontend/src/Store/Reducers/episodeReducers.js
@@ -0,0 +1,91 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createSetClientSideCollectionSortReducer from './Creators/createSetClientSideCollectionSortReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ sortKey: 'releaseDate',
+ sortDirection: sortDirections.DESCENDING,
+ items: [],
+
+ columns: [
+ {
+ name: 'monitored',
+ columnLabel: 'Monitored',
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'title',
+ label: 'Title',
+ isVisible: true
+ },
+ {
+ name: 'path',
+ label: 'Path',
+ isVisible: false
+ },
+ {
+ name: 'releaseDate',
+ label: 'Release Date',
+ isVisible: true
+ },
+ {
+ name: 'trackCount',
+ label: 'Track Count',
+ isVisible: false
+ },
+ {
+ name: 'duration',
+ label: 'Duration',
+ isVisible: false
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'episodes.columns'
+];
+
+const reducerSection = 'episodes';
+
+const episodeReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+
+ [types.SET_EPISODES_TABLE_OPTION]: createSetTableOptionReducer(reducerSection),
+
+ [types.CLEAR_EPISODES]: (state) => {
+ return Object.assign({}, state, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ });
+ },
+
+ [types.SET_EPISODES_SORT]: createSetClientSideCollectionSortReducer(reducerSection)
+
+}, defaultState);
+
+export default episodeReducers;
diff --git a/frontend/src/Store/Reducers/historyReducers.js b/frontend/src/Store/Reducers/historyReducers.js
new file mode 100644
index 000000000..8104c3814
--- /dev/null
+++ b/frontend/src/Store/Reducers/historyReducers.js
@@ -0,0 +1,113 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createClearReducer from './Creators/createClearReducer';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createUpdateServerSideCollectionReducer from './Creators/createUpdateServerSideCollectionReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pageSize: 20,
+ sortKey: 'date',
+ sortDirection: sortDirections.DESCENDING,
+ filterKey: null,
+ filterValue: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'eventType',
+ columnLabel: 'Event Type',
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'artist.sortName',
+ label: 'Artist',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'episodeTitle',
+ label: 'Album Title',
+ isVisible: true
+ },
+ {
+ name: 'trackTitle',
+ label: 'Track Title',
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isVisible: false
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isVisible: true
+ },
+ {
+ name: 'date',
+ label: 'Date',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'downloadClient',
+ label: 'Download Client',
+ isVisible: false
+ },
+ {
+ name: 'indexer',
+ label: 'Indexer',
+ isVisible: false
+ },
+ {
+ name: 'releaseGroup',
+ label: 'Release Group',
+ isVisible: false
+ },
+ {
+ name: 'details',
+ columnLabel: 'Details',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'history.pageSize',
+ 'history.sortKey',
+ 'history.sortDirection',
+ 'history.filterKey',
+ 'history.filterValue'
+];
+
+const serverSideCollectionName = 'history';
+
+const historyReducers = handleActions({
+
+ [types.SET]: createSetReducer(serverSideCollectionName),
+ [types.UPDATE]: createUpdateReducer(serverSideCollectionName),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(serverSideCollectionName),
+ [types.UPDATE_SERVER_SIDE_COLLECTION]: createUpdateServerSideCollectionReducer(serverSideCollectionName),
+
+ [types.SET_HISTORY_TABLE_OPTION]: createSetTableOptionReducer(serverSideCollectionName),
+
+ [types.CLEAR_HISTORY]: createClearReducer('history', {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ })
+
+}, defaultState);
+
+export default historyReducers;
diff --git a/frontend/src/Store/Reducers/importArtistReducers.js b/frontend/src/Store/Reducers/importArtistReducers.js
new file mode 100644
index 000000000..fddf32838
--- /dev/null
+++ b/frontend/src/Store/Reducers/importArtistReducers.js
@@ -0,0 +1,35 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isImporting: false,
+ isImported: false,
+ importError: null,
+ items: []
+};
+
+const reducerSection = 'importArtist';
+
+const importArtistReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+ [types.REMOVE_ITEM]: createRemoveItemReducer(reducerSection),
+
+ [types.CLEAR_IMPORT_ARTIST]: function(state) {
+ return Object.assign({}, state, defaultState);
+ },
+
+ [types.SET_IMPORT_ARTIST_VALUE]: createUpdateItemReducer(reducerSection)
+
+}, defaultState);
+
+export default importArtistReducers;
diff --git a/frontend/src/Store/Reducers/index.js b/frontend/src/Store/Reducers/index.js
new file mode 100644
index 000000000..917419459
--- /dev/null
+++ b/frontend/src/Store/Reducers/index.js
@@ -0,0 +1,91 @@
+import { combineReducers } from 'redux';
+import { enableBatching } from 'redux-batched-actions';
+import { routerReducer } from 'react-router-redux';
+import app, { defaultState as defaultappState } from './appReducers';
+import addArtist, { defaultState as defaultAddArtistState } from './addArtistReducers';
+import importArtist, { defaultState as defaultImportArtistState } from './importArtistReducers';
+import artist, { defaultState as defaultArtistState } from './artistReducers';
+import artistIndex, { defaultState as defaultArtistIndexState } from './artistIndexReducers';
+import artistEditor, { defaultState as defaultArtistEditorState } from './artistEditorReducers';
+import albumStudio, { defaultState as defaultAlbumStudioState } from './albumStudioReducers';
+import calendar, { defaultState as defaultCalendarState } from './calendarReducers';
+import history, { defaultState as defaultHistoryState } from './historyReducers';
+import queue, { defaultState as defaultQueueState } from './queueReducers';
+import blacklist, { defaultState as defaultBlacklistState } from './blacklistReducers';
+import episodes, { defaultState as defaultEpisodesState } from './episodeReducers';
+import tracks, { defaultState as defaultTracksState } from './trackReducers';
+import trackFiles, { defaultState as defaultTrackFilesState } from './trackFileReducers';
+import albumHistory, { defaultState as defaultAlbumHistoryState } from './albumHistoryReducers';
+import releases, { defaultState as defaultReleasesState } from './releaseReducers';
+import wanted, { defaultState as defaultWantedState } from './wantedReducers';
+import settings, { defaultState as defaultSettingsState } from './settingsReducers';
+import system, { defaultState as defaultSystemState } from './systemReducers';
+import commands, { defaultState as defaultCommandsState } from './commandReducers';
+import paths, { defaultState as defaultPathsState } from './pathReducers';
+import tags, { defaultState as defaultTagsState } from './tagReducers';
+import captcha, { defaultState as defaultCaptchaState } from './captchaReducers';
+import oAuth, { defaultState as defaultOAuthState } from './oAuthReducers';
+import interactiveImport, { defaultState as defaultInteractiveImportState } from './interactiveImportReducers';
+import rootFolders, { defaultState as defaultRootFoldersState } from './rootFolderReducers';
+import organizePreview, { defaultState as defaultOrganizePreviewState } from './organizePreviewReducers';
+
+export const defaultState = {
+ app: defaultappState,
+ addArtist: defaultAddArtistState,
+ importArtist: defaultImportArtistState,
+ artist: defaultArtistState,
+ artistIndex: defaultArtistIndexState,
+ artistEditor: defaultArtistEditorState,
+ albumStudio: defaultAlbumStudioState,
+ calendar: defaultCalendarState,
+ history: defaultHistoryState,
+ queue: defaultQueueState,
+ blacklist: defaultBlacklistState,
+ episodes: defaultEpisodesState,
+ tracks: defaultTracksState,
+ trackFiles: defaultTrackFilesState,
+ albumHistory: defaultAlbumHistoryState,
+ releases: defaultReleasesState,
+ wanted: defaultWantedState,
+ settings: defaultSettingsState,
+ system: defaultSystemState,
+ commands: defaultCommandsState,
+ paths: defaultPathsState,
+ tags: defaultTagsState,
+ captcha: defaultCaptchaState,
+ oAuth: defaultOAuthState,
+ interactiveImport: defaultInteractiveImportState,
+ rootFolders: defaultRootFoldersState,
+ organizePreview: defaultOrganizePreviewState
+};
+
+export default enableBatching(combineReducers({
+ app,
+ addArtist,
+ importArtist,
+ artist,
+ artistIndex,
+ artistEditor,
+ albumStudio,
+ calendar,
+ history,
+ queue,
+ blacklist,
+ episodes,
+ tracks,
+ trackFiles,
+ albumHistory,
+ releases,
+ wanted,
+ settings,
+ system,
+ commands,
+ paths,
+ tags,
+ captcha,
+ oAuth,
+ interactiveImport,
+ rootFolders,
+ organizePreview,
+ routing: routerReducer
+}));
diff --git a/frontend/src/Store/Reducers/interactiveImportReducers.js b/frontend/src/Store/Reducers/interactiveImportReducers.js
new file mode 100644
index 000000000..b1230befe
--- /dev/null
+++ b/frontend/src/Store/Reducers/interactiveImportReducers.js
@@ -0,0 +1,119 @@
+import _ from 'lodash';
+import moment from 'moment';
+import { handleActions } from 'redux-actions';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createReducers from './Creators/createReducers';
+import createSetClientSideCollectionSortReducer from './Creators/createSetClientSideCollectionSortReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ sortKey: 'quality',
+ sortDirection: sortDirections.DESCENDING,
+ recentFolders: [],
+ importMode: 'move',
+ sortPredicates: {
+ artist: function(item, direction) {
+ const artist = item.artist;
+
+ return artist ? artist.sortName : '';
+ },
+
+ quality: function(item, direction) {
+ return item.quality.qualityWeight;
+ }
+ },
+
+ interactiveImportAlbums: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ sortKey: 'albumTitle',
+ sortDirection: sortDirections.DESCENDING,
+ items: []
+ }
+};
+
+export const persistState = [
+ 'interactiveImport.recentFolders',
+ 'interactiveImport.importMode'
+];
+
+const reducerSection = 'interactiveImport';
+const episodesSection = 'interactiveImportAlbums';
+
+const interactiveImportReducers = handleActions({
+
+ [types.SET]: createReducers([reducerSection, episodesSection], createSetReducer),
+ [types.UPDATE]: createReducers([reducerSection, episodesSection], createUpdateReducer),
+
+ [types.UPDATE_INTERACTIVE_IMPORT_ITEM]: (state, { payload }) => {
+ const id = payload.id;
+ const newState = Object.assign({}, state);
+ const items = newState.items;
+ const index = _.findIndex(items, { id });
+ const item = Object.assign({}, items[index], payload);
+
+ newState.items = [...items];
+ newState.items.splice(index, 1, item);
+
+ return newState;
+ },
+
+ [types.ADD_RECENT_FOLDER]: function(state, { payload }) {
+ const folder = payload.folder;
+ const recentFolder = { folder, lastUsed: moment().toISOString() };
+ const recentFolders = [...state.recentFolders];
+ const index = _.findIndex(recentFolders, { folder });
+
+ if (index > -1) {
+ recentFolders.splice(index, 1, recentFolder);
+ } else {
+ recentFolders.push(recentFolder);
+ }
+
+ return Object.assign({}, state, { recentFolders });
+ },
+
+ [types.REMOVE_RECENT_FOLDER]: function(state, { payload }) {
+ const folder = payload.folder;
+ const recentFolders = _.remove([...state.recentFolders], { folder });
+
+ return Object.assign({}, state, { recentFolders });
+ },
+
+ [types.CLEAR_INTERACTIVE_IMPORT]: function(state) {
+ const newState = {
+ ...defaultState,
+ recentFolders: state.recentFolders,
+ importMode: state.importMode
+ };
+
+ return newState;
+ },
+
+ [types.SET_INTERACTIVE_IMPORT_SORT]: createSetClientSideCollectionSortReducer(reducerSection),
+
+ [types.SET_INTERACTIVE_IMPORT_MODE]: function(state, { payload }) {
+ return Object.assign({}, state, { importMode: payload.importMode });
+ },
+
+ [types.SET_INTERACTIVE_IMPORT_ALBUMS_SORT]: createSetClientSideCollectionSortReducer(episodesSection),
+
+ [types.CLEAR_INTERACTIVE_IMPORT_ALBUMS]: (state) => {
+ const section = episodesSection;
+
+ return updateSectionState(state, section, {
+ ...defaultState.interactiveImportAlbums
+ });
+ }
+
+}, defaultState);
+
+export default interactiveImportReducers;
diff --git a/frontend/src/Store/Reducers/oAuthReducers.js b/frontend/src/Store/Reducers/oAuthReducers.js
new file mode 100644
index 000000000..291faf285
--- /dev/null
+++ b/frontend/src/Store/Reducers/oAuthReducers.js
@@ -0,0 +1,28 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+export const defaultState = {
+ authorizing: false,
+ accessToken: null,
+ accessTokenSecret: null
+};
+
+const section = 'oAuth';
+
+const oAuthReducers = handleActions({
+
+ [types.SET_OAUTH_VALUE]: function(state, { payload }) {
+ const newState = Object.assign(getSectionState(state, section), payload);
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [types.RESET_OAUTH]: function(state) {
+ return updateSectionState(state, section, defaultState);
+ }
+
+}, defaultState);
+
+export default oAuthReducers;
diff --git a/frontend/src/Store/Reducers/organizePreviewReducers.js b/frontend/src/Store/Reducers/organizePreviewReducers.js
new file mode 100644
index 000000000..c4e182c09
--- /dev/null
+++ b/frontend/src/Store/Reducers/organizePreviewReducers.js
@@ -0,0 +1,26 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+const reducerSection = 'organizePreview';
+
+const organizePreviewReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+
+ [types.CLEAR_ORGANIZE_PREVIEW]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState);
+
+export default organizePreviewReducers;
diff --git a/frontend/src/Store/Reducers/pathReducers.js b/frontend/src/Store/Reducers/pathReducers.js
new file mode 100644
index 000000000..c10ad89c7
--- /dev/null
+++ b/frontend/src/Store/Reducers/pathReducers.js
@@ -0,0 +1,45 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+
+export const defaultState = {
+ currentPath: '',
+ isPopulated: false,
+ isFetching: false,
+ error: null,
+ directories: [],
+ files: [],
+ parent: null
+};
+
+const reducerSection = 'paths';
+
+const pathReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+
+ [types.UPDATE_PATHS]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+
+ newState.currentPath = payload.path;
+ newState.directories = payload.directories;
+ newState.files = payload.files;
+ newState.parent = payload.parent;
+
+ return newState;
+ },
+
+ [types.CLEAR_PATHS]: (state, { payload }) => {
+ const newState = Object.assign({}, state);
+
+ newState.path = '';
+ newState.directories = [];
+ newState.files = [];
+ newState.parent = '';
+
+ return newState;
+ }
+
+}, defaultState);
+
+export default pathReducers;
diff --git a/frontend/src/Store/Reducers/queueReducers.js b/frontend/src/Store/Reducers/queueReducers.js
new file mode 100644
index 000000000..cd129741d
--- /dev/null
+++ b/frontend/src/Store/Reducers/queueReducers.js
@@ -0,0 +1,141 @@
+import { handleActions } from 'redux-actions';
+import { sortDirections } from 'Helpers/Props';
+import * as types from 'Store/Actions/actionTypes';
+import createClearReducer from './Creators/createClearReducer';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createReducers from './Creators/createReducers';
+import createUpdateServerSideCollectionReducer from './Creators/createUpdateServerSideCollectionReducer';
+
+export const defaultState = {
+ queueStatus: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {}
+ },
+
+ details: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ params: {}
+ },
+
+ paged: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'timeleft',
+ sortDirection: sortDirections.ASCENDING,
+ error: null,
+ items: [],
+ isGrabbing: false,
+ isRemoving: false,
+
+ columns: [
+ {
+ name: 'status',
+ columnLabel: 'Status',
+ isVisible: true,
+ isModifiable: false
+ },
+ {
+ name: 'artist.sortName',
+ label: 'Artist',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'episodeTitle',
+ label: 'Album Title',
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'protocol',
+ label: 'Protocol',
+ isVisible: false
+ },
+ {
+ name: 'indexer',
+ label: 'Indexer',
+ isVisible: false
+ },
+ {
+ name: 'downloadClient',
+ label: 'Download Client',
+ isVisible: false
+ },
+ {
+ name: 'estimatedCompletionTime',
+ label: 'Timeleft',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'progress',
+ label: 'Progress',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+ },
+
+ queueEpisodes: {
+ isPopulated: false,
+ items: []
+ }
+};
+
+export const persistState = [
+ 'queue.paged.pageSize',
+ 'queue.paged.sortKey',
+ 'queue.paged.sortDirection',
+ 'queue.paged.columns'
+];
+
+const propertyNames = [
+ 'queueStatus',
+ 'details',
+ 'episodes'
+];
+
+const paged = 'paged';
+
+const queueReducers = handleActions({
+
+ [types.SET]: createReducers([...propertyNames, paged], createSetReducer),
+ [types.UPDATE]: createReducers([...propertyNames, paged], createUpdateReducer),
+ [types.UPDATE_ITEM]: createReducers(['queueEpisodes', paged], createUpdateItemReducer),
+
+ [types.CLEAR_QUEUE_DETAILS]: createClearReducer('details', defaultState.details),
+
+ [types.UPDATE_SERVER_SIDE_COLLECTION]: createUpdateServerSideCollectionReducer(paged),
+
+ [types.SET_QUEUE_TABLE_OPTION]: createSetTableOptionReducer(paged),
+
+ [types.CLEAR_QUEUE]: createClearReducer('paged', {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ })
+
+}, defaultState);
+
+export default queueReducers;
diff --git a/frontend/src/Store/Reducers/releaseReducers.js b/frontend/src/Store/Reducers/releaseReducers.js
new file mode 100644
index 000000000..237049578
--- /dev/null
+++ b/frontend/src/Store/Reducers/releaseReducers.js
@@ -0,0 +1,71 @@
+import _ from 'lodash';
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createSetClientSideCollectionSortReducer from './Creators/createSetClientSideCollectionSortReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ sortKey: 'releaseWeight',
+ sortDirection: sortDirections.ASCENDING,
+ sortPredicates: {
+ peers: function(item, direction) {
+ const seeders = item.seeders || 0;
+ const leechers = item.leechers || 0;
+
+ return seeders * 1000000 + leechers;
+ },
+
+ rejections: function(item, direction) {
+ const rejections = item.rejections;
+ const releaseWeight = item.releaseWeight;
+
+ if (rejections.length !== 0) {
+ return releaseWeight + 1000000;
+ }
+
+ return releaseWeight;
+ }
+ }
+};
+
+const reducerSection = 'releases';
+
+const releaseReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+
+ [types.CLEAR_RELEASES]: (state) => {
+ return Object.assign({}, state, defaultState);
+ },
+
+ [types.UPDATE_RELEASE]: (state, { payload }) => {
+ const guid = payload.guid;
+ const newState = Object.assign({}, state);
+ const items = newState.items;
+
+ // Return early if there aren't any items (the user closed the modal)
+ if (!items.length) {
+ return;
+ }
+
+ const index = _.findIndex(items, { guid });
+ const item = Object.assign({}, items[index], payload);
+
+ newState.items = [...items];
+ newState.items.splice(index, 1, item);
+
+ return newState;
+ },
+
+ [types.SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(reducerSection)
+
+}, defaultState);
+
+export default releaseReducers;
diff --git a/frontend/src/Store/Reducers/rootFolderReducers.js b/frontend/src/Store/Reducers/rootFolderReducers.js
new file mode 100644
index 000000000..bd5c18bfa
--- /dev/null
+++ b/frontend/src/Store/Reducers/rootFolderReducers.js
@@ -0,0 +1,28 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: []
+};
+
+const reducerSection = 'rootFolders';
+
+const rootFolderReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+ [types.REMOVE_ITEM]: createRemoveItemReducer(reducerSection)
+
+}, defaultState);
+
+export default rootFolderReducers;
diff --git a/frontend/src/Store/Reducers/settingsReducers.js b/frontend/src/Store/Reducers/settingsReducers.js
new file mode 100644
index 000000000..2e92ef6c1
--- /dev/null
+++ b/frontend/src/Store/Reducers/settingsReducers.js
@@ -0,0 +1,350 @@
+import _ from 'lodash';
+import { handleActions } from 'redux-actions';
+import getSectionState from 'Utilities/State/getSectionState';
+import selectProviderSchema from 'Utilities/State/selectProviderSchema';
+import updateSectionState from 'Utilities/State/updateSectionState';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createSetSettingValueReducer from './Creators/createSetSettingValueReducer';
+import createSetProviderFieldValueReducer from './Creators/createSetProviderFieldValueReducer';
+import createClearPendingChangesReducer from './Creators/createClearPendingChangesReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+import createReducers from './Creators/createReducers';
+
+export const defaultState = {
+ ui: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ mediaManagement: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ naming: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ namingExamples: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {}
+ },
+
+ qualityProfiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isFetchingSchema: false,
+ schemaPopulated: false,
+ schemaError: null,
+ schema: {},
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ languageProfiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isFetchingSchema: false,
+ schemaPopulated: false,
+ schemaError: null,
+ schema: {},
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ delayProfiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ qualityDefinitions: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ indexers: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isFetchingSchema: false,
+ schemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ indexerOptions: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ restrictions: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ downloadClients: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isFetchingSchema: false,
+ schemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ downloadClientOptions: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ remotePathMappings: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ isSaving: false,
+ saveError: null,
+ pendingChanges: {}
+ },
+
+ notifications: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isFetchingSchema: false,
+ schemaPopulated: false,
+ schemaError: null,
+ schema: [],
+ selectedSchema: {},
+ isSaving: false,
+ saveError: null,
+ isTesting: false,
+ items: [],
+ pendingChanges: {}
+ },
+
+ metadata: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isSaving: false,
+ saveError: null,
+ items: [],
+ pendingChanges: {}
+ },
+
+ metadataProvider: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ general: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ pendingChanges: {},
+ isSaving: false,
+ saveError: null,
+ item: {}
+ },
+
+ advancedSettings: false
+};
+
+export const persistState = [
+ 'settings.advancedSettings'
+];
+
+const propertyNames = [
+ 'ui',
+ 'mediaManagement',
+ 'naming',
+ 'namingExamples',
+ 'qualityDefinitions',
+ 'indexerOptions',
+ 'downloadClientOptions',
+ 'general',
+ 'metadataProvider'
+];
+
+const providerPropertyNames = [
+ 'qualityProfiles',
+ 'languageProfiles',
+ 'delayProfiles',
+ 'indexers',
+ 'restrictions',
+ 'downloadClients',
+ 'remotePathMappings',
+ 'notifications',
+ 'metadata'
+];
+
+const settingsReducers = handleActions({
+
+ [types.TOGGLE_ADVANCED_SETTINGS]: (state, { payload }) => {
+ return Object.assign({}, state, { advancedSettings: !state.advancedSettings });
+ },
+
+ [types.SET]: createReducers([...propertyNames, ...providerPropertyNames], createSetReducer),
+ [types.UPDATE]: createReducers([...propertyNames, ...providerPropertyNames], createUpdateReducer),
+ [types.UPDATE_ITEM]: createReducers(providerPropertyNames, createUpdateItemReducer),
+ [types.CLEAR_PENDING_CHANGES]: createReducers([...propertyNames, ...providerPropertyNames], createClearPendingChangesReducer),
+
+ [types.REMOVE_ITEM]: createReducers(providerPropertyNames, createRemoveItemReducer),
+
+ [types.SET_UI_SETTINGS_VALUE]: createSetSettingValueReducer('ui'),
+ [types.SET_MEDIA_MANAGEMENT_SETTINGS_VALUE]: createSetSettingValueReducer('mediaManagement'),
+ [types.SET_NAMING_SETTINGS_VALUE]: createSetSettingValueReducer('naming'),
+ [types.SET_QUALITY_PROFILE_VALUE]: createSetSettingValueReducer('qualityProfiles'),
+ [types.SET_LANGUAGE_PROFILE_VALUE]: createSetSettingValueReducer('languageProfiles'),
+ [types.SET_DELAY_PROFILE_VALUE]: createSetSettingValueReducer('delayProfiles'),
+
+ [types.SET_QUALITY_DEFINITION_VALUE]: function(state, { payload }) {
+ const section = 'qualityDefinitions';
+ const { id, name, value } = payload;
+ const newState = getSectionState(state, section);
+ newState.pendingChanges = _.cloneDeep(newState.pendingChanges);
+
+ const pendingState = newState.pendingChanges[id] || {};
+ const currentValue = _.find(newState.items, { id })[name];
+
+ if (currentValue === value) {
+ delete pendingState[name];
+ } else {
+ pendingState[name] = value;
+ }
+
+ if (_.isEmpty(pendingState)) {
+ delete newState.pendingChanges[id];
+ } else {
+ newState.pendingChanges[id] = pendingState;
+ }
+
+ return updateSectionState(state, section, newState);
+ },
+
+ [types.SET_INDEXER_VALUE]: createSetSettingValueReducer('indexers'),
+ [types.SET_INDEXER_FIELD_VALUE]: createSetProviderFieldValueReducer('indexers'),
+ [types.SET_INDEXER_OPTIONS_VALUE]: createSetSettingValueReducer('indexerOptions'),
+ [types.SET_RESTRICTION_VALUE]: createSetSettingValueReducer('restrictions'),
+
+ [types.SELECT_INDEXER_SCHEMA]: function(state, { payload }) {
+ return selectProviderSchema(state, 'indexers', payload, (selectedSchema) => {
+ selectedSchema.enableRss = selectedSchema.supportsRss;
+ selectedSchema.enableSearch = selectedSchema.supportsSearch;
+
+ return selectedSchema;
+ });
+ },
+
+ [types.SET_DOWNLOAD_CLIENT_VALUE]: createSetSettingValueReducer('downloadClients'),
+ [types.SET_DOWNLOAD_CLIENT_FIELD_VALUE]: createSetProviderFieldValueReducer('downloadClients'),
+
+ [types.SELECT_DOWNLOAD_CLIENT_SCHEMA]: function(state, { payload }) {
+ return selectProviderSchema(state, 'downloadClients', payload, (selectedSchema) => {
+ selectedSchema.enable = true;
+
+ return selectedSchema;
+ });
+ },
+
+ [types.SET_DOWNLOAD_CLIENT_OPTIONS_VALUE]: createSetSettingValueReducer('downloadClientOptions'),
+ [types.SET_REMOTE_PATH_MAPPING_VALUE]: createSetSettingValueReducer('remotePathMappings'),
+
+ [types.SET_NOTIFICATION_VALUE]: createSetSettingValueReducer('notifications'),
+ [types.SET_NOTIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer('notifications'),
+
+ [types.SELECT_NOTIFICATION_SCHEMA]: function(state, { payload }) {
+ return selectProviderSchema(state, 'notifications', payload, (selectedSchema) => {
+ selectedSchema.onGrab = selectedSchema.supportsOnGrab;
+ selectedSchema.onDownload = selectedSchema.supportsOnDownload;
+ selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade;
+ selectedSchema.onRename = selectedSchema.supportsOnRename;
+
+ return selectedSchema;
+ });
+ },
+
+ [types.SET_METADATA_VALUE]: createSetSettingValueReducer('metadata'),
+ [types.SET_METADATA_FIELD_VALUE]: createSetProviderFieldValueReducer('metadata'),
+
+ [types.SET_METADATA_PROVIDER_VALUE]: createSetSettingValueReducer('metadataProvider'),
+
+ [types.SET_GENERAL_SETTINGS_VALUE]: createSetSettingValueReducer('general')
+
+}, defaultState);
+
+export default settingsReducers;
diff --git a/frontend/src/Store/Reducers/systemReducers.js b/frontend/src/Store/Reducers/systemReducers.js
new file mode 100644
index 000000000..31aa2ab08
--- /dev/null
+++ b/frontend/src/Store/Reducers/systemReducers.js
@@ -0,0 +1,146 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createUpdateServerSideCollectionReducer from './Creators/createUpdateServerSideCollectionReducer';
+import createReducers from './Creators/createReducers';
+
+export const defaultState = {
+ status: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ item: {}
+ },
+
+ health: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ diskSpace: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ tasks: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ backups: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ updates: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ logs: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 50,
+ sortKey: 'time',
+ sortDirection: sortDirections.DESCENDING,
+ filterKey: null,
+ filterValue: null,
+ error: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'level',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'logger',
+ label: 'Component',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'message',
+ label: 'Message',
+ isVisible: true
+ },
+ {
+ name: 'time',
+ label: 'Time',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isSortable: true,
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+ },
+
+ logFiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ },
+
+ updateLogFiles: {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ }
+};
+
+export const persistState = [
+ 'system.logs.pageSize',
+ 'system.logs.sortKey',
+ 'system.logs.sortDirection',
+ 'system.logs.filterKey',
+ 'system.logs.filterValue'
+];
+
+const collectionNames = [
+ 'health',
+ 'diskSpace',
+ 'tasks',
+ 'backups',
+ 'updates',
+ 'logFiles',
+ 'updateLogFiles'
+];
+
+const serverSideCollectionNames = [
+ 'logs'
+];
+
+const systemReducers = handleActions({
+
+ [types.SET]: createReducers(['status', ...collectionNames, ...serverSideCollectionNames], createSetReducer),
+ [types.UPDATE]: createReducers(['status', ...collectionNames, ...serverSideCollectionNames], createUpdateReducer),
+ [types.UPDATE_ITEM]: createUpdateItemReducer('tasks'),
+ [types.UPDATE_SERVER_SIDE_COLLECTION]: createReducers(serverSideCollectionNames, createUpdateServerSideCollectionReducer),
+
+ [types.SET_LOGS_TABLE_OPTION]: createSetTableOptionReducer('logs')
+
+}, defaultState);
+
+export default systemReducers;
diff --git a/frontend/src/Store/Reducers/tagReducers.js b/frontend/src/Store/Reducers/tagReducers.js
new file mode 100644
index 000000000..6aa822fd7
--- /dev/null
+++ b/frontend/src/Store/Reducers/tagReducers.js
@@ -0,0 +1,22 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+};
+
+const reducerSection = 'tags';
+
+const tagReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection)
+
+}, defaultState);
+
+export default tagReducers;
diff --git a/frontend/src/Store/Reducers/trackFileReducers.js b/frontend/src/Store/Reducers/trackFileReducers.js
new file mode 100644
index 000000000..0db17a70f
--- /dev/null
+++ b/frontend/src/Store/Reducers/trackFileReducers.js
@@ -0,0 +1,34 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import createSetReducer from './Creators/createSetReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createRemoveItemReducer from './Creators/createRemoveItemReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ isDeleting: false,
+ deleteError: null,
+ isSaving: false,
+ saveError: null,
+ items: []
+};
+
+const reducerSection = 'trackFiles';
+
+const trackFileReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+ [types.REMOVE_ITEM]: createRemoveItemReducer(reducerSection),
+
+ [types.CLEAR_TRACK_FILES]: (state) => {
+ return Object.assign({}, state, defaultState);
+ }
+
+}, defaultState);
+
+export default trackFileReducers;
diff --git a/frontend/src/Store/Reducers/trackReducers.js b/frontend/src/Store/Reducers/trackReducers.js
new file mode 100644
index 000000000..3f684fd9d
--- /dev/null
+++ b/frontend/src/Store/Reducers/trackReducers.js
@@ -0,0 +1,80 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createSetClientSideCollectionSortReducer from './Creators/createSetClientSideCollectionSortReducer';
+
+export const defaultState = {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ sortKey: 'trackNumber',
+ sortDirection: sortDirections.DESCENDING,
+ items: [],
+
+ columns: [
+ {
+ name: 'trackNumber',
+ label: '#',
+ isVisible: true
+ },
+ {
+ name: 'title',
+ label: 'Title',
+ isVisible: true
+ },
+ {
+ name: 'duration',
+ label: 'Duration',
+ isVisible: true
+ },
+ {
+ name: 'audioInfo',
+ label: 'Audio Info',
+ isVisible: true
+ },
+ {
+ name: 'status',
+ label: 'Status',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+};
+
+export const persistState = [
+ 'tracks.columns'
+];
+
+const reducerSection = 'tracks';
+
+const trackReducers = handleActions({
+
+ [types.SET]: createSetReducer(reducerSection),
+ [types.UPDATE]: createUpdateReducer(reducerSection),
+ [types.UPDATE_ITEM]: createUpdateItemReducer(reducerSection),
+
+ [types.SET_TRACKS_TABLE_OPTION]: createSetTableOptionReducer(reducerSection),
+
+ [types.CLEAR_TRACKS]: (state) => {
+ return Object.assign({}, state, {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ });
+ },
+
+ [types.SET_TRACKS_SORT]: createSetClientSideCollectionSortReducer(reducerSection)
+
+}, defaultState);
+
+export default trackReducers;
diff --git a/frontend/src/Store/Reducers/wantedReducers.js b/frontend/src/Store/Reducers/wantedReducers.js
new file mode 100644
index 000000000..c126901c2
--- /dev/null
+++ b/frontend/src/Store/Reducers/wantedReducers.js
@@ -0,0 +1,161 @@
+import { handleActions } from 'redux-actions';
+import * as types from 'Store/Actions/actionTypes';
+import { sortDirections } from 'Helpers/Props';
+import createClearReducer from './Creators/createClearReducer';
+import createSetReducer from './Creators/createSetReducer';
+import createSetTableOptionReducer from './Creators/createSetTableOptionReducer';
+import createUpdateReducer from './Creators/createUpdateReducer';
+import createUpdateItemReducer from './Creators/createUpdateItemReducer';
+import createUpdateServerSideCollectionReducer from './Creators/createUpdateServerSideCollectionReducer';
+import createReducers from './Creators/createReducers';
+
+export const defaultState = {
+ missing: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'releaseDate',
+ sortDirection: sortDirections.DESCENDING,
+ filterKey: 'monitored',
+ filterValue: 'true',
+ error: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'artist.sortName',
+ label: 'Artist Name',
+ isSortable: true,
+ isVisible: true
+ },
+ // {
+ // name: 'episode',
+ // label: 'Episode',
+ // isVisible: true
+ // },
+ {
+ name: 'albumTitle',
+ label: 'Album Title',
+ isVisible: true
+ },
+ {
+ name: 'releaseDate',
+ label: 'Release Date',
+ isSortable: true,
+ isVisible: true
+ },
+ // {
+ // name: 'status',
+ // label: 'Status',
+ // isVisible: true
+ // },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+ },
+
+ cutoffUnmet: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'airDateUtc',
+ sortDirection: sortDirections.DESCENDING,
+ filterKey: 'monitored',
+ filterValue: true,
+ error: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'artist.sortName',
+ label: 'Artist Name',
+ isSortable: true,
+ isVisible: true
+ },
+ // {
+ // name: 'episode',
+ // label: 'Episode',
+ // isVisible: true
+ // },
+ {
+ name: 'albumTitle',
+ label: 'Album Title',
+ isVisible: true
+ },
+ {
+ name: 'releaseDate',
+ label: 'Release Date',
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isVisible: false
+ },
+ // {
+ // name: 'status',
+ // label: 'Status',
+ // isVisible: true
+ // },
+ {
+ name: 'actions',
+ columnLabel: 'Actions',
+ isVisible: true,
+ isModifiable: false
+ }
+ ]
+ }
+};
+
+export const persistState = [
+ 'wanted.missing.pageSize',
+ 'wanted.missing.sortKey',
+ 'wanted.missing.sortDirection',
+ 'wanted.missing.filterKey',
+ 'wanted.missing.filterValue',
+ 'wanted.missing.columns',
+ 'wanted.cutoffUnmet.pageSize',
+ 'wanted.cutoffUnmet.sortKey',
+ 'wanted.cutoffUnmet.sortDirection',
+ 'wanted.cutoffUnmet.filterKey',
+ 'wanted.cutoffUnmet.filterValue',
+ 'wanted.cutoffUnmet.columns'
+];
+
+const serverSideCollectionNames = [
+ 'missing',
+ 'cutoffUnmet'
+];
+
+const wantedReducers = handleActions({
+
+ [types.SET]: createReducers(serverSideCollectionNames, createSetReducer),
+ [types.UPDATE]: createReducers(serverSideCollectionNames, createUpdateReducer),
+ [types.UPDATE_ITEM]: createReducers(serverSideCollectionNames, createUpdateItemReducer),
+ [types.UPDATE_SERVER_SIDE_COLLECTION]: createReducers(serverSideCollectionNames, createUpdateServerSideCollectionReducer),
+
+ [types.SET_MISSING_TABLE_OPTION]: createSetTableOptionReducer('missing'),
+ [types.SET_CUTOFF_UNMET_TABLE_OPTION]: createSetTableOptionReducer('cutoffUnmet'),
+
+ [types.CLEAR_MISSING]: createClearReducer('missing', {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ }),
+
+ [types.CLEAR_CUTOFF_UNMET]: createClearReducer('cutoffUnmet', {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: []
+ })
+
+}, defaultState);
+
+export default wantedReducers;
diff --git a/frontend/src/Store/Selectors/createAllArtistSelector.js b/frontend/src/Store/Selectors/createAllArtistSelector.js
new file mode 100644
index 000000000..38b1bcef1
--- /dev/null
+++ b/frontend/src/Store/Selectors/createAllArtistSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createAllArtistSelector() {
+ return createSelector(
+ (state) => state.artist,
+ (artist) => {
+ return artist.items;
+ }
+ );
+}
+
+export default createAllArtistSelector;
diff --git a/frontend/src/Store/Selectors/createArtistSelector.js b/frontend/src/Store/Selectors/createArtistSelector.js
new file mode 100644
index 000000000..bc49a9349
--- /dev/null
+++ b/frontend/src/Store/Selectors/createArtistSelector.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from './createAllArtistSelector';
+
+function createArtistSelector() {
+ return createSelector(
+ (state, { artistId }) => artistId,
+ createAllArtistSelector(),
+ (artistId, artist) => {
+ return _.find(artist, { id: artistId });
+ }
+ );
+}
+
+export default createArtistSelector;
diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
new file mode 100644
index 000000000..56b75c80c
--- /dev/null
+++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js
@@ -0,0 +1,117 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+
+const filterTypePredicates = {
+ [filterTypes.CONTAINS]: function(value, filterValue) {
+ return value.toLowerCase().indexOf(filterValue.toLowerCase()) > -1;
+ },
+
+ [filterTypes.EQUAL]: function(value, filterValue) {
+ return value === filterValue;
+ },
+
+ [filterTypes.GREATER_THAN]: function(value, filterValue) {
+ return value > filterValue;
+ },
+
+ [filterTypes.GREATER_THAN_OR_EQUAL]: function(value, filterValue) {
+ return value >= filterValue;
+ },
+
+ [filterTypes.LESS_THAN]: function(value, filterValue) {
+ return value < filterValue;
+ },
+
+ [filterTypes.LESS_THAN_OR_EQUAL]: function(value, filterValue) {
+ return value <= filterValue;
+ },
+
+ [filterTypes.NOT_EQUAL]: function(value, filterValue) {
+ return value !== filterValue;
+ }
+};
+
+function getSortClause(sortKey, sortDirection, sortPredicates) {
+ if (sortPredicates && sortPredicates.hasOwnProperty(sortKey)) {
+ return function(item) {
+ return sortPredicates[sortKey](item, sortDirection);
+ };
+ }
+
+ return function(item) {
+ return item[sortKey];
+ };
+}
+
+function filter(items, state) {
+ const {
+ filterKey,
+ filterValue,
+ filterType,
+ filterPredicates
+ } = state;
+
+ if (!filterKey || !filterValue) {
+ return items;
+ }
+
+ return _.filter(items, (item) => {
+ if (filterPredicates && filterPredicates.hasOwnProperty(filterKey)) {
+ return filterPredicates[filterKey](item);
+ }
+
+ if (item.hasOwnProperty(filterKey)) {
+ return filterTypePredicates[filterType](item[filterKey], filterValue);
+ }
+
+ return false;
+ });
+}
+
+function sort(items, state) {
+ const {
+ sortKey,
+ sortDirection,
+ sortPredicates,
+ secondarySortKey,
+ secondarySortDirection
+ } = state;
+
+ const clauses = [];
+ const orders = [];
+
+ clauses.push(getSortClause(sortKey, sortDirection, sortPredicates));
+ orders.push(sortDirection === sortDirections.ASCENDING ? 'asc' : 'desc');
+
+ if (secondarySortKey &&
+ secondarySortDirection &&
+ (sortKey !== secondarySortKey ||
+ sortDirection !== secondarySortDirection)) {
+ clauses.push(getSortClause(secondarySortKey, secondarySortDirection, sortPredicates));
+ orders.push(secondarySortDirection === sortDirections.ASCENDING ? 'asc' : 'desc');
+ }
+
+ return _.orderBy(items, clauses, orders);
+}
+
+function createClientSideCollectionSelector() {
+ return createSelector(
+ (state, { section }) => _.get(state, section),
+ (state, { uiSection }) => _.get(state, uiSection),
+ (sectionState, uiSectionState = {}) => {
+ const state = Object.assign({}, sectionState, uiSectionState);
+
+ const filtered = filter(state.items, state);
+ const sorted = sort(filtered, state);
+
+ return {
+ ...sectionState,
+ ...uiSectionState,
+ items: sorted
+ };
+ }
+ );
+}
+
+export default createClientSideCollectionSelector;
diff --git a/frontend/src/Store/Selectors/createCommandExecutingSelector.js b/frontend/src/Store/Selectors/createCommandExecutingSelector.js
new file mode 100644
index 000000000..ac79194ad
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandExecutingSelector.js
@@ -0,0 +1,14 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createCommandsSelector from './createCommandsSelector';
+
+function createCommandExecutingSelector(name) {
+ return createSelector(
+ createCommandsSelector(),
+ (commands) => {
+ return _.some(commands, { name });
+ }
+ );
+}
+
+export default createCommandExecutingSelector;
diff --git a/frontend/src/Store/Selectors/createCommandSelector.js b/frontend/src/Store/Selectors/createCommandSelector.js
new file mode 100644
index 000000000..ff5bfe50a
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandSelector.js
@@ -0,0 +1,14 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createCommandsSelector from './createCommandsSelector';
+
+function createCommandSelector(name, contraints = {}) {
+ return createSelector(
+ createCommandsSelector(),
+ (commands) => {
+ return _.some(commands, { name, ...contraints });
+ }
+ );
+}
+
+export default createCommandSelector;
diff --git a/frontend/src/Store/Selectors/createCommandsSelector.js b/frontend/src/Store/Selectors/createCommandsSelector.js
new file mode 100644
index 000000000..7b9edffd9
--- /dev/null
+++ b/frontend/src/Store/Selectors/createCommandsSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createCommandsSelector() {
+ return createSelector(
+ (state) => state.commands,
+ (commands) => {
+ return commands.items;
+ }
+ );
+}
+
+export default createCommandsSelector;
diff --git a/frontend/src/Store/Selectors/createDimensionsSelector.js b/frontend/src/Store/Selectors/createDimensionsSelector.js
new file mode 100644
index 000000000..ce26b2e2c
--- /dev/null
+++ b/frontend/src/Store/Selectors/createDimensionsSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createDimensionsSelector() {
+ return createSelector(
+ (state) => state.app.dimensions,
+ (dimensions) => {
+ return dimensions;
+ }
+ );
+}
+
+export default createDimensionsSelector;
diff --git a/frontend/src/Store/Selectors/createEpisodeSelector.js b/frontend/src/Store/Selectors/createEpisodeSelector.js
new file mode 100644
index 000000000..9b987a581
--- /dev/null
+++ b/frontend/src/Store/Selectors/createEpisodeSelector.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import episodeEntities from 'Album/episodeEntities';
+
+function createEpisodeSelector() {
+ return createSelector(
+ (state, { albumId }) => albumId,
+ (state, { episodeEntity = episodeEntities.EPISODES }) => _.get(state, episodeEntity, { items: [] }),
+ (albumId, episodes) => {
+ return _.find(episodes.items, { id: albumId });
+ }
+ );
+}
+
+export default createEpisodeSelector;
diff --git a/frontend/src/Store/Selectors/createExistingArtistSelector.js b/frontend/src/Store/Selectors/createExistingArtistSelector.js
new file mode 100644
index 000000000..4811f2034
--- /dev/null
+++ b/frontend/src/Store/Selectors/createExistingArtistSelector.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from './createAllArtistSelector';
+
+function createExistingArtistSelector() {
+ return createSelector(
+ (state, { foreignArtistId }) => foreignArtistId,
+ createAllArtistSelector(),
+ (foreignArtistId, artist) => {
+ return _.some(artist, { foreignArtistId });
+ }
+ );
+}
+
+export default createExistingArtistSelector;
diff --git a/frontend/src/Store/Selectors/createImportArtistItemSelector.js b/frontend/src/Store/Selectors/createImportArtistItemSelector.js
new file mode 100644
index 000000000..6d72dc547
--- /dev/null
+++ b/frontend/src/Store/Selectors/createImportArtistItemSelector.js
@@ -0,0 +1,27 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from './createAllArtistSelector';
+
+function createImportArtistItemSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state) => state.addArtist,
+ (state) => state.importArtist,
+ createAllArtistSelector(),
+ (id, addArtist, importArtist, artist) => {
+ const item = _.find(importArtist.items, { id }) || {};
+ const selectedArtist = item && item.selectedArtist;
+ const isExistingArtist = !!selectedArtist && _.some(artist, { foreignArtistId: selectedArtist.foreignArtistId });
+
+ return {
+ defaultMonitor: addArtist.defaults.monitor,
+ defaultQualityProfileId: addArtist.defaults.qualityProfileId,
+ defaultAlbumFolder: addArtist.defaults.albumFolder,
+ ...item,
+ isExistingArtist
+ };
+ }
+ );
+}
+
+export default createImportArtistItemSelector;
diff --git a/frontend/src/Store/Selectors/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..309408459
--- /dev/null
+++ b/frontend/src/Store/Selectors/createProfileInUseSelector.js
@@ -0,0 +1,19 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import createAllArtistSelector from './createAllArtistSelector';
+
+function createProfileInUseSelector(profileProp) {
+ return createSelector(
+ (state, { id }) => id,
+ createAllArtistSelector(),
+ (id, artist) => {
+ if (!id) {
+ return false;
+ }
+
+ return _.some(artist, { [profileProp]: id });
+ }
+ );
+}
+
+export default createProfileInUseSelector;
diff --git a/frontend/src/Store/Selectors/createProviderSettingsSelector.js b/frontend/src/Store/Selectors/createProviderSettingsSelector.js
new file mode 100644
index 000000000..c1018b661
--- /dev/null
+++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.js
@@ -0,0 +1,59 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+
+function createProviderSettingsSelector() {
+ return createSelector(
+ (state, { id }) => id,
+ (state, { section }) => state.settings[section],
+ (id, section) => {
+ if (!id) {
+ const item = _.isArray(section.schema) ? section.selectedSchema : section.schema;
+ const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError);
+
+ const {
+ isFetchingSchema: isFetching,
+ schemaError: error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges
+ } = section;
+
+ return {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges,
+ ...settings,
+ item: settings.settings
+ };
+ }
+
+ const {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ pendingChanges
+ } = section;
+
+ const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError);
+
+ return {
+ isFetching,
+ error,
+ isSaving,
+ saveError,
+ isTesting,
+ item: settings.settings,
+ ...settings
+ };
+ }
+ );
+}
+
+export default createProviderSettingsSelector;
diff --git a/frontend/src/Store/Selectors/createQualityProfileSelector.js b/frontend/src/Store/Selectors/createQualityProfileSelector.js
new file mode 100644
index 000000000..9308d63ac
--- /dev/null
+++ b/frontend/src/Store/Selectors/createQualityProfileSelector.js
@@ -0,0 +1,14 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+
+function createQualityProfileSelector() {
+ return createSelector(
+ (state, { qualityProfileId }) => qualityProfileId,
+ (state) => state.settings.qualityProfiles.items,
+ (qualityProfileId, qualityProfiles) => {
+ return _.find(qualityProfiles, { id: qualityProfileId });
+ }
+ );
+}
+
+export default createQualityProfileSelector;
diff --git a/frontend/src/Store/Selectors/createQueueItemSelector.js b/frontend/src/Store/Selectors/createQueueItemSelector.js
new file mode 100644
index 000000000..2e902d7de
--- /dev/null
+++ b/frontend/src/Store/Selectors/createQueueItemSelector.js
@@ -0,0 +1,20 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+
+function createQueueItemSelector() {
+ return createSelector(
+ (state, { albumId }) => albumId,
+ (state) => state.queue.details,
+ (albumId, details) => {
+ if (!albumId) {
+ return null;
+ }
+
+ return _.find(details.items, (item) => {
+ return item.album.id === albumId;
+ });
+ }
+ );
+}
+
+export default createQueueItemSelector;
diff --git a/frontend/src/Store/Selectors/createSettingsSectionSelector.js b/frontend/src/Store/Selectors/createSettingsSectionSelector.js
new file mode 100644
index 000000000..7ff0d2708
--- /dev/null
+++ b/frontend/src/Store/Selectors/createSettingsSectionSelector.js
@@ -0,0 +1,32 @@
+import { createSelector } from 'reselect';
+import selectSettings from 'Store/Selectors/selectSettings';
+
+function createSettingsSectionSelector() {
+ return createSelector(
+ (state, { section }) => state.settings[section],
+ (sectionSettings) => {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ item,
+ pendingChanges,
+ isSaving,
+ saveError
+ } = sectionSettings;
+
+ const settings = selectSettings(item, pendingChanges, saveError);
+
+ return {
+ isFetching,
+ isPopulated,
+ error,
+ isSaving,
+ saveError,
+ ...settings
+ };
+ }
+ );
+}
+
+export default createSettingsSectionSelector;
diff --git a/frontend/src/Store/Selectors/createSystemStatusSelector.js b/frontend/src/Store/Selectors/createSystemStatusSelector.js
new file mode 100644
index 000000000..df586bbb9
--- /dev/null
+++ b/frontend/src/Store/Selectors/createSystemStatusSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createSystemStatusSelector() {
+ return createSelector(
+ (state) => state.system.status,
+ (status) => {
+ return status.item;
+ }
+ );
+}
+
+export default createSystemStatusSelector;
diff --git a/frontend/src/Store/Selectors/createTagsSelector.js b/frontend/src/Store/Selectors/createTagsSelector.js
new file mode 100644
index 000000000..fbfd91cdb
--- /dev/null
+++ b/frontend/src/Store/Selectors/createTagsSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createTagsSelector() {
+ return createSelector(
+ (state) => state.tags.items,
+ (tags) => {
+ return tags;
+ }
+ );
+}
+
+export default createTagsSelector;
diff --git a/frontend/src/Store/Selectors/createTrackFileSelector.js b/frontend/src/Store/Selectors/createTrackFileSelector.js
new file mode 100644
index 000000000..fb55fdc94
--- /dev/null
+++ b/frontend/src/Store/Selectors/createTrackFileSelector.js
@@ -0,0 +1,18 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+
+function createTrackFileSelector() {
+ return createSelector(
+ (state, { trackFileId }) => trackFileId,
+ (state) => state.trackFiles,
+ (trackFileId, trackFiles) => {
+ if (!trackFileId) {
+ return null;
+ }
+
+ return _.find(trackFiles.items, { id: trackFileId });
+ }
+ );
+}
+
+export default createTrackFileSelector;
diff --git a/frontend/src/Store/Selectors/createTrackSelector.js b/frontend/src/Store/Selectors/createTrackSelector.js
new file mode 100644
index 000000000..be57e6ca0
--- /dev/null
+++ b/frontend/src/Store/Selectors/createTrackSelector.js
@@ -0,0 +1,14 @@
+import _ from 'lodash';
+import { createSelector } from 'reselect';
+
+function createTrackSelector() {
+ return createSelector(
+ (state, { trackId }) => trackId,
+ (state) => state.tracks,
+ (trackId, tracks) => {
+ return _.find(tracks.items, { id: trackId });
+ }
+ );
+}
+
+export default createTrackSelector;
diff --git a/frontend/src/Store/Selectors/createUISettingsSelector.js b/frontend/src/Store/Selectors/createUISettingsSelector.js
new file mode 100644
index 000000000..b256d0e98
--- /dev/null
+++ b/frontend/src/Store/Selectors/createUISettingsSelector.js
@@ -0,0 +1,12 @@
+import { createSelector } from 'reselect';
+
+function createUISettingsSelector() {
+ return createSelector(
+ (state) => state.settings.ui,
+ (ui) => {
+ return ui.item;
+ }
+ );
+}
+
+export default createUISettingsSelector;
diff --git a/frontend/src/Store/Selectors/selectSettings.js b/frontend/src/Store/Selectors/selectSettings.js
new file mode 100644
index 000000000..74e5444c9
--- /dev/null
+++ b/frontend/src/Store/Selectors/selectSettings.js
@@ -0,0 +1,97 @@
+import _ from 'lodash';
+
+function getValidationFailures(saveError) {
+ if (!saveError || saveError.status !== 400) {
+ return [];
+ }
+
+ return _.cloneDeep(saveError.responseJSON);
+}
+
+function mapFailure(failure) {
+ return {
+ message: failure.errorMessage,
+ link: failure.infoLink,
+ detailedMessage: failure.detailedDescription
+ };
+}
+
+function selectSettings(item, pendingChanges, saveError) {
+ const validationFailures = getValidationFailures(saveError);
+
+ // Merge all settings from the item along with pending
+ // changes to ensure any settings that were not included
+ // with the item are included.
+ const allSettings = Object.assign({}, item, pendingChanges);
+
+ const settings = _.reduce(allSettings, (result, value, key) => {
+ if (key === 'fields') {
+ return result;
+ }
+
+ const setting = {
+ value: item[key],
+ errors: _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === key.toLowerCase() && !failure.isWarning;
+ }), mapFailure),
+
+ warnings: _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === key.toLowerCase() && failure.isWarning;
+ }), mapFailure)
+ };
+
+ if (pendingChanges.hasOwnProperty(key)) {
+ setting.previousValue = setting.value;
+ setting.value = pendingChanges[key];
+ setting.pending = true;
+ }
+
+ result[key] = setting;
+ return result;
+ }, {});
+
+ const fields = _.reduce(item.fields, (result, f) => {
+ const field = Object.assign({ pending: false }, f);
+ const hasPendingFieldChange = pendingChanges.fields && pendingChanges.fields.hasOwnProperty(field.name);
+
+ if (hasPendingFieldChange) {
+ field.previousValue = field.value;
+ field.value = pendingChanges.fields[field.name];
+ field.pending = true;
+ }
+
+ field.errors = _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === field.name.toLowerCase() && !failure.isWarning;
+ }), mapFailure);
+
+ field.warnings = _.map(_.remove(validationFailures, (failure) => {
+ return failure.propertyName.toLowerCase() === field.name.toLowerCase() && failure.isWarning;
+ }), mapFailure);
+
+ result.push(field);
+ return result;
+ }, []);
+
+ if (fields.length) {
+ settings.fields = fields;
+ }
+
+ const validationErrors = _.filter(validationFailures, (failure) => {
+ return !failure.isWarning;
+ });
+
+ const validationWarnings = _.filter(validationFailures, (failure) => {
+ return failure.isWarning;
+ });
+
+ return {
+ settings,
+ validationErrors,
+ validationWarnings,
+ hasPendingChanges: !_.isEmpty(pendingChanges),
+ hasSettings: !_.isEmpty(settings),
+ pendingChanges
+ };
+}
+
+export default selectSettings;
diff --git a/frontend/src/Store/connectSection.js b/frontend/src/Store/connectSection.js
new file mode 100644
index 000000000..5d309cc5e
--- /dev/null
+++ b/frontend/src/Store/connectSection.js
@@ -0,0 +1,57 @@
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import getDisplayName from 'Helpers/getDisplayName';
+
+function connectSection(mapStateToProps, mapDispatchToProps, mergeProps, options = {}, sectionOptions = {}) {
+ return function wrap(WrappedComponent) {
+ const ConnectedComponent = connect(mapStateToProps, mapDispatchToProps, mergeProps, options)(WrappedComponent);
+
+ class Section extends Component {
+
+ //
+ // Control
+
+ getWrappedInstance = () => {
+ if (this._wrappedInstance) {
+ return this._wrappedInstance.getWrappedInstance();
+ }
+ }
+
+ //
+ // Listeners
+
+ setWrappedInstanceRef = (ref) => {
+ this._wrappedInstance = ref;
+ }
+
+ //
+ // Render
+
+ render() {
+ if (options.withRef) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+ }
+
+ Section.displayName = `Section(${getDisplayName(WrappedComponent)})`;
+ Section.WrappedComponent = WrappedComponent;
+
+ return Section;
+ };
+}
+
+export default connectSection;
diff --git a/frontend/src/Store/createAppStore.js b/frontend/src/Store/createAppStore.js
new file mode 100644
index 000000000..168bbc954
--- /dev/null
+++ b/frontend/src/Store/createAppStore.js
@@ -0,0 +1,15 @@
+import { createStore } from 'redux';
+import reducers, { defaultState } from 'Store/Reducers';
+import middlewares from 'Store/Middleware/middlewares';
+
+function createAppStore(history) {
+ const appStore = createStore(
+ reducers,
+ defaultState,
+ middlewares(history)
+ );
+
+ return appStore;
+}
+
+export default createAppStore;
diff --git a/frontend/src/Store/scrollPositions.js b/frontend/src/Store/scrollPositions.js
new file mode 100644
index 000000000..287a58593
--- /dev/null
+++ b/frontend/src/Store/scrollPositions.js
@@ -0,0 +1,5 @@
+const scrollPositions = {
+ artistIndex: 0
+};
+
+export default scrollPositions;
diff --git a/src/UI/Shared/Styles/clickable.less b/frontend/src/Styles/Mixins/clickable.css
similarity index 100%
rename from src/UI/Shared/Styles/clickable.less
rename to frontend/src/Styles/Mixins/clickable.css
diff --git a/frontend/src/Styles/Mixins/cover.css b/frontend/src/Styles/Mixins/cover.css
new file mode 100644
index 000000000..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..d0762a029
--- /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..402358e66
--- /dev/null
+++ b/frontend/src/Styles/Variables/colors.js
@@ -0,0 +1,172 @@
+module.exports = {
+ defaultColor: '#333',
+ disabledColor: '#999',
+ dimColor: '#555',
+ black: '#000',
+ white: '#fff',
+ primaryColor: '#0b8750',
+ selectedColor: '#f9be03',
+ successColor: '#27c24c',
+ dangerColor: '#f05050',
+ warningColor: '#ffa500',
+ infoColor: '#00A65B',
+ purple: '#7a43b6',
+ nzbdronePurple: '#7932ea',
+ nzbdronePink: '#f43565',
+ sonarrBlue: '#00A65B',
+ helpTextColor: '#909293',
+ gray: '#adadad',
+
+ // Theme Colors
+
+ themeBlue: '#00A65B',
+ themeRed: '#c4273c',
+ themeDarkColor: '#216044',
+ themeLightColor: '#216044',
+
+ torrentColor: '#00853d',
+ usenetColor: '#17b1d9',
+
+ // Links
+ defaultLinkHoverColor: '#fff',
+ linkColor: '#0b8750',
+ linkHoverColor: '#1b72e2',
+
+ // Sidebar
+
+ sidebarColor: '#e1e2e3',
+ sidebarBackgroundColor: '#216044',
+ sidebarActiveBackgroundColor: '#353535',
+
+ // Toolbar
+ toolbarColor: '#e1e2e3',
+ toolbarBackgroundColor: '#216044',
+ toolbarMenuItemBackgroundColor: '#4D8069',
+ toolbarMenuItemHoverBackgroundColor: '#216044',
+ toolbarLabelColor: '#8895aa',
+
+ // Accents
+ borderColor: '#e5e5e5',
+ inputBorderColor: '#dde6e9',
+ inputBoxShadowColor: 'rgba(0, 0, 0, 0.075)',
+ inputFocusBorderColor: '#66afe9',
+ inputFocusBoxShadowColor: 'rgba(102, 175, 233, 0.6)',
+ inputErrorBorderColor: '#f05050',
+ inputErrorBoxShadowColor: 'rgba(240, 80, 80, 0.6)',
+ inputWarningBorderColor: '#ffa500',
+ inputWarningBoxShadowColor: 'rgba(255, 165, 0, 0.6)',
+
+ //
+ // Buttons
+
+ defaultBackgroundColor: '#fff',
+ defaultBorderColor: '#eaeaea',
+ defaultHoverBackgroundColor: '#f5f5f5',
+ defaultHoverBorderColor: '#d6d6d6;',
+
+ primaryBackgroundColor: '#0b8750',
+ primaryBorderColor: '#216044',
+ primaryHoverBackgroundColor: '#097948',
+ primaryHoverBorderColor: '#1D563D;',
+
+ successBackgroundColor: '#27c24c',
+ successBorderColor: '#26be4a',
+ successHoverBackgroundColor: '#24b145',
+ successHoverBorderColor: '#1f9c3d;',
+
+ warningBackgroundColor: '#ff902b',
+ warningBorderColor: '#ff8d26',
+ warningHoverBackgroundColor: '#ff8517',
+ warningHoverBorderColor: '#fc7800;',
+
+ dangerBackgroundColor: '#f05050',
+ dangerBorderColor: '#f04b4b',
+ dangerHoverBackgroundColor: '#ee3d3d',
+ dangerHoverBorderColor: '#ec2626;',
+
+ iconButtonHoverColor: '#666',
+ iconButtonHoverLightColor: '#ccc',
+
+ //
+ // Modal
+
+ modalBackdropBackgroundColor: 'rgba(0, 0, 0, 0.6)',
+ modalBackgroundColor: '#fff',
+ modalCloseButtonHoverColor: '#888',
+
+ //
+ // Menu
+ menuItemColor: '#e1e2e3',
+ menuItemHoverColor: '#fbfcfc',
+
+ //
+ // Toolbar
+
+ toobarButtonHoverColor: '#00A65B',
+ toobarButtonSelectedColor: '#00A65B',
+
+ //
+ // Scroller
+
+ scrollbarBackgroundColor: '#9ea4b9',
+ scrollbarHoverBackgroundColor: '#656d8c',
+
+ //
+ // Card
+
+ cardShadowColor: '#e1e1e1',
+ cardAlternateBackgroundColor: '#f5f5f5',
+
+ //
+ // Alert
+
+ alertDangerBorderColor: '#ebccd1',
+ alertDangerBackgroundColor: '#f2dede',
+ alertDangerColor: '#a94442',
+
+ alertInfoBorderColor: '#bce8f1',
+ alertInfoBackgroundColor: '#d9edf7',
+ alertInfoColor: '#31708f',
+
+ alertSuccessBorderColor: '#d6e9c6',
+ alertSuccessBackgroundColor: '#dff0d8',
+ alertSuccessColor: '#3c763d',
+
+ alertWarningBorderColor: '#faebcc',
+ alertWarningBackgroundColor: '#fcf8e3',
+ alertWarningColor: '#8a6d3b',
+
+ //
+ // Slider
+
+ sliderAccentColor: '#0b8750',
+
+ //
+ // Form
+
+ advancedFormLabelColor: '#ff902b',
+ disabledCheckInputColor: '#ddd',
+
+ //
+ // Popover
+
+ popoverTitleBackgroundColor: '#f7f7f7',
+ popoverTitleBorderColor: '#ebebeb',
+ popoverShadowColor: 'rgba(0, 0, 0, 0.2)',
+ popoverArrowBorderColor: 'rgba(0, 0, 0, 0.25)',
+
+ popoverTitleBackgroundInverseColor: '#3a3f51',
+ popoverTitleBorderInverseColor: '#216044',
+ popoverShadowInverseColor: 'rgba(0, 0, 0, 0.2)',
+ popoverArrowBorderInverseColor: 'rgba(58, 63, 81, 0.75)',
+
+ //
+ // Calendar
+
+ calendarTodayBackgroundColor: '#ddd',
+
+ //
+ // Table
+
+ tableRowHoverBackgroundColor: '#fafbfc'
+};
diff --git a/frontend/src/Styles/Variables/dimensions.js b/frontend/src/Styles/Variables/dimensions.js
new file mode 100644
index 000000000..54282902c
--- /dev/null
+++ b/frontend/src/Styles/Variables/dimensions.js
@@ -0,0 +1,45 @@
+module.exports = {
+ // Page
+ pageContentBodyPadding: '20px',
+ pageContentBodyPaddingSmallScreen: '10px',
+
+ // Header
+ headerHeight: '60px',
+
+ // Sidebar
+ sidebarWidth: '210px',
+
+ // Toolbar
+ toolbarHeight: '60px',
+ toolbarButtonWidth: '60px',
+ toolbarSeparatorMargin: '20px',
+
+ // Break Points
+ breakpointExtraSmall: '480px',
+ breakpointSmall: '768px',
+ breakpointMedium: '992px',
+ breakpointLarge: '1200px',
+
+ // Form
+ formGroupSmallWidth: '650px',
+ formGroupMediumWidth: '800px',
+ formGroupLargeWidth: '1200px',
+ formLabelWidth: '250px',
+ formLabelRightMarginWidth: '20px',
+
+ // Drag
+ dragHandleWidth: '40px',
+
+ // Progress Bar
+ progressBarSmallHeight: '5px',
+ progressBarMediumHeight: '15px',
+ progressBarLargeHeight: '20px',
+
+ // Jump Bar
+ jumpBarItemHeight: '25px',
+
+ // Artist
+ artistIndexColumnPadding: '20px',
+ artistIndexColumnPaddingSmallScreen: '10px',
+ artistIndexOverviewInfoRowHeight: '21px'
+};
diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js
new file mode 100644
index 000000000..6221ce5f1
--- /dev/null
+++ b/frontend/src/Styles/Variables/fonts.js
@@ -0,0 +1,13 @@
+module.exports = {
+ // Families
+ defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
+ monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;',
+
+ // Sizes
+ extraSmallFontSize: '11px',
+ smallFontSize: '12px',
+ defaultFontSize: '14px',
+ largeFontSize: '16px',
+
+ lineHeight: '1.528571429'
+};
diff --git a/frontend/src/Styles/globals.css b/frontend/src/Styles/globals.css
new file mode 100644
index 000000000..83e7649ae
--- /dev/null
+++ b/frontend/src/Styles/globals.css
@@ -0,0 +1,8 @@
+/* stylelint-disable */
+
+@import '~normalize.css/normalize.css';
+@import 'scaffolding.css';
+@import '../Content/Fonts/fonts.css';
+@import '../Content/Fonts/font-awesome.css';
+
+/* stylelint-enable */
\ No newline at end of file
diff --git a/frontend/src/Styles/scaffolding.css b/frontend/src/Styles/scaffolding.css
new file mode 100644
index 000000000..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/Backups.css b/frontend/src/System/Backup/Backups.css
new file mode 100644
index 000000000..fb280312c
--- /dev/null
+++ b/frontend/src/System/Backup/Backups.css
@@ -0,0 +1,5 @@
+.type {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+}
diff --git a/frontend/src/System/Backup/Backups.js b/frontend/src/System/Backup/Backups.js
new file mode 100644
index 000000000..02b8c3166
--- /dev/null
+++ b/frontend/src/System/Backup/Backups.js
@@ -0,0 +1,148 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import styles from './Backups.css';
+
+const columns = [
+ {
+ name: 'type',
+ isVisible: true
+ },
+ {
+ name: 'name',
+ label: 'Name',
+ isVisible: true
+ },
+ {
+ name: 'time',
+ label: 'Time',
+ isVisible: true
+ }
+];
+
+class Backups extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ items,
+ backupExecuting,
+ onBackupPress
+ } = this.props;
+
+ const hasBackups = !isFetching && items.length > 0;
+ const noBackups = !isFetching && !items.length;
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ noBackups &&
+ No backups are available
+ }
+
+ {
+ hasBackups &&
+
+
+ {
+ items.map((item) => {
+ const {
+ id,
+ type,
+ name,
+ path,
+ time
+ } = item;
+
+ let iconClassName = icons.SCHEDULED;
+ let iconTooltip = 'Scheduled';
+
+ if (type === 'manual') {
+ iconClassName = icons.INTERACTIVE;
+ iconTooltip = 'Manual';
+ } else if (item === 'update') {
+ iconClassName = icons.UPDATE;
+ iconTooltip = 'Before update';
+ }
+
+ return (
+
+
+ {
+
+ }
+
+
+
+
+ {name}
+
+
+
+
+
+ );
+ })
+ }
+
+
+ }
+
+
+ );
+ }
+
+}
+
+Backups.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired,
+ backupExecuting: PropTypes.bool.isRequired,
+ onBackupPress: PropTypes.func.isRequired
+};
+
+export default Backups;
diff --git a/frontend/src/System/Backup/BackupsConnector.js b/frontend/src/System/Backup/BackupsConnector.js
new file mode 100644
index 000000000..f6d5469eb
--- /dev/null
+++ b/frontend/src/System/Backup/BackupsConnector.js
@@ -0,0 +1,81 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { fetchBackups } from 'Store/Actions/systemActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as commandNames from 'Commands/commandNames';
+import Backups from './Backups';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.backups,
+ createCommandsSelector(),
+ (backups, commands) => {
+ const {
+ isFetching,
+ items
+ } = backups;
+
+ const backupExecuting = _.some(commands, { name: commandNames.BACKUP });
+
+ return {
+ isFetching,
+ items,
+ backupExecuting
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchBackups,
+ executeCommand
+};
+
+class BackupsConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchBackups();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.backupExecuting && !this.props.backupExecuting) {
+ this.props.fetchBackups();
+ }
+ }
+
+ //
+ // Listeners
+
+ onBackupPress = () => {
+ this.props.executeCommand({
+ name: commandNames.BACKUP
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+BackupsConnector.propTypes = {
+ backupExecuting: PropTypes.bool.isRequired,
+ fetchBackups: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(BackupsConnector);
diff --git a/frontend/src/System/Events/LogsTable.js b/frontend/src/System/Events/LogsTable.js
new file mode 100644
index 000000000..7ed1f6cf1
--- /dev/null
+++ b/frontend/src/System/Events/LogsTable.js
@@ -0,0 +1,176 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { align, icons } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TablePager from 'Components/Table/TablePager';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import MenuContent from 'Components/Menu/MenuContent';
+import LogsTableRow from './LogsTableRow';
+
+class LogsTable extends Component {
+
+ //
+ // Listeners
+
+ onFilterMenuItemPress = (filterKey, filterValue) => {
+ this.props.onFilterSelect(filterKey, filterValue);
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ filterKey,
+ filterValue,
+ totalRecords,
+ clearLogExecuting,
+ onRefreshPress,
+ onClearLogsPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ All
+
+
+
+ Info
+
+
+
+ Warn
+
+
+
+ Error
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ No logs found
+
+ }
+
+ {
+ isPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+ }
+
+
+ );
+ }
+
+}
+
+LogsTable.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.string,
+ totalRecords: PropTypes.number,
+ clearLogExecuting: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onRefreshPress: PropTypes.func.isRequired,
+ onClearLogsPress: PropTypes.func.isRequired
+};
+
+export default LogsTable;
diff --git a/frontend/src/System/Events/LogsTableConnector.js b/frontend/src/System/Events/LogsTableConnector.js
new file mode 100644
index 000000000..428ea90fd
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableConnector.js
@@ -0,0 +1,130 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import * as systemActions from 'Store/Actions/systemActions';
+import * as commandNames from 'Commands/commandNames';
+import LogsTable from './LogsTable';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.logs,
+ createCommandsSelector(),
+ (logs, commands) => {
+ const clearLogExecuting = _.some(commands, { name: commandNames.CLEAR_LOGS });
+
+ return {
+ clearLogExecuting,
+ ...logs
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ executeCommand,
+ ...systemActions
+};
+
+class LogsTableConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchLogs();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.clearLogExecuting && !this.props.clearLogExecuting) {
+ this.props.gotoLogsFirstPage();
+ }
+ }
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoLogsFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoLogsPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoLogsNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoLogsLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoLogsPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setLogsSort({ sortKey });
+ }
+
+ onFilterSelect = (filterKey, filterValue) => {
+ this.props.setLogsFilter({ filterKey, filterValue });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setLogsTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoLogsFirstPage();
+ }
+ }
+
+ onRefreshPress = () => {
+ this.props.gotoLogsFirstPage();
+ }
+
+ onClearLogsPress = () => {
+ this.props.executeCommand({ name: commandNames.CLEAR_LOGS });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+LogsTableConnector.propTypes = {
+ clearLogExecuting: PropTypes.bool.isRequired,
+ fetchLogs: PropTypes.func.isRequired,
+ gotoLogsFirstPage: PropTypes.func.isRequired,
+ gotoLogsPreviousPage: PropTypes.func.isRequired,
+ gotoLogsNextPage: PropTypes.func.isRequired,
+ gotoLogsLastPage: PropTypes.func.isRequired,
+ gotoLogsPage: PropTypes.func.isRequired,
+ setLogsSort: PropTypes.func.isRequired,
+ setLogsFilter: PropTypes.func.isRequired,
+ setLogsTableOption: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(LogsTableConnector);
diff --git a/frontend/src/System/Events/LogsTableDetailsModal.css b/frontend/src/System/Events/LogsTableDetailsModal.css
new file mode 100644
index 000000000..127c1139f
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableDetailsModal.css
@@ -0,0 +1,17 @@
+.detailsText {
+ composes: scroller from 'Components/Scroller/Scroller.css';
+
+ display: block;
+ margin: 0 0 10.5px;
+ padding: 10px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ background-color: #f5f5f5;
+ color: #3a3f51;
+ white-space: pre;
+ word-wrap: break-word;
+ word-break: break-all;
+ font-size: 13px;
+ font-family: $monoSpaceFontFamily;
+ line-height: 1.52857143;
+}
diff --git a/frontend/src/System/Events/LogsTableDetailsModal.js b/frontend/src/System/Events/LogsTableDetailsModal.js
new file mode 100644
index 000000000..de6a881df
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableDetailsModal.js
@@ -0,0 +1,74 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { scrollDirections } from 'Helpers/Props';
+import Button from 'Components/Link/Button';
+import Scroller from 'Components/Scroller/Scroller';
+import Modal from 'Components/Modal/Modal';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import styles from './LogsTableDetailsModal.css';
+
+function LogsTableDetailsModal(props) {
+ const {
+ isOpen,
+ message,
+ exception,
+ onModalClose
+ } = props;
+
+ return (
+
+
+
+ Details
+
+
+
+ Message
+
+
+ {message}
+
+
+ {
+ !!exception &&
+
+
Exception
+
+ {exception}
+
+
+ }
+
+
+
+
+ Close
+
+
+
+
+ );
+}
+
+LogsTableDetailsModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ message: PropTypes.string.isRequired,
+ exception: PropTypes.string,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default LogsTableDetailsModal;
diff --git a/frontend/src/System/Events/LogsTableRow.css b/frontend/src/System/Events/LogsTableRow.css
new file mode 100644
index 000000000..557d690f0
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableRow.css
@@ -0,0 +1,35 @@
+.level {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+}
+
+.info {
+ color: #1e90ff;
+}
+
+.debug {
+ color: #808080;
+}
+
+.trace {
+ color: #d3d3d3;
+}
+
+.warn {
+ color: $warningColor;
+}
+
+.error {
+ color: $dangerColor;
+}
+
+.fatal {
+ color: $purple;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 45px;
+}
diff --git a/frontend/src/System/Events/LogsTableRow.js b/frontend/src/System/Events/LogsTableRow.js
new file mode 100644
index 000000000..6321c5748
--- /dev/null
+++ b/frontend/src/System/Events/LogsTableRow.js
@@ -0,0 +1,152 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { icons } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRowButton from 'Components/Table/TableRowButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import LogsTableDetailsModal from './LogsTableDetailsModal';
+import styles from './LogsTableRow.css';
+
+function getIconName(level) {
+ switch (level) {
+ case 'trace':
+ case 'debug':
+ case 'info':
+ return icons.INFO;
+ case 'warn':
+ return icons.DANGER;
+ case 'error':
+ return icons.BUG;
+ case 'fatal':
+ return icons.FATAL;
+ default:
+ return icons.UNKNOWN;
+ }
+}
+
+class LogsTableRow extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isDetailsModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onPress = () => {
+ this.setState({ isDetailsModalOpen: true });
+ }
+
+ onModalClose = () => {
+ this.setState({ isDetailsModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ level,
+ logger,
+ message,
+ time,
+ exception,
+ columns
+ } = this.props;
+
+ return (
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'level') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'logger') {
+ return (
+
+ {logger}
+
+ );
+ }
+
+ if (name === 'message') {
+ return (
+
+ {message}
+
+ );
+ }
+
+ if (name === 'time') {
+ return (
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ 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..16d8d23b9
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesConnector.js
@@ -0,0 +1,93 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import combinePath from 'Utilities/String/combinePath';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchLogFiles } from 'Store/Actions/systemActions';
+import * as commandNames from 'Commands/commandNames';
+import LogFiles from './LogFiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.logFiles,
+ (state) => state.system.status.item,
+ createCommandsSelector(),
+ (logFiles, status, commands) => {
+ const {
+ isFetching,
+ items
+ } = logFiles;
+
+ const {
+ appData,
+ isWindows
+ } = status;
+
+ const deleteFilesExecuting = _.some(commands, { name: commandNames.DELETE_LOG_FILES });
+
+ return {
+ isFetching,
+ items,
+ deleteFilesExecuting,
+ currentLogView: 'Log Files',
+ location: combinePath(isWindows, appData, ['logs'])
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchLogFiles,
+ executeCommand
+};
+
+class LogFilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchLogFiles();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) {
+ this.props.fetchLogFiles();
+ }
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ this.props.fetchLogFiles();
+ }
+
+ onDeleteFilesPress = () => {
+ this.props.executeCommand({ name: commandNames.DELETE_LOG_FILES });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+LogFilesConnector.propTypes = {
+ deleteFilesExecuting: PropTypes.bool.isRequired,
+ fetchLogFiles: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(LogFilesConnector);
diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.css b/frontend/src/System/Logs/Files/LogFilesTableRow.css
new file mode 100644
index 000000000..779794b7d
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesTableRow.css
@@ -0,0 +1,5 @@
+.download {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/System/Logs/Files/LogFilesTableRow.js b/frontend/src/System/Logs/Files/LogFilesTableRow.js
new file mode 100644
index 000000000..7ae61a531
--- /dev/null
+++ b/frontend/src/System/Logs/Files/LogFilesTableRow.js
@@ -0,0 +1,50 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './LogFilesTableRow.css';
+
+class LogFilesTableRow extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ filename,
+ lastWriteTime,
+ downloadUrl
+ } = this.props;
+
+ return (
+
+ {filename}
+
+
+
+
+
+ Download
+
+
+
+ );
+ }
+
+}
+
+LogFilesTableRow.propTypes = {
+ filename: PropTypes.string.isRequired,
+ lastWriteTime: PropTypes.string.isRequired,
+ downloadUrl: PropTypes.string.isRequired
+};
+
+export default LogFilesTableRow;
diff --git a/frontend/src/System/Logs/Logs.js b/frontend/src/System/Logs/Logs.js
new file mode 100644
index 000000000..fa0be453e
--- /dev/null
+++ b/frontend/src/System/Logs/Logs.js
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+import { Route } from 'react-router-dom';
+import Switch from 'Components/Router/Switch';
+import LogFilesConnector from './Files/LogFilesConnector';
+import UpdateLogFilesConnector from './Updates/UpdateLogFilesConnector';
+
+class Logs extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+export default Logs;
diff --git a/frontend/src/System/Logs/LogsNavMenu.js b/frontend/src/System/Logs/LogsNavMenu.js
new file mode 100644
index 000000000..b69630248
--- /dev/null
+++ b/frontend/src/System/Logs/LogsNavMenu.js
@@ -0,0 +1,71 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Menu from 'Components/Menu/Menu';
+import MenuButton from 'Components/Menu/MenuButton';
+import MenuContent from 'Components/Menu/MenuContent';
+import MenuItem from 'Components/Menu/MenuItem';
+
+class LogsNavMenu extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isMenuOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onMenuButtonPress = () => {
+ this.setState({ isMenuOpen: !this.state.isMenuOpen });
+ }
+
+ onMenuItemPress = () => {
+ this.setState({ isMenuOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ current
+ } = this.props;
+
+ return (
+
+
+ {current}
+
+
+
+ Log Files
+
+
+
+ Updater Log Files
+
+
+
+ );
+ }
+}
+
+LogsNavMenu.propTypes = {
+ current: PropTypes.string.isRequired
+};
+
+export default LogsNavMenu;
diff --git a/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js
new file mode 100644
index 000000000..d49b82152
--- /dev/null
+++ b/frontend/src/System/Logs/Updates/UpdateLogFilesConnector.js
@@ -0,0 +1,93 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import combinePath from 'Utilities/String/combinePath';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchUpdateLogFiles } from 'Store/Actions/systemActions';
+import * as commandNames from 'Commands/commandNames';
+import LogFiles from '../Files/LogFiles';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.updateLogFiles,
+ (state) => state.system.status.item,
+ createCommandsSelector(),
+ (updateLogFiles, status, commands) => {
+ const {
+ isFetching,
+ items
+ } = updateLogFiles;
+
+ const deleteFilesExecuting = _.some(commands, { name: commandNames.DELETE_UPDATE_LOG_FILES });
+
+ const {
+ appData,
+ isWindows
+ } = status;
+
+ return {
+ isFetching,
+ items,
+ deleteFilesExecuting,
+ currentLogView: 'Updater Log Files',
+ location: combinePath(isWindows, appData, ['UpdateLogs'])
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchUpdateLogFiles,
+ executeCommand
+};
+
+class UpdateLogFilesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchUpdateLogFiles();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.deleteFilesExecuting && !this.props.deleteFilesExecuting) {
+ this.props.fetchUpdateLogFiles();
+ }
+ }
+
+ //
+ // Listeners
+
+ onRefreshPress = () => {
+ this.props.fetchUpdateLogFiles();
+ }
+
+ onDeleteFilesPress = () => {
+ this.props.executeCommand({ name: commandNames.DELETE_UPDATE_LOG_FILES });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+UpdateLogFilesConnector.propTypes = {
+ deleteFilesExecuting: PropTypes.bool.isRequired,
+ fetchUpdateLogFiles: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(UpdateLogFilesConnector);
diff --git a/frontend/src/System/Status/About/About.js b/frontend/src/System/Status/About/About.js
new file mode 100644
index 000000000..51c0afde6
--- /dev/null
+++ b/frontend/src/System/Status/About/About.js
@@ -0,0 +1,71 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+import FieldSet from 'Components/FieldSet';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+
+class About extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ version,
+ isMonoRuntime,
+ runtimeVersion,
+ appData,
+ startupPath,
+ mode
+ } = this.props;
+
+ return (
+
+
+
+
+ {
+ isMonoRuntime &&
+
+ }
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+About.propTypes = {
+ version: PropTypes.string,
+ isMonoRuntime: PropTypes.bool,
+ runtimeVersion: PropTypes.string,
+ appData: PropTypes.string,
+ startupPath: PropTypes.string,
+ mode: PropTypes.string
+};
+
+export default About;
diff --git a/frontend/src/System/Status/About/AboutConnector.js b/frontend/src/System/Status/About/AboutConnector.js
new file mode 100644
index 000000000..8d5c2ce0f
--- /dev/null
+++ b/frontend/src/System/Status/About/AboutConnector.js
@@ -0,0 +1,48 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchStatus } from 'Store/Actions/systemActions';
+import About from './About';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.status,
+ (status) => {
+ return {
+ ...status.item
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchStatus
+};
+
+class AboutConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchStatus();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+AboutConnector.propTypes = {
+ fetchStatus: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(AboutConnector);
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.css b/frontend/src/System/Status/DiskSpace/DiskSpace.css
new file mode 100644
index 000000000..70ef6f884
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpace.css
@@ -0,0 +1,5 @@
+.space {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpace.js b/frontend/src/System/Status/DiskSpace/DiskSpace.js
new file mode 100644
index 000000000..d2c10706e
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpace.js
@@ -0,0 +1,122 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds, sizes } from 'Helpers/Props';
+import formatBytes from 'Utilities/Number/formatBytes';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import ProgressBar from 'Components/ProgressBar';
+import styles from './DiskSpace.css';
+
+const columns = [
+ {
+ name: 'path',
+ label: 'Location',
+ isVisible: true
+ },
+ {
+ name: 'freeSpace',
+ label: 'Free Space',
+ isVisible: true
+ },
+ {
+ name: 'totalSpace',
+ label: 'Total Space',
+ isVisible: true
+ },
+ {
+ name: 'progress',
+ isVisible: true
+ }
+];
+
+class DiskSpace extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ items
+ } = this.props;
+
+ return (
+
+ {
+ isFetching &&
+
+ }
+
+ {
+ !isFetching &&
+
+
+ {
+ items.map((item) => {
+ const {
+ freeSpace,
+ totalSpace
+ } = item;
+
+ const diskUsage = (100 - freeSpace / totalSpace * 100);
+ let diskUsageKind = kinds.PRIMARY;
+
+ if (diskUsage > 90) {
+ diskUsageKind = kinds.DANGER;
+ } else if (diskUsage > 80) {
+ diskUsageKind = kinds.WARNING;
+ }
+
+ return (
+
+
+ {item.path}
+
+ {
+ item.label &&
+ ` (${item.label})`
+ }
+
+
+
+ {formatBytes(freeSpace)}
+
+
+
+ {formatBytes(totalSpace)}
+
+
+
+
+
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+ }
+
+}
+
+DiskSpace.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired
+};
+
+export default DiskSpace;
diff --git a/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js
new file mode 100644
index 000000000..3049b2ead
--- /dev/null
+++ b/frontend/src/System/Status/DiskSpace/DiskSpaceConnector.js
@@ -0,0 +1,54 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchDiskSpace } from 'Store/Actions/systemActions';
+import DiskSpace from './DiskSpace';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.diskSpace,
+ (diskSpace) => {
+ const {
+ isFetching,
+ items
+ } = diskSpace;
+
+ return {
+ isFetching,
+ items
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchDiskSpace
+};
+
+class DiskSpaceConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchDiskSpace();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DiskSpaceConnector.propTypes = {
+ fetchDiskSpace: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(DiskSpaceConnector);
diff --git a/frontend/src/System/Status/Health/Health.css b/frontend/src/System/Status/Health/Health.css
new file mode 100644
index 000000000..1aad8ee77
--- /dev/null
+++ b/frontend/src/System/Status/Health/Health.css
@@ -0,0 +1,21 @@
+.legend {
+ display: flex;
+ justify-content: space-between;
+}
+
+.loading {
+ composes: loading from 'Components/Loading/LoadingIndicator.css';
+
+ margin-top: 2px;
+ margin-left: 10px;
+ text-align: left;
+}
+
+.status {
+ width: 20px;
+}
+
+.healthOk {
+ margin-bottom: 25px;
+}
+
diff --git a/frontend/src/System/Status/Health/Health.js b/frontend/src/System/Status/Health/Health.js
new file mode 100644
index 000000000..ff2165048
--- /dev/null
+++ b/frontend/src/System/Status/Health/Health.js
@@ -0,0 +1,170 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import titleCase from 'Utilities/String/titleCase';
+import { icons, kinds } from 'Helpers/Props';
+import Icon from 'Components/Icon';
+import Link from 'Components/Link/Link';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FieldSet from 'Components/FieldSet';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './Health.css';
+
+function getInternalLink(source) {
+ switch (source) {
+ case 'IndexerRssCheck':
+ case 'IndexerSearchCheck':
+ case 'IndexerStatusCheck':
+ return (
+
+ Settings
+
+ );
+ case 'DownloadClientCheck':
+ case 'ImportMechanismCheck':
+ return (
+
+ Settings
+
+ );
+ case 'RootFolderCheck':
+ return (
+
+
+ Artist Editor
+
+
+ );
+ case 'UpdateCheck':
+ return (
+
+ Updates
+
+ );
+ default:
+ return;
+ }
+}
+
+const columns = [
+ {
+ className: styles.status,
+ name: 'type',
+ isVisible: true
+ },
+ {
+ name: 'message',
+ label: 'Message',
+ isVisible: true
+ },
+ {
+ name: 'wikiLink',
+ label: 'Wiki',
+ isVisible: true
+ },
+ {
+ name: 'internalLink',
+ isVisible: true
+ }
+];
+
+class Health extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = this.props;
+
+ const healthIssues = !!items.length;
+
+ return (
+
+ Health
+
+ {
+ isFetching && isPopulated &&
+
+ }
+
+ }
+ >
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !healthIssues &&
+
+ No issues with your configuration
+
+ }
+
+ {
+ healthIssues &&
+
+
+ {
+ items.map((item) => {
+ const internalLink = getInternalLink(item.source);
+
+ return (
+
+
+
+
+
+ {item.message}
+
+
+
+ Wiki
+
+
+
+
+ {
+ internalLink
+ }
+
+
+ );
+ })
+ }
+
+
+ }
+
+ );
+ }
+
+}
+
+Health.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired
+};
+
+export default Health;
diff --git a/frontend/src/System/Status/Health/HealthConnector.js b/frontend/src/System/Status/Health/HealthConnector.js
new file mode 100644
index 000000000..67f6a39dc
--- /dev/null
+++ b/frontend/src/System/Status/Health/HealthConnector.js
@@ -0,0 +1,56 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchHealth } from 'Store/Actions/systemActions';
+import Health from './Health';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.health,
+ (health) => {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = health;
+
+ return {
+ isFetching,
+ isPopulated,
+ items
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchHealth
+};
+
+class HealthConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchHealth();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+HealthConnector.propTypes = {
+ fetchHealth: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(HealthConnector);
diff --git a/frontend/src/System/Status/Health/HealthStatusConnector.js b/frontend/src/System/Status/Health/HealthStatusConnector.js
new file mode 100644
index 000000000..181eae916
--- /dev/null
+++ b/frontend/src/System/Status/Health/HealthStatusConnector.js
@@ -0,0 +1,79 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchHealth } from 'Store/Actions/systemActions';
+import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.app,
+ (state) => state.system.health,
+ (app, health) => {
+ const count = health.items.length;
+ let errors = false;
+ let warnings = false;
+
+ health.items.forEach((item) => {
+ if (item.type === 'error') {
+ errors = true;
+ }
+
+ if (item.type === 'warning') {
+ warnings = true;
+ }
+ });
+
+ return {
+ isConnected: app.isConnected,
+ isReconnecting: app.isReconnecting,
+ isPopulated: health.isPopulated,
+ count,
+ errors,
+ warnings
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchHealth
+};
+
+class HealthStatusConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ if (!this.props.isPopulated) {
+ this.props.fetchHealth();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.isConnected && prevProps.isReconnecting) {
+ this.props.fetchHealth();
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+HealthStatusConnector.propTypes = {
+ isConnected: PropTypes.bool.isRequired,
+ isReconnecting: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ fetchHealth: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(HealthStatusConnector);
diff --git a/frontend/src/System/Status/MoreInfo/MoreInfo.js b/frontend/src/System/Status/MoreInfo/MoreInfo.js
new file mode 100644
index 000000000..e573f1bfa
--- /dev/null
+++ b/frontend/src/System/Status/MoreInfo/MoreInfo.js
@@ -0,0 +1,69 @@
+import React, { Component } from 'react';
+import Link from 'Components/Link/Link';
+import FieldSet from 'Components/FieldSet';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
+import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
+
+class MoreInfo extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+ Home page
+
+ lidarr.audio
+
+
+ Wiki
+
+ wiki.lidarr.audio
+
+
+ Reddit
+
+ Lidarr
+
+
+ Discord
+
+ #lidarr on Discord
+
+
+ Donations
+
+ Donate to Lidarr
+
+
+ Donations (Sonarr)
+
+ Donate to Sonarr
+
+
+ Source
+
+ github.com/Lidarr/Lidarr
+
+
+ Feature Requests
+
+ github.com/Lidarr/Lidarr/issues
+
+
+
+
+ );
+ }
+}
+
+MoreInfo.propTypes = {
+
+};
+
+export default MoreInfo;
diff --git a/frontend/src/System/Status/Status.js b/frontend/src/System/Status/Status.js
new file mode 100644
index 000000000..f0b157515
--- /dev/null
+++ b/frontend/src/System/Status/Status.js
@@ -0,0 +1,29 @@
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import HealthConnector from './Health/HealthConnector';
+import DiskSpaceConnector from './DiskSpace/DiskSpaceConnector';
+import AboutConnector from './About/AboutConnector';
+import MoreInfo from './MoreInfo/MoreInfo';
+
+class Status extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
+
+export default Status;
diff --git a/frontend/src/System/Tasks/TaskRow.css b/frontend/src/System/Tasks/TaskRow.css
new file mode 100644
index 000000000..dc83cfd69
--- /dev/null
+++ b/frontend/src/System/Tasks/TaskRow.css
@@ -0,0 +1,18 @@
+.interval {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 150px;
+}
+
+.lastExecution,
+.nextExecution {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 180px;
+}
+
+.actions {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 20px;
+}
diff --git a/frontend/src/System/Tasks/TaskRow.js b/frontend/src/System/Tasks/TaskRow.js
new file mode 100644
index 000000000..f118842a7
--- /dev/null
+++ b/frontend/src/System/Tasks/TaskRow.js
@@ -0,0 +1,94 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import formatDate from 'Utilities/Date/formatDate';
+import formatDateTime from 'Utilities/Date/formatDateTime';
+import { icons } from 'Helpers/Props';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import styles from './TaskRow.css';
+
+function TaskRow(props) {
+ const {
+ name,
+ interval,
+ lastExecution,
+ nextExecution,
+ isExecuting,
+ showRelativeDates,
+ shortDateFormat,
+ longDateFormat,
+ timeFormat,
+ onExecutePress
+ } = props;
+
+ const disabled = interval === 0;
+ const executeNow = !disabled && moment().isAfter(nextExecution);
+ const hasNextExecutionTime = !disabled && !executeNow;
+ const duration = moment.duration(interval, 'minutes').humanize().replace(/an?(?=\s)/, '1');
+
+ return (
+
+ {name}
+
+ {disabled ? 'disabled' : duration}
+
+
+
+ {showRelativeDates ? moment(lastExecution).fromNow() : formatDate(lastExecution, shortDateFormat)}
+
+
+ {
+ disabled &&
+ -
+ }
+
+ {
+ executeNow &&
+ now
+ }
+
+ {
+ hasNextExecutionTime &&
+
+ {showRelativeDates ? moment(nextExecution).fromNow() : formatDate(nextExecution, shortDateFormat)}
+
+ }
+
+
+
+
+
+ );
+}
+
+TaskRow.propTypes = {
+ name: PropTypes.string.isRequired,
+ interval: PropTypes.number.isRequired,
+ lastExecution: PropTypes.string.isRequired,
+ nextExecution: PropTypes.string.isRequired,
+ isExecuting: PropTypes.bool.isRequired,
+ showRelativeDates: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ longDateFormat: PropTypes.string.isRequired,
+ timeFormat: PropTypes.string.isRequired,
+ onExecutePress: PropTypes.func.isRequired
+};
+
+export default TaskRow;
diff --git a/frontend/src/System/Tasks/TaskRowConnector.js b/frontend/src/System/Tasks/TaskRowConnector.js
new file mode 100644
index 000000000..035364034
--- /dev/null
+++ b/frontend/src/System/Tasks/TaskRowConnector.js
@@ -0,0 +1,91 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { findCommand } from 'Utilities/Command';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchTask } from 'Store/Actions/systemActions';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import TaskRow from './TaskRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { taskName }) => taskName,
+ createCommandsSelector(),
+ createUISettingsSelector(),
+ (taskName, commands, uiSettings) => {
+ const isExecuting = !!findCommand(commands, { name: taskName });
+
+ return {
+ isExecuting,
+ showRelativeDates: uiSettings.showRelativeDates,
+ shortDateFormat: uiSettings.shortDateFormat,
+ longDateFormat: uiSettings.longDateFormat,
+ timeFormat: uiSettings.timeFormat
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ const taskName = props.taskName;
+
+ return {
+ dispatchFetchTask() {
+ dispatch(fetchTask({
+ id: props.id
+ }));
+ },
+
+ onExecutePress() {
+ dispatch(executeCommand({
+ name: taskName
+ }));
+ }
+ };
+}
+
+class TaskRowConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidUpdate(prevProps) {
+ const {
+ isExecuting,
+ dispatchFetchTask
+ } = this.props;
+
+ if (!isExecuting && prevProps.isExecuting) {
+ // Give the host a moment to update after the command completes
+ setTimeout(() => {
+ dispatchFetchTask();
+ }, 1000);
+ }
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ dispatchFetchTask,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+TaskRowConnector.propTypes = {
+ id: PropTypes.number.isRequired,
+ isExecuting: PropTypes.bool.isRequired,
+ dispatchFetchTask: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(TaskRowConnector);
diff --git a/frontend/src/System/Tasks/Tasks.js b/frontend/src/System/Tasks/Tasks.js
new file mode 100644
index 000000000..ae2d75dbb
--- /dev/null
+++ b/frontend/src/System/Tasks/Tasks.js
@@ -0,0 +1,89 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TaskRowConnector from './TaskRowConnector';
+
+const columns = [
+ {
+ name: 'name',
+ label: 'Name',
+ isVisible: true
+ },
+ {
+ name: 'interval',
+ label: 'Interval',
+ isVisible: true
+ },
+ {
+ name: 'lastExecution',
+ label: 'Last Execution',
+ isVisible: true
+ },
+ {
+ name: 'nextExecution',
+ label: 'Next Execution',
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ isVisible: true
+ }
+];
+
+class Tasks extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ items
+ } = this.props;
+
+ return (
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ isPopulated &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+ );
+ }
+
+}
+
+Tasks.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ items: PropTypes.array.isRequired
+};
+
+export default Tasks;
diff --git a/frontend/src/System/Tasks/TasksConnector.js b/frontend/src/System/Tasks/TasksConnector.js
new file mode 100644
index 000000000..492040674
--- /dev/null
+++ b/frontend/src/System/Tasks/TasksConnector.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchTasks } from 'Store/Actions/systemActions';
+import Tasks from './Tasks';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.tasks,
+ (tasks) => {
+ return tasks;
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchTasks
+};
+
+class TasksConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchTasks();
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+TasksConnector.propTypes = {
+ fetchTasks: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(TasksConnector);
diff --git a/frontend/src/System/Updates/UpdateChanges.css b/frontend/src/System/Updates/UpdateChanges.css
new file mode 100644
index 000000000..d21897373
--- /dev/null
+++ b/frontend/src/System/Updates/UpdateChanges.css
@@ -0,0 +1,4 @@
+.title {
+ margin-top: 10px;
+ font-size: 16px;
+}
diff --git a/frontend/src/System/Updates/UpdateChanges.js b/frontend/src/System/Updates/UpdateChanges.js
new file mode 100644
index 000000000..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..307028426
--- /dev/null
+++ b/frontend/src/System/Updates/Updates.css
@@ -0,0 +1,46 @@
+.upToDate {
+ display: flex;
+ margin-bottom: 20px;
+}
+
+.upToDateIcon {
+ color: #37bc9b;
+ font-size: 30px;
+}
+
+.upToDateMessage {
+ padding-left: 5px;
+ font-size: 18px;
+ line-height: 30px;
+}
+
+.update {
+ margin-top: 20px;
+}
+
+.info {
+ display: flex;
+ 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..08a12e6d1
--- /dev/null
+++ b/frontend/src/System/Updates/Updates.js
@@ -0,0 +1,149 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { kinds } from 'Helpers/Props';
+import formatDate from 'Utilities/Date/formatDate';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import SpinnerButton from 'Components/Link/SpinnerButton';
+import Icon from 'Components/Icon';
+import Label from 'Components/Label';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import UpdateChanges from './UpdateChanges';
+import styles from './Updates.css';
+
+class Updates extends Component {
+
+ //
+ // Render
+
+ render() {
+ const {
+ isPopulated,
+ error,
+ items,
+ isInstallingUpdate,
+ shortDateFormat,
+ onInstallLatestPress
+ } = this.props;
+
+ const hasUpdates = isPopulated && !error && items.length > 0;
+ const noUpdates = isPopulated && !error && !items.length;
+ const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true });
+ const noUpdateToInstall = hasUpdates && !hasUpdateToInstall;
+
+ return (
+
+
+ {
+ !isPopulated &&
+
+ }
+
+ {
+ noUpdates &&
+ No updates are available
+ }
+
+ {
+ hasUpdateToInstall &&
+
+ Install Latest
+
+ }
+
+ {
+ noUpdateToInstall &&
+
+
+
+ The latest version of Lidarr is already installed
+
+
+ }
+
+ {
+ hasUpdates &&
+
+ {
+ items.map((update) => {
+ const hasChanges = !!update.changes;
+
+ return (
+
+
+
{update.version}
+
—
+
{formatDate(update.releaseDate, shortDateFormat)}
+
+ {
+ update.branch !== 'master' &&
+
+ {update.branch}
+
+ }
+
+
+ {
+ !hasChanges &&
+
Maintenance release
+ }
+
+ {
+ hasChanges &&
+
+
+
+
+
+ }
+
+ );
+ })
+ }
+
+ }
+
+ {
+ !!error &&
+
+ Failed to fetch updates
+
+ }
+
+
+ );
+ }
+
+}
+
+Updates.propTypes = {
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.array.isRequired,
+ isInstallingUpdate: PropTypes.bool.isRequired,
+ shortDateFormat: PropTypes.string.isRequired,
+ onInstallLatestPress: PropTypes.func.isRequired
+};
+
+export default Updates;
diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js
new file mode 100644
index 000000000..0ac6cf239
--- /dev/null
+++ b/frontend/src/System/Updates/UpdatesConnector.js
@@ -0,0 +1,74 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { fetchUpdates } from 'Store/Actions/systemActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
+import * as commandNames from 'Commands/commandNames';
+import Updates from './Updates';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.system.updates,
+ createUISettingsSelector(),
+ createCommandExecutingSelector(commandNames.APPLICATION_UPDATE),
+ (updates, uiSettings, isInstallingUpdate) => {
+ const {
+ isPopulated,
+ error,
+ items
+ } = updates;
+
+ return {
+ isPopulated,
+ error,
+ items,
+ isInstallingUpdate,
+ shortDateFormat: uiSettings.shortDateFormat
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ fetchUpdates,
+ executeCommand
+};
+
+class UpdatesConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ this.props.fetchUpdates();
+ }
+
+ //
+ // Listeners
+
+ onInstallLatestPress = () => {
+ this.props.executeCommand({ name: commandNames.APPLICATION_UPDATE });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+UpdatesConnector.propTypes = {
+ fetchUpdates: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector);
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModal.js b/frontend/src/TrackFile/Editor/TrackFileEditorModal.js
new file mode 100644
index 000000000..7f52aca05
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorModal.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import TrackFileEditorModalContentConnector from './TrackFileEditorModalContentConnector';
+
+function TrackFileEditorModal(props) {
+ const {
+ isOpen,
+ onModalClose,
+ ...otherProps
+ } = props;
+
+ return (
+
+ {
+ isOpen &&
+
+ }
+
+ );
+}
+
+TrackFileEditorModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default TrackFileEditorModal;
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css
new file mode 100644
index 000000000..49e946826
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.css
@@ -0,0 +1,8 @@
+.actions {
+ display: flex;
+ margin-right: auto;
+}
+
+.selectInput {
+ margin-left: 10px;
+}
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js
new file mode 100644
index 000000000..a1365dc99
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js
@@ -0,0 +1,268 @@
+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 TrackFileEditorRow from './TrackFileEditorRow';
+import styles from './TrackFileEditorModalContent.css';
+
+const columns = [
+ {
+ name: 'trackNumber',
+ label: 'Track',
+ isVisible: true
+ },
+ {
+ name: 'relativePath',
+ label: 'Relative Path',
+ isVisible: true
+ },
+ {
+ name: 'language',
+ label: 'Language',
+ isVisible: true
+ },
+ {
+ name: 'quality',
+ label: 'Quality',
+ isVisible: true
+ }
+];
+
+class TrackFileEditorModalContent extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmDeleteModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.items !== this.props.items) {
+ this.onSelectAllChange({ value: false });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ const selectedIds = getSelectedIds(this.state.selectedState);
+
+ return _.uniq(_.map(selectedIds, (id) => {
+ return _.find(this.props.items, { id }).trackFileId;
+ }));
+ }
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onDeletePress = () => {
+ this.setState({ isConfirmDeleteModalOpen: true });
+ }
+
+ onConfirmDelete = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ this.props.onDeletePress(this.getSelectedIds());
+ }
+
+ onConfirmDeleteModalClose = () => {
+ this.setState({ isConfirmDeleteModalOpen: false });
+ }
+
+ onLanguageChange = ({ value }) => {
+ const selectedIds = this.getSelectedIds();
+
+ if (!selectedIds.length) {
+ return;
+ }
+
+ this.props.onLanguageChange(selectedIds, parseInt(value));
+ }
+
+ onQualityChange = ({ value }) => {
+ const selectedIds = this.getSelectedIds();
+
+ if (!selectedIds.length) {
+ return;
+ }
+
+ this.props.onQualityChange(selectedIds, parseInt(value));
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isDeleting,
+ items,
+ languages,
+ qualities,
+ onModalClose
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmDeleteModalOpen
+ } = this.state;
+
+ const languageOptions = _.reduceRight(languages, (acc, language) => {
+ acc.push({
+ key: language.id,
+ value: language.name
+ });
+
+ return acc;
+ }, [{ key: 'selectLanguage', value: 'Select Language', disabled: true }]);
+
+ const qualityOptions = _.reduceRight(qualities, (acc, quality) => {
+ acc.push({
+ key: quality.id,
+ value: quality.name
+ });
+
+ return acc;
+ }, [{ key: 'selectQuality', value: 'Select Quality', disabled: true }]);
+
+ const hasSelectedFiles = this.getSelectedIds().length > 0;
+
+ return (
+
+
+ Manage Tracks
+
+
+
+ {
+ !items.length &&
+
+ No track files to manage.
+
+ }
+
+ {
+ !!items.length &&
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+ }
+
+
+
+
+
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+ Close
+
+
+
+
+
+ );
+ }
+}
+
+TrackFileEditorModalContent.propTypes = {
+ isDeleting: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ languages: PropTypes.arrayOf(PropTypes.object).isRequired,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onDeletePress: PropTypes.func.isRequired,
+ onLanguageChange: PropTypes.func.isRequired,
+ onQualityChange: PropTypes.func.isRequired,
+ onModalClose: PropTypes.func.isRequired
+};
+
+export default TrackFileEditorModalContent;
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js
new file mode 100644
index 000000000..1fd99480c
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js
@@ -0,0 +1,183 @@
+/* eslint max-params: 0 */
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import { deleteTrackFiles, updateTrackFiles } from 'Store/Actions/trackFileActions';
+import { fetchTracks, clearTracks } from 'Store/Actions/trackActions';
+import { fetchLanguageProfileSchema, fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
+import TrackFileEditorModalContent from './TrackFileEditorModalContent';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { albumId }) => albumId,
+ (state) => state.tracks,
+ (state) => state.trackFiles,
+ (state) => state.settings.languageProfiles.schema,
+ (state) => state.settings.qualityProfiles.schema,
+ createArtistSelector(),
+ (
+ albumId,
+ tracks,
+ trackFiles,
+ languageProfilesSchema,
+ qualityProfileSchema,
+ artist
+ ) => {
+ const filtered = _.filter(tracks.items, (track) => {
+ if (albumId >= 0 && track.albumId !== albumId) {
+ return false;
+ }
+
+ if (!track.trackFileId) {
+ return false;
+ }
+
+ return _.some(trackFiles.items, { id: track.trackFileId });
+ });
+
+ const sorted = _.orderBy(filtered, ['albumId', 'trackNumber'], ['desc', 'asc']);
+
+ const items = _.map(sorted, (track) => {
+ const trackFile = _.find(trackFiles.items, { id: track.trackFileId });
+
+ return {
+ relativePath: trackFile.relativePath,
+ language: trackFile.language,
+ quality: trackFile.quality,
+ ...track
+ };
+ });
+
+ const languages = _.map(languageProfilesSchema.languages, 'language');
+ const qualities = _.map(qualityProfileSchema.items, 'quality');
+
+ return {
+ items,
+ artistType: artist.artistType,
+ isDeleting: trackFiles.isDeleting,
+ isSaving: trackFiles.isSaving,
+ languages,
+ qualities
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ dispatchClearTracks() {
+ dispatch(clearTracks());
+ },
+
+ dispatchFetchTracks(updateProps) {
+ dispatch(fetchTracks(updateProps));
+ },
+
+ dispatchFetchLanguageProfileSchema(name, path) {
+ dispatch(fetchLanguageProfileSchema());
+ },
+
+ dispatchFetchQualityProfileSchema(name, path) {
+ dispatch(fetchQualityProfileSchema());
+ },
+
+ dispatchUpdateTrackFiles(updateProps) {
+ dispatch(updateTrackFiles(updateProps));
+ },
+
+ onDeletePress(trackFileIds) {
+ dispatch(deleteTrackFiles({ trackFileIds }));
+ },
+
+ onQualityChange(trackFileIds, qualityId) {
+ const quality = {
+ quality: _.find(this.props.qualities, { id: qualityId }),
+ revision: {
+ version: 1,
+ real: 0
+ }
+ };
+
+ dispatch(updateTrackFiles({ trackFileIds, quality }));
+ }
+ };
+}
+
+class TrackFileEditorModalContentConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const artistId = this.props.artistId;
+
+ this.props.dispatchFetchTracks({ artistId });
+
+ this.props.dispatchFetchLanguageProfileSchema();
+ this.props.dispatchFetchQualityProfileSchema();
+ }
+
+ componentWillUnmount() {
+ this.props.dispatchClearTracks();
+ }
+
+ //
+ // Render
+
+ //
+ // Listeners
+
+ onLanguageChange = (trackFileIds, languageId) => {
+ const language = _.find(this.props.languages, { id: languageId });
+
+ this.props.dispatchUpdateTrackFiles({ trackFileIds, language });
+ }
+
+ onQualityChange = (trackFileIds, qualityId) => {
+ const quality = {
+ quality: _.find(this.props.qualities, { id: qualityId }),
+ revision: {
+ version: 1,
+ real: 0
+ }
+ };
+
+ this.props.dispatchUpdateTrackFiles({ trackFileIds, quality });
+ }
+
+ render() {
+ const {
+ dispatchFetchLanguageProfileSchema,
+ dispatchFetchQualityProfileSchema,
+ dispatchUpdateTrackFiles,
+ dispatchFetchTracks,
+ dispatchClearTracks,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+TrackFileEditorModalContentConnector.propTypes = {
+ artistId: PropTypes.number.isRequired,
+ albumId: PropTypes.number,
+ languages: PropTypes.arrayOf(PropTypes.object).isRequired,
+ qualities: PropTypes.arrayOf(PropTypes.object).isRequired,
+ dispatchFetchTracks: PropTypes.func.isRequired,
+ dispatchClearTracks: PropTypes.func.isRequired,
+ dispatchFetchLanguageProfileSchema: PropTypes.func.isRequired,
+ dispatchFetchQualityProfileSchema: PropTypes.func.isRequired,
+ dispatchUpdateTrackFiles: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(TrackFileEditorModalContentConnector);
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorRow.css b/frontend/src/TrackFile/Editor/TrackFileEditorRow.css
new file mode 100644
index 000000000..f86e1de6b
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorRow.css
@@ -0,0 +1,3 @@
+.absoluteEpisodeNumber {
+ margin-left: 5px;
+}
diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorRow.js b/frontend/src/TrackFile/Editor/TrackFileEditorRow.js
new file mode 100644
index 000000000..2e1d3f2cd
--- /dev/null
+++ b/frontend/src/TrackFile/Editor/TrackFileEditorRow.js
@@ -0,0 +1,64 @@
+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 'Album/EpisodeQuality';
+import styles from './TrackFileEditorRow';
+
+function TrackFileEditorRow(props) {
+ const {
+ id,
+ trackNumber,
+ relativePath,
+ language,
+ quality,
+ isSelected,
+ onSelectedChange
+ } = props;
+
+ return (
+
+
+
+
+ {padNumber(trackNumber, 2)}
+
+
+
+ {relativePath}
+
+
+
+
+ {language.name}
+
+
+
+
+
+
+
+ );
+}
+
+TrackFileEditorRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ trackNumber: PropTypes.number.isRequired,
+ relativePath: PropTypes.string.isRequired,
+ language: PropTypes.object.isRequired,
+ quality: PropTypes.object.isRequired,
+ isSelected: PropTypes.bool,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default TrackFileEditorRow;
diff --git a/frontend/src/TrackFile/MediaInfo.js b/frontend/src/TrackFile/MediaInfo.js
new file mode 100644
index 000000000..75b264d58
--- /dev/null
+++ b/frontend/src/TrackFile/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/TrackFile/MediaInfoConnector.js b/frontend/src/TrackFile/MediaInfoConnector.js
new file mode 100644
index 000000000..5f3a1386b
--- /dev/null
+++ b/frontend/src/TrackFile/MediaInfoConnector.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
+import MediaInfo from './MediaInfo';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTrackFileSelector(),
+ (trackFile) => {
+ if (trackFile) {
+ return {
+ ...trackFile.mediaInfo
+ };
+ }
+
+ return {};
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(MediaInfo);
diff --git a/frontend/src/TrackFile/TrackFileLanguageConnector.js b/frontend/src/TrackFile/TrackFileLanguageConnector.js
new file mode 100644
index 000000000..9a1a3b1bf
--- /dev/null
+++ b/frontend/src/TrackFile/TrackFileLanguageConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
+import EpisodeLanguage from 'Album/EpisodeLanguage';
+
+function createMapStateToProps() {
+ return createSelector(
+ createTrackFileSelector(),
+ (trackFile) => {
+ return {
+ language: trackFile ? trackFile.language : undefined
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(EpisodeLanguage);
diff --git a/frontend/src/TrackFile/mediaInfoTypes.js b/frontend/src/TrackFile/mediaInfoTypes.js
new file mode 100644
index 000000000..5e5a78e64
--- /dev/null
+++ b/frontend/src/TrackFile/mediaInfoTypes.js
@@ -0,0 +1,2 @@
+export const AUDIO = 'audio';
+export const VIDEO = 'video';
diff --git a/frontend/src/Utilities/Album/updateAlbums.js b/frontend/src/Utilities/Album/updateAlbums.js
new file mode 100644
index 000000000..16d800de9
--- /dev/null
+++ b/frontend/src/Utilities/Album/updateAlbums.js
@@ -0,0 +1,21 @@
+import _ from 'lodash';
+import { update } from 'Store/Actions/baseActions';
+
+function updateAlbums(dispatch, section, episodes, albumIds, options) {
+ const data = _.reduce(episodes, (result, item) => {
+ if (albumIds.indexOf(item.id) > -1) {
+ result.push({
+ ...item,
+ ...options
+ });
+ } else {
+ result.push(item);
+ }
+
+ return result;
+ }, []);
+
+ dispatch(update({ section, data }));
+}
+
+export default updateAlbums;
diff --git a/frontend/src/Utilities/Array/sortByName.js b/frontend/src/Utilities/Array/sortByName.js
new file mode 100644
index 000000000..1956d3bac
--- /dev/null
+++ b/frontend/src/Utilities/Array/sortByName.js
@@ -0,0 +1,5 @@
+function sortByName(a, b) {
+ return a.name.localeCompare(b.name);
+}
+
+export default sortByName;
diff --git a/frontend/src/Utilities/Command/findCommand.js b/frontend/src/Utilities/Command/findCommand.js
new file mode 100644
index 000000000..cf7d5444a
--- /dev/null
+++ b/frontend/src/Utilities/Command/findCommand.js
@@ -0,0 +1,10 @@
+import _ from 'lodash';
+import isSameCommand from './isSameCommand';
+
+function findCommand(commands, options) {
+ return _.findLast(commands, (command) => {
+ return isSameCommand(command.body, options);
+ });
+}
+
+export default findCommand;
diff --git a/frontend/src/Utilities/Command/index.js b/frontend/src/Utilities/Command/index.js
new file mode 100644
index 000000000..66043bf03
--- /dev/null
+++ b/frontend/src/Utilities/Command/index.js
@@ -0,0 +1,5 @@
+export { default as findCommand } from './findCommand';
+export { default as isCommandComplete } from './isCommandComplete';
+export { default as isCommandExecuting } from './isCommandExecuting';
+export { default as isCommandFailed } from './isCommandFailed';
+export { default as isSameCommand } from './isSameCommand';
diff --git a/frontend/src/Utilities/Command/isCommandComplete.js b/frontend/src/Utilities/Command/isCommandComplete.js
new file mode 100644
index 000000000..e64737188
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandComplete.js
@@ -0,0 +1,9 @@
+function isCommandComplete(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.state === 'complete';
+}
+
+export default isCommandComplete;
diff --git a/frontend/src/Utilities/Command/isCommandExecuting.js b/frontend/src/Utilities/Command/isCommandExecuting.js
new file mode 100644
index 000000000..4e2e6d8c4
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandExecuting.js
@@ -0,0 +1,9 @@
+function isCommandExecuting(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.state === 'queued' || command.state === 'started';
+}
+
+export default isCommandExecuting;
diff --git a/frontend/src/Utilities/Command/isCommandFailed.js b/frontend/src/Utilities/Command/isCommandFailed.js
new file mode 100644
index 000000000..f48d790e3
--- /dev/null
+++ b/frontend/src/Utilities/Command/isCommandFailed.js
@@ -0,0 +1,12 @@
+function isCommandFailed(command) {
+ if (!command) {
+ return false;
+ }
+
+ return command.state === 'failed' ||
+ command.state === 'aborted' ||
+ command.state === 'cancelled' ||
+ command.state === 'orphaned';
+}
+
+export default isCommandFailed;
diff --git a/frontend/src/Utilities/Command/isSameCommand.js b/frontend/src/Utilities/Command/isSameCommand.js
new file mode 100644
index 000000000..d0acb24b5
--- /dev/null
+++ b/frontend/src/Utilities/Command/isSameCommand.js
@@ -0,0 +1,24 @@
+import _ from 'lodash';
+
+function isSameCommand(commandA, commandB) {
+ if (commandA.name.toLocaleLowerCase() !== commandB.name.toLocaleLowerCase()) {
+ return false;
+ }
+
+ for (const key in commandB) {
+ if (key !== 'name') {
+ const value = commandB[key];
+ if (Array.isArray(value)) {
+ if (_.difference(value, commandA[key]).length > 0) {
+ return false;
+ }
+ } else if (value !== commandA[key]) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+export default isSameCommand;
diff --git a/frontend/src/Utilities/Constants/keyCodes.js b/frontend/src/Utilities/Constants/keyCodes.js
new file mode 100644
index 000000000..9285b10fe
--- /dev/null
+++ b/frontend/src/Utilities/Constants/keyCodes.js
@@ -0,0 +1,7 @@
+export const TAB = 9;
+export const ENTER = 13;
+export const SHIFT = 16;
+export const CONTROL = 17;
+export const ESCAPE = 27;
+export const UP_ARROW = 38;
+export const DOWN_ARROW = 40;
diff --git a/frontend/src/Utilities/Date/formatDate.js b/frontend/src/Utilities/Date/formatDate.js
new file mode 100644
index 000000000..92eb57840
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatDate.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function formatDate(date, dateFormat) {
+ if (!date) {
+ return '';
+ }
+
+ return moment(date).format(dateFormat);
+}
+
+export default formatDate;
diff --git a/frontend/src/Utilities/Date/formatDateTime.js b/frontend/src/Utilities/Date/formatDateTime.js
new file mode 100644
index 000000000..f36f4f3e0
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatDateTime.js
@@ -0,0 +1,39 @@
+import moment from 'moment';
+import formatTime from './formatTime';
+import isToday from './isToday';
+import isTomorrow from './isTomorrow';
+import isYesterday from './isYesterday';
+
+function getRelativeDay(date, includeRelativeDate) {
+ if (!includeRelativeDate) {
+ return '';
+ }
+
+ if (isYesterday(date)) {
+ return 'Yesterday, ';
+ }
+
+ if (isToday(date)) {
+ return 'Today, ';
+ }
+
+ if (isTomorrow(date)) {
+ return 'Tomorrow, ';
+ }
+
+ return '';
+}
+
+function formatDateTime(date, dateFormat, timeFormat, { includeSeconds = false, includeRelativeDay = false } = {}) {
+ if (!date) {
+ return '';
+ }
+
+ const relativeDay = getRelativeDay(date, includeRelativeDay);
+ const formattedDate = moment(date).format(dateFormat);
+ const formattedTime = formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds });
+
+ return `${relativeDay}${formattedDate} ${formattedTime}`;
+}
+
+export default formatDateTime;
diff --git a/frontend/src/Utilities/Date/formatTime.js b/frontend/src/Utilities/Date/formatTime.js
new file mode 100644
index 000000000..89c908d1f
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatTime.js
@@ -0,0 +1,19 @@
+import moment from 'moment';
+
+function formatTime(date, timeFormat, { includeMinuteZero = false, includeSeconds = false } = {}) {
+ if (!date) {
+ return '';
+ }
+
+ if (includeSeconds) {
+ timeFormat = timeFormat.replace(/\(?:mm\)?/, ':mm:ss');
+ } else if (includeMinuteZero) {
+ timeFormat = timeFormat.replace('(:mm)', ':mm');
+ } else {
+ timeFormat = timeFormat.replace('(:mm)', '');
+ }
+
+ return moment(date).format(timeFormat);
+}
+
+export default formatTime;
diff --git a/frontend/src/Utilities/Date/formatTimeSpan.js b/frontend/src/Utilities/Date/formatTimeSpan.js
new file mode 100644
index 000000000..ef1a278e5
--- /dev/null
+++ b/frontend/src/Utilities/Date/formatTimeSpan.js
@@ -0,0 +1,24 @@
+import moment from 'moment';
+import padNumber from 'Utilities/Number/padNumber';
+
+function formatTimeSpan(timeSpan) {
+ if (!timeSpan) {
+ return '';
+ }
+
+ const duration = moment.duration(timeSpan);
+ const days = duration.get('days');
+ const hours = padNumber(duration.get('hours'), 2);
+ const minutes = padNumber(duration.get('minutes'), 2);
+ const seconds = padNumber(duration.get('seconds'), 2);
+
+ const time = `${hours}:${minutes}:${seconds}`;
+
+ if (days > 0) {
+ return `${days}d ${time}`;
+ }
+
+ return time;
+}
+
+export default formatTimeSpan;
diff --git a/frontend/src/Utilities/Date/getRelativeDate.js b/frontend/src/Utilities/Date/getRelativeDate.js
new file mode 100644
index 000000000..0d5ab4f58
--- /dev/null
+++ b/frontend/src/Utilities/Date/getRelativeDate.js
@@ -0,0 +1,40 @@
+import moment from 'moment';
+import formatTime from 'Utilities/Date/formatTime';
+import isInNextWeek from 'Utilities/Date/isInNextWeek';
+import isToday from 'Utilities/Date/isToday';
+import isTomorrow from 'Utilities/Date/isTomorrow';
+import isYesterday from 'Utilities/Date/isYesterday';
+
+function getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds = false, timeForToday = false } = {}) {
+ if (!date) {
+ return null;
+ }
+
+ if (!showRelativeDates) {
+ return moment(date).format(shortDateFormat);
+ }
+
+ if (isYesterday(date)) {
+ return 'Yesterday';
+ }
+
+ if (isToday(date)) {
+ if (timeForToday && timeFormat) {
+ return formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds });
+ }
+
+ return 'Today';
+ }
+
+ if (isTomorrow(date)) {
+ return 'Tomorrow';
+ }
+
+ if (isInNextWeek(date)) {
+ return moment(date).format('dddd');
+ }
+
+ return moment(date).format(shortDateFormat);
+}
+
+export default getRelativeDate;
diff --git a/frontend/src/Utilities/Date/isAfter.js b/frontend/src/Utilities/Date/isAfter.js
new file mode 100644
index 000000000..4bbd8660b
--- /dev/null
+++ b/frontend/src/Utilities/Date/isAfter.js
@@ -0,0 +1,17 @@
+import moment from 'moment';
+
+function isAfter(date, offsets = {}) {
+ if (!date) {
+ return false;
+ }
+
+ const offsetTime = moment();
+
+ Object.keys(offsets).forEach((key) => {
+ offsetTime.add(offsets[key], key);
+ });
+
+ return moment(date).isAfter(offsetTime);
+}
+
+export default isAfter;
diff --git a/frontend/src/Utilities/Date/isBefore.js b/frontend/src/Utilities/Date/isBefore.js
new file mode 100644
index 000000000..3e1e81f67
--- /dev/null
+++ b/frontend/src/Utilities/Date/isBefore.js
@@ -0,0 +1,17 @@
+import moment from 'moment';
+
+function isBefore(date, offsets = {}) {
+ if (!date) {
+ return false;
+ }
+
+ const offsetTime = moment();
+
+ Object.keys(offsets).forEach((key) => {
+ offsetTime.add(offsets[key], key);
+ });
+
+ return moment(date).isBefore(offsetTime);
+}
+
+export default isBefore;
diff --git a/frontend/src/Utilities/Date/isInNextWeek.js b/frontend/src/Utilities/Date/isInNextWeek.js
new file mode 100644
index 000000000..7b5fd7cc7
--- /dev/null
+++ b/frontend/src/Utilities/Date/isInNextWeek.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isInNextWeek(date) {
+ if (!date) {
+ return false;
+ }
+ const now = moment();
+ return moment(date).isBetween(now, now.clone().add(6, 'days').endOf('day'));
+}
+
+export default isInNextWeek;
diff --git a/frontend/src/Utilities/Date/isSameWeek.js b/frontend/src/Utilities/Date/isSameWeek.js
new file mode 100644
index 000000000..14b76ffb7
--- /dev/null
+++ b/frontend/src/Utilities/Date/isSameWeek.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isSameWeek(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment(), 'week');
+}
+
+export default isSameWeek;
diff --git a/frontend/src/Utilities/Date/isToday.js b/frontend/src/Utilities/Date/isToday.js
new file mode 100644
index 000000000..31502951f
--- /dev/null
+++ b/frontend/src/Utilities/Date/isToday.js
@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+function isToday(date) {
+ if (!date) {
+ return false;
+ }
+
+ return moment(date).isSame(moment(), 'day');
+}
+
+export default isToday;
diff --git a/frontend/src/Utilities/Date/isTomorrow.js b/frontend/src/Utilities/Date/isTomorrow.js
new file mode 100644
index 000000000..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/Number/formatAge.js b/frontend/src/Utilities/Number/formatAge.js
new file mode 100644
index 000000000..b8a4aacc5
--- /dev/null
+++ b/frontend/src/Utilities/Number/formatAge.js
@@ -0,0 +1,17 @@
+function formatAge(age, ageHours, ageMinutes) {
+ age = Math.round(age);
+ ageHours = parseFloat(ageHours);
+ ageMinutes = ageMinutes && parseFloat(ageMinutes);
+
+ if (age < 2 && ageHours) {
+ if (ageHours < 2 && !!ageMinutes) {
+ return `${ageMinutes.toFixed(0)} ${ageHours === 1 ? 'minute' : 'minutes'}`;
+ }
+
+ return `${ageHours.toFixed(1)} ${ageHours === 1 ? 'hour' : 'hours'}`;
+ }
+
+ return `${age} ${age === 1 ? 'day' : 'days'}`;
+}
+
+export default formatAge;
diff --git a/frontend/src/Utilities/Number/formatBytes.js b/frontend/src/Utilities/Number/formatBytes.js
new file mode 100644
index 000000000..1ff1b5a97
--- /dev/null
+++ b/frontend/src/Utilities/Number/formatBytes.js
@@ -0,0 +1,16 @@
+import filesize from 'filesize';
+
+function formatBytes(input) {
+ const size = Number(input);
+
+ if (isNaN(size)) {
+ return '';
+ }
+
+ return filesize(size, {
+ base: 2,
+ round: 1
+ });
+}
+
+export default formatBytes;
diff --git a/frontend/src/Utilities/Number/padNumber.js b/frontend/src/Utilities/Number/padNumber.js
new file mode 100644
index 000000000..53ae69cac
--- /dev/null
+++ b/frontend/src/Utilities/Number/padNumber.js
@@ -0,0 +1,10 @@
+function padNumber(input, width, paddingCharacter = 0) {
+ if (input == null) {
+ return '';
+ }
+
+ input = `${input}`;
+ return input.length >= width ? input : new Array(width - input.length + 1).join(paddingCharacter) + input;
+}
+
+export default padNumber;
diff --git a/frontend/src/Utilities/Object/getErrorMessage.js b/frontend/src/Utilities/Object/getErrorMessage.js
new file mode 100644
index 000000000..1ba874660
--- /dev/null
+++ b/frontend/src/Utilities/Object/getErrorMessage.js
@@ -0,0 +1,11 @@
+function getErrorMessage(xhr, fallbackErrorMessage) {
+ if (!xhr || !xhr.responseJSON || !xhr.responseJSON.message) {
+ return fallbackErrorMessage;
+ }
+
+ const message = xhr.responseJSON.message;
+
+ return message || fallbackErrorMessage;
+}
+
+export default getErrorMessage;
diff --git a/frontend/src/Utilities/Object/hasDifferentItems.js b/frontend/src/Utilities/Object/hasDifferentItems.js
new file mode 100644
index 000000000..f89c99a10
--- /dev/null
+++ b/frontend/src/Utilities/Object/hasDifferentItems.js
@@ -0,0 +1,10 @@
+import _ from 'lodash';
+
+function hasDifferentItems(prevItems, currentItems, idProp = 'id') {
+ const diff1 = _.differenceBy(prevItems, currentItems, (item) => item[idProp]);
+ const diff2 = _.differenceBy(currentItems, prevItems, (item) => item[idProp]);
+
+ return diff1.length > 0 || diff2.length > 0;
+}
+
+export default hasDifferentItems;
diff --git a/frontend/src/Utilities/Object/selectUniqueIds.js b/frontend/src/Utilities/Object/selectUniqueIds.js
new file mode 100644
index 000000000..c2c0c17e3
--- /dev/null
+++ b/frontend/src/Utilities/Object/selectUniqueIds.js
@@ -0,0 +1,15 @@
+import _ from 'lodash';
+
+function selectUniqueIds(items, idProp) {
+ const ids = _.reduce(items, (result, item) => {
+ if (item[idProp]) {
+ result.push(item[idProp]);
+ }
+
+ return result;
+ }, []);
+
+ return _.uniq(ids);
+}
+
+export default selectUniqueIds;
diff --git a/frontend/src/Utilities/ResolutionUtility.js b/frontend/src/Utilities/ResolutionUtility.js
new file mode 100644
index 000000000..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/getMonitoringOptions.js b/frontend/src/Utilities/Series/getMonitoringOptions.js
new file mode 100644
index 000000000..741236f8d
--- /dev/null
+++ b/frontend/src/Utilities/Series/getMonitoringOptions.js
@@ -0,0 +1,70 @@
+import _ from 'lodash';
+
+function monitorSeasons(seasons, startingSeason) {
+ seasons.forEach((season) => {
+ if (season.seasonNumber >= startingSeason) {
+ season.monitored = true;
+ } else {
+ season.monitored = false;
+ }
+ });
+}
+
+function getMonitoringOptions(albums, monitor) {
+ if (!albums.length) {
+ return {
+ albums: [],
+ options: {
+ ignoreEpisodesWithFiles: false,
+ ignoreEpisodesWithoutFiles: false
+ }
+ };
+ }
+
+ const firstSeason = _.minBy(_.reject(albums, { seasonNumber: 0 }), 'seasonNumber').seasonNumber;
+ const lastSeason = _.maxBy(albums, 'seasonNumber').seasonNumber;
+
+ monitorSeasons(albums, firstSeason);
+
+ const monitoringOptions = {
+ ignoreEpisodesWithFiles: false,
+ ignoreEpisodesWithoutFiles: false
+ };
+
+ switch (monitor) {
+ case 'future':
+ monitoringOptions.ignoreEpisodesWithFiles = true;
+ monitoringOptions.ignoreEpisodesWithoutFiles = true;
+ break;
+ case 'latest':
+ monitorSeasons(albums, lastSeason);
+ break;
+ case 'first':
+ monitorSeasons(albums, lastSeason + 1);
+ _.find(albums, { seasonNumber: firstSeason }).monitored = true;
+ break;
+ case 'missing':
+ monitoringOptions.ignoreEpisodesWithFiles = true;
+ break;
+ case 'existing':
+ monitoringOptions.ignoreEpisodesWithoutFiles = true;
+ break;
+ case 'none':
+ monitorSeasons(albums, lastSeason + 1);
+ break;
+ default:
+ break;
+ }
+
+ return {
+ seasons: _.map(albums, (season) => {
+ return _.pick(season, [
+ 'seasonNumber',
+ 'monitored'
+ ]);
+ }),
+ options: monitoringOptions
+ };
+}
+
+export default getMonitoringOptions;
diff --git a/frontend/src/Utilities/Series/getNewSeries.js b/frontend/src/Utilities/Series/getNewSeries.js
new file mode 100644
index 000000000..d9424776c
--- /dev/null
+++ b/frontend/src/Utilities/Series/getNewSeries.js
@@ -0,0 +1,38 @@
+import getMonitoringOptions from 'Utilities/Series/getMonitoringOptions';
+
+function getNewSeries(artist, payload) {
+ const {
+ rootFolderPath,
+ monitor,
+ qualityProfileId,
+ languageProfileId,
+ artistType,
+ albumFolder,
+ primaryAlbumTypes,
+ secondaryAlbumTypes,
+ tags,
+ searchForMissingAlbums = false
+ } = payload;
+
+ // const {
+ // seasons,
+ // options: addOptions
+ // } = getMonitoringOptions(artist.seasons, monitor);
+
+ // addOptions.searchForMissingAlbums = searchForMissingAlbums;
+ // artist.addOptions = addOptions;
+ // artist.seasons = seasons;
+ artist.monitored = true;
+ artist.qualityProfileId = qualityProfileId;
+ artist.languageProfileId = languageProfileId;
+ artist.rootFolderPath = rootFolderPath;
+ artist.artistType = artistType;
+ artist.albumFolder = albumFolder;
+ artist.primaryAlbumTypes = primaryAlbumTypes;
+ artist.secondaryAlbumTypes = secondaryAlbumTypes;
+ artist.tags = tags;
+
+ return artist;
+}
+
+export default getNewSeries;
diff --git a/frontend/src/Utilities/Series/getProgressBarKind.js b/frontend/src/Utilities/Series/getProgressBarKind.js
new file mode 100644
index 000000000..eb3b2dd6e
--- /dev/null
+++ b/frontend/src/Utilities/Series/getProgressBarKind.js
@@ -0,0 +1,15 @@
+import { kinds } from 'Helpers/Props';
+
+function getProgressBarKind(status, monitored, progress) {
+ if (progress === 100) {
+ return status === 'ended' ? kinds.SUCCESS : kinds.PRIMARY;
+ }
+
+ if (monitored) {
+ return kinds.DANGER;
+ }
+
+ return kinds.WARNING;
+}
+
+export default getProgressBarKind;
diff --git a/frontend/src/Utilities/State/getProviderState.js b/frontend/src/Utilities/State/getProviderState.js
new file mode 100644
index 000000000..7d6e5cb1d
--- /dev/null
+++ b/frontend/src/Utilities/State/getProviderState.js
@@ -0,0 +1,30 @@
+import _ from 'lodash';
+
+function getProviderState(payload, getState, getFromState) {
+ const id = payload.id;
+ const state = getFromState(getState());
+ const pendingChanges = Object.assign({}, state.pendingChanges);
+ const pendingFields = state.pendingChanges.fields || {};
+ delete pendingChanges.fields;
+
+ const item = id ? _.find(state.items, { id }) : state.selectedSchema || state.schema || {};
+
+ if (item.fields) {
+ pendingChanges.fields = _.reduce(item.fields, (result, field) => {
+ const value = pendingFields.hasOwnProperty(field.name) ?
+ pendingFields[field.name] :
+ field.value;
+
+ result.push({
+ ...field,
+ value
+ });
+
+ return result;
+ }, []);
+ }
+
+ return Object.assign({}, item, pendingChanges);
+}
+
+export default getProviderState;
diff --git a/frontend/src/Utilities/State/getSectionState.js b/frontend/src/Utilities/State/getSectionState.js
new file mode 100644
index 000000000..c188d9eaa
--- /dev/null
+++ b/frontend/src/Utilities/State/getSectionState.js
@@ -0,0 +1,9 @@
+function getSectionState(state, section) {
+ if (state.hasOwnProperty(section)) {
+ return Object.assign({}, state[section]);
+ }
+
+ return Object.assign({}, state);
+}
+
+export default getSectionState;
diff --git a/frontend/src/Utilities/State/selectProviderSchema.js b/frontend/src/Utilities/State/selectProviderSchema.js
new file mode 100644
index 000000000..c8a31760c
--- /dev/null
+++ b/frontend/src/Utilities/State/selectProviderSchema.js
@@ -0,0 +1,34 @@
+import _ from 'lodash';
+import getSectionState from 'Utilities/State/getSectionState';
+import updateSectionState from 'Utilities/State/updateSectionState';
+
+function applySchemaDefaults(selectedSchema, schemaDefaults) {
+ if (!schemaDefaults) {
+ return selectedSchema;
+ } else if (_.isFunction(schemaDefaults)) {
+ return schemaDefaults(selectedSchema);
+ }
+
+ return Object.assign(selectedSchema, schemaDefaults);
+}
+
+function selectProviderSchema(state, section, payload, schemaDefaults) {
+ const newState = getSectionState(state, section);
+
+ const {
+ implementation,
+ presetName
+ } = payload;
+
+ const selectedImplementation = _.find(newState.schema, { implementation });
+
+ const selectedSchema = presetName ?
+ _.find(selectedImplementation.presets, { name: presetName }) :
+ selectedImplementation;
+
+ newState.selectedSchema = applySchemaDefaults(_.cloneDeep(selectedSchema), schemaDefaults);
+
+ return updateSectionState(state, section, newState);
+}
+
+export default selectProviderSchema;
diff --git a/frontend/src/Utilities/State/updateSectionState.js b/frontend/src/Utilities/State/updateSectionState.js
new file mode 100644
index 000000000..c7407257d
--- /dev/null
+++ b/frontend/src/Utilities/State/updateSectionState.js
@@ -0,0 +1,9 @@
+function updateSectionState(state, section, newState) {
+ if (state.hasOwnProperty(section)) {
+ return Object.assign({}, state, { [section]: newState });
+ }
+
+ return Object.assign({}, state, newState);
+}
+
+export default updateSectionState;
diff --git a/frontend/src/Utilities/String/combinePath.js b/frontend/src/Utilities/String/combinePath.js
new file mode 100644
index 000000000..9e4e9abe8
--- /dev/null
+++ b/frontend/src/Utilities/String/combinePath.js
@@ -0,0 +1,5 @@
+export default function combinePath(isWindows, basePath, paths = []) {
+ const slash = isWindows ? '\\' : '/';
+
+ return `${basePath}${slash}${paths.join(slash)}`;
+}
diff --git a/frontend/src/Utilities/String/parseUrl.js b/frontend/src/Utilities/String/parseUrl.js
new file mode 100644
index 000000000..99377cd7a
--- /dev/null
+++ b/frontend/src/Utilities/String/parseUrl.js
@@ -0,0 +1,34 @@
+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));
+ }
+
+ 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..531e4df68
--- /dev/null
+++ b/frontend/src/Utilities/String/titleCase.js
@@ -0,0 +1,11 @@
+function titleCase(input) {
+ if (!input) {
+ return '';
+ }
+
+ return input.replace(/\w\S*/g, (match) => {
+ return match.charAt(0).toUpperCase() + match.substr(1).toLowerCase();
+ });
+}
+
+export default titleCase;
diff --git a/frontend/src/Utilities/Table/areAllSelected.js b/frontend/src/Utilities/Table/areAllSelected.js
new file mode 100644
index 000000000..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..4b19dc268
--- /dev/null
+++ b/frontend/src/Utilities/Table/toggleSelected.js
@@ -0,0 +1,26 @@
+import areAllSelected from './areAllSelected';
+import getToggledRange from './getToggledRange';
+
+function toggleSelected(state, items, id, selected, shiftKey) {
+ const lastToggled = state.lastToggled;
+ const selectedState = {
+ ...state.selectedState,
+ [id]: selected
+ };
+
+ if (shiftKey && lastToggled) {
+ const { lower, upper } = getToggledRange(items, id, lastToggled);
+
+ for (let i = lower; i < upper; i++) {
+ selectedState[items[i].id] = selected;
+ }
+ }
+
+ return {
+ ...areAllSelected(selectedState),
+ lastToggled: id,
+ selectedState
+ };
+}
+
+export default toggleSelected;
diff --git a/frontend/src/Utilities/createAjaxRequest.js b/frontend/src/Utilities/createAjaxRequest.js
new file mode 100644
index 000000000..fc75d65b8
--- /dev/null
+++ b/frontend/src/Utilities/createAjaxRequest.js
@@ -0,0 +1,32 @@
+import $ from 'jquery';
+
+export default function createAjaxRequest() {
+ return function(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..3fb26b1db
--- /dev/null
+++ b/frontend/src/Utilities/pagePopulator.js
@@ -0,0 +1,17 @@
+let currentPopulator = null;
+
+export function registerPagePopulator(populator) {
+ currentPopulator = populator;
+}
+
+export function unregisterPagePopulator(populator) {
+ if (currentPopulator === populator) {
+ currentPopulator = null;
+ }
+}
+
+export function repopulatePage() {
+ if (currentPopulator) {
+ 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..76ec74085
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js
@@ -0,0 +1,285 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, icons, kinds } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TablePager from 'Components/Table/TablePager';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import CutoffUnmetRowConnector from './CutoffUnmetRowConnector';
+
+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 selected = this.getSelectedIds();
+
+ this.props.onToggleSelectedPress(selected);
+ }
+
+ onSearchAllCutoffUnmetPress = () => {
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: true });
+ }
+
+ onSearchAllCutoffUnmetConfirmed = () => {
+ this.props.onSearchAllCutoffUnmetPress();
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
+ }
+
+ onConfirmSearchAllCutoffUnmetModalClose = () => {
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ totalRecords,
+ isSearchingForAlbums,
+ isSearchingForCutoffUnmetAlbums,
+ isSaving,
+ filterKey,
+ filterValue,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmSearchAllCutoffUnmetModalOpen
+ } = this.state;
+
+ const itemsSelected = !!this.getSelectedIds().length;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monitored
+
+
+
+ Unmonitored
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && error &&
+
+ Error fetching cutoff unmet
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ No cutoff unmet items
+
+ }
+
+ {
+ isPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+ Are you sure you want to search for all {totalRecords} Cutoff Unmet episodes?
+
+
+ This cannot be cancelled once started without restarting Lidarr.
+
+
+ }
+ confirmLabel="Search"
+ onConfirm={this.onSearchAllCutoffUnmetConfirmed}
+ onCancel={this.onConfirmSearchAllCutoffUnmetModalClose}
+ />
+
+ }
+
+
+ );
+ }
+}
+
+CutoffUnmet.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isSearchingForAlbums: PropTypes.bool.isRequired,
+ isSearchingForCutoffUnmetAlbums: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ onFilterSelect: PropTypes.func.isRequired,
+ onSearchSelectedPress: PropTypes.func.isRequired,
+ onToggleSelectedPress: PropTypes.func.isRequired,
+ onSearchAllCutoffUnmetPress: PropTypes.func.isRequired
+};
+
+export default CutoffUnmet;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js
new file mode 100644
index 000000000..423bdad53
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js
@@ -0,0 +1,190 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import * as wantedActions from 'Store/Actions/wantedActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions';
+import * as commandNames from 'Commands/commandNames';
+import CutoffUnmet from './CutoffUnmet';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.wanted.cutoffUnmet,
+ createCommandsSelector(),
+ (cutoffUnmet, commands) => {
+ const isSearchingForAlbums = _.some(commands, { name: commandNames.ALBUM_SEARCH });
+ const isSearchingForCutoffUnmetAlbums = _.some(commands, { name: commandNames.CUTOFF_UNMET_ALBUM_SEARCH });
+
+ return {
+ isSearchingForAlbums,
+ isSearchingForCutoffUnmetAlbums,
+ isSaving: _.some(cutoffUnmet.items, { isSaving: true }),
+ ...cutoffUnmet
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...wantedActions,
+ executeCommand,
+ fetchQueueDetails,
+ clearQueueDetails,
+ fetchTrackFiles,
+ clearTrackFiles
+};
+
+class CutoffUnmetConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ registerPagePopulator(this.repopulate);
+ this.props.gotoCutoffUnmetFirstPage();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const albumIds = selectUniqueIds(this.props.items, 'id');
+ const trackFileIds = selectUniqueIds(this.props.items, 'trackFileId');
+
+ this.props.fetchQueueDetails({ albumIds });
+
+ if (trackFileIds.length) {
+ this.props.fetchTrackFiles({ trackFileIds });
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearCutoffUnmet();
+ this.props.clearQueueDetails();
+ this.props.clearTrackFiles();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchCutoffUnmet();
+ }
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoCutoffUnmetFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoCutoffUnmetPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoCutoffUnmetNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoCutoffUnmetLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoCutoffUnmetPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setCutoffUnmetSort({ sortKey });
+ }
+
+ onFilterSelect = (filterKey, filterValue) => {
+ this.props.setCutoffUnmetFilter({ filterKey, filterValue });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setCutoffUnmetTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoCutoffUnmetFirstPage();
+ }
+ }
+
+ onSearchSelectedPress = (selected) => {
+ this.props.executeCommand({
+ name: commandNames.ALBUM_SEARCH,
+ albumIds: selected
+ });
+ }
+
+ onToggleSelectedPress = (selected) => {
+ const {
+ filterKey,
+ filterValue
+ } = this.props;
+
+ this.props.batchToggleCutoffUnmetAlbums({
+ albumIds: selected,
+ monitored: filterKey !== 'monitored' || !filterValue
+ });
+ }
+
+ onSearchAllCutoffUnmetPress = () => {
+ this.props.executeCommand({
+ name: commandNames.CUTOFF_UNMET_ALBUM_SEARCH
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CutoffUnmetConnector.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string.isRequired,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ fetchCutoffUnmet: PropTypes.func.isRequired,
+ gotoCutoffUnmetFirstPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetPreviousPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetNextPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetLastPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetPage: PropTypes.func.isRequired,
+ setCutoffUnmetSort: PropTypes.func.isRequired,
+ setCutoffUnmetFilter: PropTypes.func.isRequired,
+ setCutoffUnmetTableOption: PropTypes.func.isRequired,
+ batchToggleCutoffUnmetAlbums: PropTypes.func.isRequired,
+ clearCutoffUnmet: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired,
+ fetchTrackFiles: PropTypes.func.isRequired,
+ clearTrackFiles: PropTypes.func.isRequired
+};
+
+export default 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..1d224eb8e
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js
@@ -0,0 +1,139 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import episodeEntities from 'Album/episodeEntities';
+import EpisodeTitleLink from 'Album/EpisodeTitleLink';
+import EpisodeStatusConnector from 'Album/EpisodeStatusConnector';
+import EpisodeSearchCellConnector from 'Album/EpisodeSearchCellConnector';
+import TrackFileLanguageConnector from 'TrackFile/TrackFileLanguageConnector';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import styles from './CutoffUnmetRow.css';
+
+function CutoffUnmetRow(props) {
+ const {
+ id,
+ trackFileId,
+ artist,
+ releaseDate,
+ title,
+ isSelected,
+ columns,
+ onSelectedChange
+ } = props;
+
+ return (
+
+
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'artist.sortName') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'albumTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'releaseDate') {
+ return (
+
+ );
+ }
+
+ if (name === 'language') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'status') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+}
+
+CutoffUnmetRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ trackFileId: PropTypes.number,
+ artist: PropTypes.object.isRequired,
+ releaseDate: PropTypes.string.isRequired,
+ 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..625055c57
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRowConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import CutoffUnmetRow from './CutoffUnmetRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (artist) => {
+ return {
+ artist
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(CutoffUnmetRow);
diff --git a/frontend/src/Wanted/Missing/Missing.js b/frontend/src/Wanted/Missing/Missing.js
new file mode 100644
index 000000000..0914e3c3a
--- /dev/null
+++ b/frontend/src/Wanted/Missing/Missing.js
@@ -0,0 +1,307 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import { align, icons, kinds } from 'Helpers/Props';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TablePager from 'Components/Table/TablePager';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import MenuContent from 'Components/Menu/MenuContent';
+import FilterMenuItem from 'Components/Menu/FilterMenuItem';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
+import MissingRowConnector from './MissingRowConnector';
+
+class Missing extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmSearchAllMissingModalOpen: false,
+ isInteractiveImportModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ this.setState((state) => {
+ return removeOldSelectedState(state, prevProps.items);
+ });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ }
+
+ //
+ // Listeners
+
+ onFilterMenuItemPress = (filterKey, filterValue) => {
+ this.props.onFilterSelect(filterKey, filterValue);
+ }
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ }
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ }
+
+ onSearchSelectedPress = () => {
+ const selected = this.getSelectedIds();
+
+ this.props.onSearchSelectedPress(selected);
+ }
+
+ onToggleSelectedPress = () => {
+ const selected = this.getSelectedIds();
+
+ this.props.onToggleSelectedPress(selected);
+ }
+
+ onSearchAllMissingPress = () => {
+ this.setState({ isConfirmSearchAllMissingModalOpen: true });
+ }
+
+ onSearchAllMissingConfirmed = () => {
+ this.props.onSearchAllMissingPress();
+ this.setState({ isConfirmSearchAllMissingModalOpen: false });
+ }
+
+ onConfirmSearchAllMissingModalClose = () => {
+ this.setState({ isConfirmSearchAllMissingModalOpen: false });
+ }
+
+ onInteractiveImportPress = () => {
+ this.setState({ isInteractiveImportModalOpen: true });
+ }
+
+ onInteractiveImportModalClose = () => {
+ this.setState({ isInteractiveImportModalOpen: false });
+ }
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ columns,
+ totalRecords,
+ isSearchingForAlbums,
+ isSearchingForMissingAlbums,
+ isSaving,
+ filterKey,
+ filterValue,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmSearchAllMissingModalOpen,
+ isInteractiveImportModalOpen
+ } = this.state;
+
+ const itemsSelected = !!this.getSelectedIds().length;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monitored
+
+
+
+ Unmonitored
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && error &&
+
+ Error fetching missing items
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ No missing items
+
+ }
+
+ {
+ isPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+ Are you sure you want to search for all {totalRecords} missing albums?
+
+
+ This cannot be cancelled once started without restarting Lidarr.
+
+
+ }
+ confirmLabel="Search"
+ onConfirm={this.onSearchAllMissingConfirmed}
+ onCancel={this.onConfirmSearchAllMissingModalClose}
+ />
+
+
+
+ }
+
+
+ );
+ }
+}
+
+Missing.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isSearchingForAlbums: PropTypes.bool.isRequired,
+ isSearchingForMissingAlbums: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ filterKey: PropTypes.string,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ onFilterSelect: PropTypes.func.isRequired,
+ onSearchSelectedPress: PropTypes.func.isRequired,
+ onToggleSelectedPress: PropTypes.func.isRequired,
+ onSearchAllMissingPress: PropTypes.func.isRequired
+};
+
+export default Missing;
diff --git a/frontend/src/Wanted/Missing/MissingConnector.js b/frontend/src/Wanted/Missing/MissingConnector.js
new file mode 100644
index 000000000..80db02eb0
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingConnector.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 { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import * as wantedActions from 'Store/Actions/wantedActions';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
+import * as commandNames from 'Commands/commandNames';
+import Missing from './Missing';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.wanted.missing,
+ createCommandsSelector(),
+ (missing, commands) => {
+ const isSearchingForAlbums = _.some(commands, { name: commandNames.ALBUM_SEARCH });
+ const isSearchingForMissingAlbums = _.some(commands, { name: commandNames.MISSING_ALBUM_SEARCH });
+
+ return {
+ isSearchingForAlbums,
+ isSearchingForMissingAlbums,
+ isSaving: _.some(missing.items, { isSaving: true }),
+ ...missing
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...wantedActions,
+ executeCommand,
+ fetchQueueDetails,
+ clearQueueDetails
+};
+
+class MissingConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ registerPagePopulator(this.repopulate);
+ this.props.gotoMissingFirstPage();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const albumIds = selectUniqueIds(this.props.items, 'id');
+ this.props.fetchQueueDetails({ albumIds });
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearMissing();
+ this.props.clearQueueDetails();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchMissing();
+ }
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoMissingFirstPage();
+ }
+
+ onPreviousPagePress = () => {
+ this.props.gotoMissingPreviousPage();
+ }
+
+ onNextPagePress = () => {
+ this.props.gotoMissingNextPage();
+ }
+
+ onLastPagePress = () => {
+ this.props.gotoMissingLastPage();
+ }
+
+ onPageSelect = (page) => {
+ this.props.gotoMissingPage({ page });
+ }
+
+ onSortPress = (sortKey) => {
+ this.props.setMissingSort({ sortKey });
+ }
+
+ onFilterSelect = (filterKey, filterValue) => {
+ this.props.setMissingFilter({ filterKey, filterValue });
+ }
+
+ onTableOptionChange = (payload) => {
+ this.props.setMissingTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoMissingFirstPage();
+ }
+ }
+
+ onSearchSelectedPress = (selected) => {
+ this.props.executeCommand({
+ name: commandNames.ALBUM_SEARCH,
+ albumIds: selected
+ });
+ }
+
+ onToggleSelectedPress = (selected) => {
+ const {
+ filterKey,
+ filterValue
+ } = this.props;
+
+ this.props.batchToggleMissingAlbums({
+ albumIds: selected,
+ monitored: filterKey !== 'monitored' || !filterValue
+ });
+ }
+
+ onSearchAllMissingPress = () => {
+ this.props.executeCommand({
+ name: commandNames.MISSING_ALBUM_SEARCH
+ });
+ }
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MissingConnector.propTypes = {
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ filterKey: PropTypes.string.isRequired,
+ filterValue: PropTypes.oneOfType([PropTypes.bool, PropTypes.number, PropTypes.string]),
+ fetchMissing: PropTypes.func.isRequired,
+ gotoMissingFirstPage: PropTypes.func.isRequired,
+ gotoMissingPreviousPage: PropTypes.func.isRequired,
+ gotoMissingNextPage: PropTypes.func.isRequired,
+ gotoMissingLastPage: PropTypes.func.isRequired,
+ gotoMissingPage: PropTypes.func.isRequired,
+ setMissingSort: PropTypes.func.isRequired,
+ setMissingFilter: PropTypes.func.isRequired,
+ setMissingTableOption: PropTypes.func.isRequired,
+ clearMissing: PropTypes.func.isRequired,
+ batchToggleMissingAlbums: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired
+};
+
+export default connect(createMapStateToProps, mapDispatchToProps)(MissingConnector);
diff --git a/frontend/src/Wanted/Missing/MissingRow.css b/frontend/src/Wanted/Missing/MissingRow.css
new file mode 100644
index 000000000..3ec895d66
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRow.css
@@ -0,0 +1,6 @@
+.episode,
+.status {
+ composes: cell from 'Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Wanted/Missing/MissingRow.js b/frontend/src/Wanted/Missing/MissingRow.js
new file mode 100644
index 000000000..41c217482
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRow.js
@@ -0,0 +1,144 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import episodeEntities from 'Album/episodeEntities';
+import EpisodeTitleLink from 'Album/EpisodeTitleLink';
+import EpisodeStatusConnector from 'Album/EpisodeStatusConnector';
+import SeasonEpisodeNumber from 'Album/SeasonEpisodeNumber';
+import EpisodeSearchCellConnector from 'Album/EpisodeSearchCellConnector';
+import ArtistNameLink from 'Artist/ArtistNameLink';
+import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
+import TableRow from 'Components/Table/TableRow';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import styles from './MissingRow.css';
+
+function MissingRow(props) {
+ const {
+ id,
+ // trackFileId,
+ artist,
+ releaseDate,
+ title,
+ isSelected,
+ columns,
+ onSelectedChange
+ } = props;
+
+ return (
+
+
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'artist.sortName') {
+ return (
+
+
+
+ );
+ }
+
+ // if (name === 'episode') {
+ // return (
+ //
+ //
+ //
+ // );
+ // }
+
+ if (name === 'albumTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'releaseDate') {
+ return (
+
+ );
+ }
+
+ // if (name === 'status') {
+ // return (
+ //
+ //
+ //
+ // );
+ // }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+}
+
+MissingRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ // trackFileId: PropTypes.number,
+ artist: PropTypes.object.isRequired,
+ releaseDate: 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..f0a30d9cd
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRowConnector.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import createArtistSelector from 'Store/Selectors/createArtistSelector';
+import MissingRow from './MissingRow';
+
+function createMapStateToProps() {
+ return createSelector(
+ createArtistSelector(),
+ (artist) => {
+ return {
+ artist
+ };
+ }
+ );
+}
+
+export default connect(createMapStateToProps)(MissingRow);
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 000000000..04e0e11ef
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,15 @@
+html,
+body {
+ height: 100%; /* needed for proper layout */
+}
+
+body {
+ overflow: hidden;
+ background-color: #f5f7fa;
+}
+
+@media only screen and (max-width: $breakpointSmall) {
+ body {
+ overflow-y: auto;
+ }
+}
diff --git a/frontend/src/index.html b/frontend/src/index.html
new file mode 100644
index 000000000..fd93b8500
--- /dev/null
+++ b/frontend/src/index.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Lidarr (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..ca88b5141
--- /dev/null
+++ b/frontend/src/login.html
@@ -0,0 +1,220 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Login - Lidarr
+
+
+
+
+
+
+
+
+
+
+
+
+ SIGN IN TO CONTINUE
+
+
+
+
+
+
+
+ ©
+ 2010-2017
+ -
+ Lidarr
+
+
+
+
+
+
+
diff --git a/src/UI/oauth.html b/frontend/src/oauth.html
similarity index 84%
rename from src/UI/oauth.html
rename to frontend/src/oauth.html
index fe6ddf864..16a34dbf3 100644
--- a/src/UI/oauth.html
+++ b/frontend/src/oauth.html
@@ -2,7 +2,7 @@
- oauth landing page
+ OAuth landing page
@@ -10,4 +10,4 @@
Shouldn't see this
-
\ No newline at end of file
+
diff --git a/frontend/src/polyfills.js b/frontend/src/polyfills.js
new file mode 100644
index 000000000..b5d17d598
--- /dev/null
+++ b/frontend/src/polyfills.js
@@ -0,0 +1,41 @@
+/* eslint no-empty-function: 0 no-extend-native: 0 */
+
+window.console = window.console || {};
+window.console.log = window.console.log || function() {};
+window.console.group = window.console.group || function() {};
+window.console.groupEnd = window.console.groupEnd || function() {};
+window.console.debug = window.console.debug || function() {};
+window.console.warn = window.console.warn || function() {};
+window.console.assert = window.console.assert || function() {};
+
+if (!String.prototype.startsWith) {
+ Object.defineProperty(String.prototype, 'startsWith', {
+ enumerable: false,
+ configurable: false,
+ writable: false,
+ value(searchString, position) {
+ position = position || 0;
+ return this.indexOf(searchString, position) === position;
+ }
+ });
+}
+
+if (!String.prototype.endsWith) {
+ Object.defineProperty(String.prototype, 'endsWith', {
+ enumerable: false,
+ configurable: false,
+ writable: false,
+ value(searchString, position) {
+ position = position || this.length;
+ position = position - searchString.length;
+ const lastIndex = this.lastIndexOf(searchString);
+ return lastIndex !== -1 && lastIndex === position;
+ }
+ });
+}
+
+if (!('contains' in String.prototype)) {
+ String.prototype.contains = function(str, startIndex) {
+ return String.prototype.indexOf.call(this, str, startIndex) !== -1;
+ };
+}
diff --git a/frontend/src/preload.js b/frontend/src/preload.js
new file mode 100644
index 000000000..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..2b08817be
--- /dev/null
+++ b/frontend/src/vendor.js
@@ -0,0 +1,5 @@
+/* Base */
+// require('jquery');
+require('lodash');
+require('moment');
+// require('signalR');
diff --git a/gulp/build.js b/gulp/build.js
deleted file mode 100644
index 23f457baf..000000000
--- a/gulp/build.js
+++ /dev/null
@@ -1,18 +0,0 @@
-var gulp = require('gulp');
-var runSequence = require('run-sequence');
-
-require('./clean');
-require('./less');
-require('./handlebars');
-require('./copy');
-
-gulp.task('build', function() {
- return runSequence('clean', [
- 'webpack',
- 'less',
- 'handlebars',
- 'copyHtml',
- 'copyContent',
- 'copyJs'
- ]);
-});
diff --git a/gulp/clean.js b/gulp/clean.js
deleted file mode 100644
index 58f9c223f..000000000
--- a/gulp/clean.js
+++ /dev/null
@@ -1,8 +0,0 @@
-var gulp = require('gulp');
-var del = require('del');
-
-var paths = require('./paths');
-
-gulp.task('clean', function(cb) {
- del([paths.dest.root], cb);
-});
diff --git a/gulp/copy.js b/gulp/copy.js
deleted file mode 100644
index 9962defef..000000000
--- a/gulp/copy.js
+++ /dev/null
@@ -1,31 +0,0 @@
-var gulp = require('gulp');
-var print = require('gulp-print');
-var cache = require('gulp-cached');
-var livereload = require('gulp-livereload');
-
-var paths = require('./paths.js');
-
-gulp.task('copyJs', function () {
- return gulp.src(
- [
- paths.src.root + 'polyfills.js',
- paths.src.root + 'JsLibraries/handlebars.runtime.js'
- ])
- .pipe(cache('copyJs'))
- .pipe(print())
- .pipe(gulp.dest(paths.dest.root))
- .pipe(livereload());
-});
-
-gulp.task('copyHtml', function () {
- return gulp.src(paths.src.html)
- .pipe(cache('copyHtml'))
- .pipe(gulp.dest(paths.dest.root))
- .pipe(livereload());
-});
-
-gulp.task('copyContent', function () {
- return gulp.src([paths.src.content + '**/*.*', '!**/*.less', '!**/*.css'])
- .pipe(gulp.dest(paths.dest.content))
- .pipe(livereload());
-});
diff --git a/gulp/errorHandler.js b/gulp/errorHandler.js
deleted file mode 100644
index db24e1a66..000000000
--- a/gulp/errorHandler.js
+++ /dev/null
@@ -1,7 +0,0 @@
-module.exports = {
- onError : function(error) {
- //If you want details of the error in the console
- console.log(error.toString());
- this.emit('end');
- }
-};
\ No newline at end of file
diff --git a/gulp/gulpFile.js b/gulp/gulpFile.js
deleted file mode 100644
index 428fad285..000000000
--- a/gulp/gulpFile.js
+++ /dev/null
@@ -1,11 +0,0 @@
-require('./watch.js');
-require('./build.js');
-require('./clean.js');
-require('./jshint.js');
-require('./handlebars.js');
-require('./copy.js');
-require('./less.js');
-require('./stripBom.js');
-require('./imageMin.js');
-require('./webpack.js');
-require('./start.js');
diff --git a/gulp/handlebars.js b/gulp/handlebars.js
deleted file mode 100644
index aab62f438..000000000
--- a/gulp/handlebars.js
+++ /dev/null
@@ -1,55 +0,0 @@
-var gulp = require('gulp');
-var handlebars = require('gulp-handlebars');
-var declare = require('gulp-declare');
-var concat = require('gulp-concat');
-var wrap = require("gulp-wrap");
-var livereload = require('gulp-livereload');
-var path = require('path');
-var streamqueue = require('streamqueue');
-var stripbom = require('gulp-stripbom');
-
-var paths = require('./paths.js');
-
-gulp.task('handlebars', function() {
-
- var coreStream = gulp.src([
- paths.src.templates,
- '!*/**/*Partial.*'
- ])
- .pipe(stripbom({ showLog : false }))
- .pipe(handlebars())
- .pipe(declare({
- namespace : 'T',
- noRedeclare : true,
- processName : function(filePath) {
-
- filePath = path.relative(paths.src.root, filePath);
-
- return filePath.replace(/\\/g, '/')
- .toLocaleLowerCase()
- .replace('template', '')
- .replace('.js', '');
- }
- }));
-
- var partialStream = gulp.src([paths.src.partials])
- .pipe(stripbom({ showLog : false }))
- .pipe(handlebars())
- .pipe(wrap('Handlebars.template(<%= contents %>)'))
- .pipe(wrap('Handlebars.registerPartial(<%= processPartialName(file.relative) %>, <%= contents %>)', {}, {
- imports : {
- processPartialName : function(fileName) {
- return JSON.stringify(
- path.basename(fileName, '.js')
- );
- }
- }
- }));
-
- return streamqueue({ objectMode : true },
- partialStream,
- coreStream
- ).pipe(concat('templates.js'))
- .pipe(gulp.dest(paths.dest.root))
- .pipe(livereload());
-});
diff --git a/gulp/imageMin.js b/gulp/imageMin.js
deleted file mode 100644
index 6c8236e03..000000000
--- a/gulp/imageMin.js
+++ /dev/null
@@ -1,15 +0,0 @@
-var gulp = require('gulp');
-var print = require('gulp-print');
-var paths = require('./paths.js');
-
-gulp.task('imageMin', function() {
- var imagemin = require('gulp-imagemin');
- return gulp.src(paths.src.images)
- .pipe(imagemin({
- progressive : false,
- optimizationLevel : 4,
- svgoPlugins : [{ removeViewBox : false }]
- }))
- .pipe(print())
- .pipe(gulp.dest(paths.src.content + 'Images/'));
-});
\ No newline at end of file
diff --git a/gulp/jshint.js b/gulp/jshint.js
deleted file mode 100644
index 650ad02c2..000000000
--- a/gulp/jshint.js
+++ /dev/null
@@ -1,15 +0,0 @@
-var gulp = require('gulp');
-var jshint = require('gulp-jshint');
-var stylish = require('jshint-stylish');
-var cache = require('gulp-cached');
-var paths = require('./paths.js');
-
-gulp.task('jshint', function() {
- return gulp.src([
- paths.src.scripts,
- paths.src.exclude.libs
- ])
- .pipe(cache('jshint'))
- .pipe(jshint())
- .pipe(jshint.reporter(stylish));
-});
diff --git a/gulp/less.js b/gulp/less.js
deleted file mode 100644
index c91ebd538..000000000
--- a/gulp/less.js
+++ /dev/null
@@ -1,50 +0,0 @@
-var gulp = require('gulp');
-
-var less = require('gulp-less');
-var postcss = require('gulp-postcss');
-var sourcemaps = require('gulp-sourcemaps');
-var autoprefixer = require('autoprefixer-core');
-var livereload = require('gulp-livereload');
-
-var print = require('gulp-print');
-var paths = require('./paths');
-var errorHandler = require('./errorHandler');
-
-gulp.task('less', function() {
-
- var src = [
- paths.src.content + 'bootstrap.less',
- paths.src.content + 'theme.less',
- paths.src.content + 'overrides.less',
- paths.src.content + 'bootstrap.toggle-switch.css',
- paths.src.content + 'fullcalendar.css',
- paths.src.content + 'Messenger/messenger.css',
- paths.src.content + 'Messenger/messenger.flat.css',
- paths.src.root + 'Artist/artist.less',
- paths.src.root + 'Activity/activity.less',
- paths.src.root + 'AddArtist/addArtist.less',
- paths.src.root + 'Calendar/calendar.less',
- paths.src.root + 'Cells/cells.less',
- paths.src.root + 'ManualImport/manualimport.less',
- paths.src.root + 'Settings/settings.less',
- paths.src.root + 'System/Logs/logs.less',
- paths.src.root + 'System/Update/update.less',
- paths.src.root + 'System/Info/info.less'
- ];
-
- return gulp.src(src)
- .pipe(print())
- .pipe(sourcemaps.init())
- .pipe(less({
- dumpLineNumbers : 'false',
- compress : true,
- yuicompress : true,
- ieCompat : true,
- strictImports : true
- }))
- .pipe(postcss([ autoprefixer({ browsers: ['last 2 versions'] }) ]))
- .on('error', errorHandler.onError)
- .pipe(sourcemaps.write(paths.dest.content))
- .pipe(gulp.dest(paths.dest.content))
- .pipe(livereload());
-});
diff --git a/gulp/paths.js b/gulp/paths.js
deleted file mode 100644
index e05aa1d2b..000000000
--- a/gulp/paths.js
+++ /dev/null
@@ -1,21 +0,0 @@
-var paths = {
- src : {
- root : './src/UI/',
- templates : './src/UI/**/*.hbs',
- html : './src/UI/*.html',
- partials : './src/UI/**/*Partial.hbs',
- scripts : './src/UI/**/*.js',
- less : ['./src/UI/**/*.less'],
- content : './src/UI/Content/',
- images : './src/UI/Content/Images/**/*',
- exclude : {
- libs : '!./src/UI/JsLibraries/**'
- }
- },
- dest : {
- root : './_output/UI/',
- content : './_output/UI/Content/'
- }
-};
-
-module.exports = paths;
diff --git a/gulp/start.js b/gulp/start.js
deleted file mode 100644
index 296eb6836..000000000
--- a/gulp/start.js
+++ /dev/null
@@ -1,112 +0,0 @@
-// will download and run Lidarr (server) in a non-windows enviroment
-// you can use this if you don't care about the server code and just want to work
-// with the web code.
-
-var http = require('http');
-var gulp = require('gulp');
-var fs = require('fs');
-var targz = require('tar.gz');
-var del = require('del');
-var print = require('gulp-print');
-var spawn = require('child_process').spawn;
-
-function download(url, dest, cb) {
- console.log('Downloading ' + url + ' to ' + dest);
- var file = fs.createWriteStream(dest);
- var request = http.get(url, function (response) {
- response.pipe(file);
- file.on('finish', function () {
- console.log('Download completed');
- file.close(cb);
- });
- });
-}
-
-function getLatest(cb) {
- var branch = 'develop';
- process.argv.forEach(function (val) {
- var branchMatch = /branch=([\S]*)/.exec(val);
- if (branchMatch && branchMatch.length > 1) {
- branch = branchMatch[1];
- }
- });
-
- var url = 'http://services.lidarr.audio/v1/update/' + branch + '?os=osx';
-
- console.log('Checking for latest version:', url);
-
- http.get(url, function (res) {
- var data = '';
-
- res.on('data', function (chunk) {
- data += chunk;
- });
-
- res.on('end', function () {
- var updatePackage = JSON.parse(data).updatePackage;
- console.log('Latest version available: ' + updatePackage.version + ' Release Date: ' + updatePackage.releaseDate);
- cb(updatePackage);
- });
- }).on('error', function (e) {
- console.log('problem with request: ' + e.message);
- });
-}
-
-function extract(source, dest, cb) {
- console.log('extracting download page to ' + dest);
- new targz().extract(source, dest, function (err) {
- if (err) {
- console.log(err);
- }
- console.log('Update package extracted.');
- cb();
- });
-}
-
-gulp.task('getSonarr', function () {
-
- //gulp.src('/Users/kayone/git/Sonarr/_start/2.0.0.3288/NzbDrone/*.*')
- // .pipe(print())
- // .pipe(gulp.dest('./_output
-
- //return;
- try {
- fs.mkdirSync('./_start/');
- } catch (e) {
- if (e.code != 'EEXIST') {
- throw e;
- }
- }
-
- getLatest(function (package) {
- var packagePath = "./_start/" + package.filename;
- var dirName = "./_start/" + package.version;
- download(package.url, packagePath, function () {
- extract(packagePath, dirName, function () {
- // clean old binaries
- console.log('Cleaning old binaries');
- del.sync(['./_output/*', '!./_output/UI/']);
- console.log('copying binaries to target');
- gulp.src(dirName + '/NzbDrone/*.*')
- .pipe(gulp.dest('./_output/'));
- });
- });
- });
-});
-
-gulp.task('startSonarr', function () {
-
- var ls = spawn('mono', ['--debug', './_output/NzbDrone.exe']);
-
- ls.stdout.on('data', function (data) {
- process.stdout.write('' + data);
- });
-
- ls.stderr.on('data', function (data) {
- process.stdout.write('' + data);
- });
-
- ls.on('close', function (code) {
- console.log('child process exited with code ' + code);
- });
-});
diff --git a/gulp/stripBom.js b/gulp/stripBom.js
deleted file mode 100644
index 085e6b753..000000000
--- a/gulp/stripBom.js
+++ /dev/null
@@ -1,21 +0,0 @@
-var gulp = require('gulp');
-var paths = require('./paths.js');
-var stripbom = require('gulp-stripbom');
-
-var stripBom = function (dest) {
- gulp.src([paths.src.scripts, paths.src.exclude.libs])
- .pipe(stripbom({ showLog: false }))
- .pipe(gulp.dest(dest));
-
- gulp.src(paths.src.less)
- .pipe(stripbom({ showLog: false }))
- .pipe(gulp.dest(dest));
-
- gulp.src(paths.src.templates)
- .pipe(stripbom({ showLog: false }))
- .pipe(gulp.dest(dest));
-};
-
-gulp.task('stripBom', function () {
- stripBom(paths.src.root);
-});
diff --git a/gulp/watch.js b/gulp/watch.js
deleted file mode 100644
index f9145a464..000000000
--- a/gulp/watch.js
+++ /dev/null
@@ -1,20 +0,0 @@
-var gulp = require('gulp');
-var livereload = require('gulp-livereload');
-
-var paths = require('./paths.js');
-
-require('./jshint.js');
-require('./handlebars.js');
-require('./less.js');
-require('./copy.js');
-require('./webpack.js');
-
-gulp.task('watch', ['jshint', 'handlebars', 'less', 'copyHtml', 'copyContent', 'copyJs'], function () {
- livereload.listen();
- gulp.start('webpackWatch');
- gulp.watch([paths.src.scripts, paths.src.exclude.libs], ['jshint', 'copyJs']);
- gulp.watch(paths.src.templates, ['handlebars']);
- gulp.watch([paths.src.less, paths.src.exclude.libs], ['less']);
- gulp.watch([paths.src.html], ['copyHtml']);
- gulp.watch([paths.src.content + '**/*.*', '!**/*.less'], ['copyContent']);
-});
\ No newline at end of file
diff --git a/gulp/webpack.js b/gulp/webpack.js
deleted file mode 100644
index 64570593c..000000000
--- a/gulp/webpack.js
+++ /dev/null
@@ -1,13 +0,0 @@
-var gulp = require('gulp');
-var webpackStream = require('webpack-stream');
-var livereload = require('gulp-livereload');
-var webpackConfig = require('../webpack.config');
-
-gulp.task('webpack', function() {
- return gulp.src('main.js').pipe(webpackStream(webpackConfig)).pipe(gulp.dest(''));
-});
-
-gulp.task('webpackWatch', function() {
- webpackConfig.watch = true;
- return gulp.src('main.js').pipe(webpackStream(webpackConfig)).pipe(gulp.dest('')).pipe(livereload());
-});
diff --git a/gulpFile.js b/gulpFile.js
index 28dc9b0f1..73636a918 100644
--- a/gulpFile.js
+++ b/gulpFile.js
@@ -1 +1 @@
-require('./gulp/gulpFile.js');
+require('./frontend/gulp/gulpFile.js');
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 000000000..3e102a488
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,10437 @@
+{
+ "name": "lidarr",
+ "version": "1.0.0",
+ "lockfileVersion": 1,
+ "requires": true,
+ "dependencies": {
+ "@gulp-sourcemaps/identity-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-1.0.1.tgz",
+ "integrity": "sha1-z6I7xYQPkQTOMqZedNt+epdLvuE=",
+ "requires": {
+ "acorn": "5.1.2",
+ "css": "2.2.1",
+ "normalize-path": "2.1.1",
+ "source-map": "0.5.7",
+ "through2": "2.0.3"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ }
+ }
+ },
+ "@gulp-sourcemaps/map-sources": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@gulp-sourcemaps/map-sources/-/map-sources-1.0.0.tgz",
+ "integrity": "sha1-iQrnxdjId/bThIYCFazp1+yUW9o=",
+ "requires": {
+ "normalize-path": "2.1.1",
+ "through2": "2.0.3"
+ }
+ },
+ "acorn": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.1.2.tgz",
+ "integrity": "sha512-o96FZLJBPY1lvTuJylGA9Bk3t/GKPPJG8H0ydQQl01crzwJgspa4AEIq/pVTXigmK0PHVQhiAtn8WMBLL9D2WA=="
+ },
+ "acorn-dynamic-import": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/acorn-dynamic-import/-/acorn-dynamic-import-2.0.2.tgz",
+ "integrity": "sha1-x1K9IQvvZ5UBtsbLf8hPj0cVjMQ=",
+ "requires": {
+ "acorn": "4.0.13"
+ },
+ "dependencies": {
+ "acorn": {
+ "version": "4.0.13",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz",
+ "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c="
+ }
+ }
+ },
+ "acorn-jsx": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz",
+ "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=",
+ "requires": {
+ "acorn": "3.3.0"
+ },
+ "dependencies": {
+ "acorn": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz",
+ "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo="
+ }
+ }
+ },
+ "acorn-to-esprima": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/acorn-to-esprima/-/acorn-to-esprima-2.0.8.tgz",
+ "integrity": "sha1-AD8MZC65ITL0F9NwjxStqCrfLrE="
+ },
+ "add-px-to-style": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/add-px-to-style/-/add-px-to-style-1.0.0.tgz",
+ "integrity": "sha1-0ME1RB+oAUqBN5BFMQlvZ/KPJjo="
+ },
+ "ajv": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.2.3.tgz",
+ "integrity": "sha1-wG9Zh3jETGsWGrr+NGa4GtGBTtI=",
+ "requires": {
+ "co": "4.6.0",
+ "fast-deep-equal": "1.0.0",
+ "json-schema-traverse": "0.3.1",
+ "json-stable-stringify": "1.0.1"
+ }
+ },
+ "ajv-keywords": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-2.1.0.tgz",
+ "integrity": "sha1-opbhf3v658HOT34N5T0pyzIWLfA="
+ },
+ "align-text": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz",
+ "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=",
+ "requires": {
+ "kind-of": "3.2.2",
+ "longest": "1.0.1",
+ "repeat-string": "1.6.1"
+ }
+ },
+ "alphanum-sort": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz",
+ "integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM="
+ },
+ "amdefine": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz",
+ "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU="
+ },
+ "ansi-escapes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.0.0.tgz",
+ "integrity": "sha512-O/klc27mWNUigtv0F8NJWbLF00OcegQalkqKURWdosW08YZKi4m6CnSUSvIZG1otNJbTWhN01Hhz389DW7mvDQ=="
+ },
+ "ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
+ },
+ "ansi-styles": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.0.tgz",
+ "integrity": "sha512-NnSOmMEYtVR2JVMIGTzynRkkaxtiq1xnFBcdQD/DnNCYPoEPsVJhM98BDyaoNOQIi7p4okdi3E27eN7GQbsUug==",
+ "requires": {
+ "color-convert": "1.9.0"
+ }
+ },
+ "anymatch": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-1.3.2.tgz",
+ "integrity": "sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==",
+ "requires": {
+ "micromatch": "2.3.11",
+ "normalize-path": "2.1.1"
+ }
+ },
+ "archy": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz",
+ "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA="
+ },
+ "argparse": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.9.tgz",
+ "integrity": "sha1-c9g7wmP4bpf4zE9rrhsOkKfSLIY=",
+ "requires": {
+ "sprintf-js": "1.0.3"
+ }
+ },
+ "arr-diff": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz",
+ "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=",
+ "requires": {
+ "arr-flatten": "1.1.0"
+ }
+ },
+ "arr-flatten": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz",
+ "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg=="
+ },
+ "array-differ": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz",
+ "integrity": "sha1-7/UuN1gknTO+QCuLuOVkuytdQDE="
+ },
+ "array-each": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz",
+ "integrity": "sha1-p5SvDAWrF1KEbudTofIRoFugxE8="
+ },
+ "array-find-index": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz",
+ "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E="
+ },
+ "array-includes": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.0.3.tgz",
+ "integrity": "sha1-GEtI9i2S10UrsxsyMWXH+L0CJm0=",
+ "requires": {
+ "define-properties": "1.1.2",
+ "es-abstract": "1.9.0"
+ }
+ },
+ "array-slice": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.0.0.tgz",
+ "integrity": "sha1-5zA08A3MH0CHYAj9IP6ud71LfC8="
+ },
+ "array-union": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz",
+ "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=",
+ "requires": {
+ "array-uniq": "1.0.3"
+ }
+ },
+ "array-uniq": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz",
+ "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY="
+ },
+ "array-unique": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.2.1.tgz",
+ "integrity": "sha1-odl8yvy8JiXMcPrc6zalDFiwGlM="
+ },
+ "arrify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz",
+ "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0="
+ },
+ "asap": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
+ "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY="
+ },
+ "asn1.js": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.9.1.tgz",
+ "integrity": "sha1-SLokC0WpKA6UdImQull9IWYX/UA=",
+ "requires": {
+ "bn.js": "4.11.8",
+ "inherits": "2.0.3",
+ "minimalistic-assert": "1.0.0"
+ }
+ },
+ "assert": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.1.tgz",
+ "integrity": "sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE=",
+ "requires": {
+ "util": "0.10.3"
+ }
+ },
+ "async": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/async/-/async-2.5.0.tgz",
+ "integrity": "sha512-e+lJAJeNWuPCNyxZKOBdaJGyLGHugXVQtrAwtuAe2vhxTYxFTKE73p8JuTmdH0qdQZtDvI4dhJwjZc5zsfIsYw==",
+ "requires": {
+ "lodash": "4.17.4"
+ }
+ },
+ "async-each": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.1.tgz",
+ "integrity": "sha1-GdOGodntxufByF04iu28xW0zYC0="
+ },
+ "atob": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/atob/-/atob-1.1.3.tgz",
+ "integrity": "sha1-lfE2KbEsOlGl0hWr3OKqnzL4B3M="
+ },
+ "autoprefixer": {
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-7.1.5.tgz",
+ "integrity": "sha512-sMN453qIm8Z+tunzYWW+Y490wWkICHhCYm/VohLjjl+N7ARSFuF5au7E6tr7oEbeeXj8mNjpSw2kxjJaO6YCOw==",
+ "requires": {
+ "browserslist": "2.5.1",
+ "caniuse-lite": "1.0.30000748",
+ "normalize-range": "0.1.2",
+ "num2fraction": "1.2.2",
+ "postcss": "6.0.13",
+ "postcss-value-parser": "3.3.0"
+ }
+ },
+ "babel-code-frame": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz",
+ "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=",
+ "requires": {
+ "chalk": "1.1.3",
+ "esutils": "2.0.2",
+ "js-tokens": "3.0.2"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ }
+ },
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "babel-core": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.0.tgz",
+ "integrity": "sha1-rzL3izGm/O8RnIew/Y2XU/A6C7g=",
+ "requires": {
+ "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.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"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ }
+ }
+ },
+ "babel-eslint": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-8.0.1.tgz",
+ "integrity": "sha512-h3moF6PCTQE06UjMMG+ydZSBvZ4Q7rqPE/5WAUOvUyHYUTqxm8JVhjZRiG1avI/tGVOK4BnZLDQapyLzh8DeKg==",
+ "requires": {
+ "babel-code-frame": "7.0.0-beta.0",
+ "babel-traverse": "7.0.0-beta.0",
+ "babel-types": "7.0.0-beta.0",
+ "babylon": "7.0.0-beta.22"
+ },
+ "dependencies": {
+ "babel-code-frame": {
+ "version": "7.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-7.0.0-beta.0.tgz",
+ "integrity": "sha512-/xr1ADm5bnTjjN+xwoXb7lF4v2rnxMzNZzFU7h8SxB+qB6+IqSTOOqVcpaPTUC2Non/MbQxS3OIZnJpQ2X21aQ==",
+ "requires": {
+ "chalk": "2.2.0",
+ "esutils": "2.0.2",
+ "js-tokens": "3.0.2"
+ }
+ },
+ "babel-messages": {
+ "version": "7.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-7.0.0-beta.0.tgz",
+ "integrity": "sha512-eXdShsm9ZTh9AQhlIaAn6HR3xWpxCnK9ZwIDA9QyjnwTgMctGxHHflw4b4RJ3/ZjTL0Vrmvm0tQXPkp49mTAUw=="
+ },
+ "babel-traverse": {
+ "version": "7.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-7.0.0-beta.0.tgz",
+ "integrity": "sha512-IKzuTqUcQtMRZ0Vv5RjIrGGj33eBKmNTNeRexWSyjPPuAciyNkva1rt7WXPfHfkb+dX7coRAIUhzeTUEzhnwdA==",
+ "requires": {
+ "babel-code-frame": "7.0.0-beta.0",
+ "babel-helper-function-name": "7.0.0-beta.0",
+ "babel-messages": "7.0.0-beta.0",
+ "babel-types": "7.0.0-beta.0",
+ "babylon": "7.0.0-beta.22",
+ "debug": "3.1.0",
+ "globals": "10.1.0",
+ "invariant": "2.2.2",
+ "lodash": "4.17.4"
+ }
+ },
+ "babel-types": {
+ "version": "7.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-7.0.0-beta.0.tgz",
+ "integrity": "sha512-rJc2kV9iPJGLlqIY71AM3nPcdkoeLRCDuR07GFgfd3lFl4TsBQq76TxYQQIZ2MONg1HpsqmuoCXr9aZ1Oa4wYw==",
+ "requires": {
+ "esutils": "2.0.2",
+ "lodash": "4.17.4",
+ "to-fast-properties": "2.0.0"
+ }
+ },
+ "babylon": {
+ "version": "7.0.0-beta.22",
+ "resolved": "https://registry.npmjs.org/babylon/-/babylon-7.0.0-beta.22.tgz",
+ "integrity": "sha512-Yl7iT8QGrS8OfR7p6R12AJexQm+brKwrryai4VWZ7NHUbPoZ5al3+klhvl/14shXZiLa7uK//OIFuZ1/RKHgoA=="
+ },
+ "debug": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+ "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "globals": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-10.1.0.tgz",
+ "integrity": "sha1-RCWhiBvg0za0qCOoKnvnJdXdmHw="
+ },
+ "to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4="
+ }
+ }
+ },
+ "babel-generator": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.0.tgz",
+ "integrity": "sha1-rBriAHC3n248odMmlhMFN3TyDcU=",
+ "requires": {
+ "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.7",
+ "trim-right": "1.0.1"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ }
+ }
+ },
+ "babel-helper-bindify-decorators": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-bindify-decorators/-/babel-helper-bindify-decorators-6.24.1.tgz",
+ "integrity": "sha1-FMGeXxQte0fxmlJDHlKxzLxAozA=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-traverse": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-helper-builder-binary-assignment-operator-visitor": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz",
+ "integrity": "sha1-zORReto1b0IgvK6KAsKzRvmlZmQ=",
+ "requires": {
+ "babel-helper-explode-assignable-expression": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-helper-builder-react-jsx": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-helper-builder-react-jsx/-/babel-helper-builder-react-jsx-6.26.0.tgz",
+ "integrity": "sha1-Of+DE7dci2Xc7/HzHTg+D/KkCKA=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0",
+ "esutils": "2.0.2"
+ }
+ },
+ "babel-helper-call-delegate": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz",
+ "integrity": "sha1-7Oaqzdx25Bw0YfiL/Fdb0Nqi340=",
+ "requires": {
+ "babel-helper-hoist-variables": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-traverse": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-helper-define-map": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz",
+ "integrity": "sha1-pfVtq0GiX5fstJjH66ypgZ+Vvl8=",
+ "requires": {
+ "babel-helper-function-name": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0",
+ "lodash": "4.17.4"
+ },
+ "dependencies": {
+ "babel-helper-function-name": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz",
+ "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=",
+ "requires": {
+ "babel-helper-get-function-arity": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0",
+ "babel-traverse": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-helper-get-function-arity": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz",
+ "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ }
+ }
+ },
+ "babel-helper-explode-assignable-expression": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz",
+ "integrity": "sha1-8luCz33BBDPFX3BZLVdGQArCLKo=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-traverse": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-helper-explode-class": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-explode-class/-/babel-helper-explode-class-6.24.1.tgz",
+ "integrity": "sha1-fcKjkQ3uAHBW4eMdZAztPVTqqes=",
+ "requires": {
+ "babel-helper-bindify-decorators": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-traverse": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-helper-function-name": {
+ "version": "7.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-7.0.0-beta.0.tgz",
+ "integrity": "sha512-DaQccFBBWBEzMdqbKmNXamY0m1yLHJGOdbbEsNoGdJrrU7wAF3wwowtDDPzF0ZT3SqJXPgZW/P2kgBX9moMuAA==",
+ "requires": {
+ "babel-helper-get-function-arity": "7.0.0-beta.0",
+ "babel-template": "7.0.0-beta.0",
+ "babel-traverse": "7.0.0-beta.0",
+ "babel-types": "7.0.0-beta.0"
+ },
+ "dependencies": {
+ "babel-code-frame": {
+ "version": "7.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-7.0.0-beta.0.tgz",
+ "integrity": "sha512-/xr1ADm5bnTjjN+xwoXb7lF4v2rnxMzNZzFU7h8SxB+qB6+IqSTOOqVcpaPTUC2Non/MbQxS3OIZnJpQ2X21aQ==",
+ "requires": {
+ "chalk": "2.2.0",
+ "esutils": "2.0.2",
+ "js-tokens": "3.0.2"
+ }
+ },
+ "babel-messages": {
+ "version": "7.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-7.0.0-beta.0.tgz",
+ "integrity": "sha512-eXdShsm9ZTh9AQhlIaAn6HR3xWpxCnK9ZwIDA9QyjnwTgMctGxHHflw4b4RJ3/ZjTL0Vrmvm0tQXPkp49mTAUw=="
+ },
+ "babel-template": {
+ "version": "7.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-7.0.0-beta.0.tgz",
+ "integrity": "sha512-tmdH+MmmU0F6Ur8humpevSmFzYKbrN3Oru0g5Qyg4R6+sxjnzZmnvzUbsP0aKMr7tB0Ua6xhEb9arKTOsEMkyA==",
+ "requires": {
+ "babel-traverse": "7.0.0-beta.0",
+ "babel-types": "7.0.0-beta.0",
+ "babylon": "7.0.0-beta.22",
+ "lodash": "4.17.4"
+ }
+ },
+ "babel-traverse": {
+ "version": "7.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-7.0.0-beta.0.tgz",
+ "integrity": "sha512-IKzuTqUcQtMRZ0Vv5RjIrGGj33eBKmNTNeRexWSyjPPuAciyNkva1rt7WXPfHfkb+dX7coRAIUhzeTUEzhnwdA==",
+ "requires": {
+ "babel-code-frame": "7.0.0-beta.0",
+ "babel-helper-function-name": "7.0.0-beta.0",
+ "babel-messages": "7.0.0-beta.0",
+ "babel-types": "7.0.0-beta.0",
+ "babylon": "7.0.0-beta.22",
+ "debug": "3.1.0",
+ "globals": "10.1.0",
+ "invariant": "2.2.2",
+ "lodash": "4.17.4"
+ }
+ },
+ "babel-types": {
+ "version": "7.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-7.0.0-beta.0.tgz",
+ "integrity": "sha512-rJc2kV9iPJGLlqIY71AM3nPcdkoeLRCDuR07GFgfd3lFl4TsBQq76TxYQQIZ2MONg1HpsqmuoCXr9aZ1Oa4wYw==",
+ "requires": {
+ "esutils": "2.0.2",
+ "lodash": "4.17.4",
+ "to-fast-properties": "2.0.0"
+ }
+ },
+ "babylon": {
+ "version": "7.0.0-beta.22",
+ "resolved": "https://registry.npmjs.org/babylon/-/babylon-7.0.0-beta.22.tgz",
+ "integrity": "sha512-Yl7iT8QGrS8OfR7p6R12AJexQm+brKwrryai4VWZ7NHUbPoZ5al3+klhvl/14shXZiLa7uK//OIFuZ1/RKHgoA=="
+ },
+ "debug": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+ "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "globals": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-10.1.0.tgz",
+ "integrity": "sha1-RCWhiBvg0za0qCOoKnvnJdXdmHw="
+ },
+ "to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4="
+ }
+ }
+ },
+ "babel-helper-get-function-arity": {
+ "version": "7.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-7.0.0-beta.0.tgz",
+ "integrity": "sha512-csqAic15/2Vm1951nJxkkL9K8E6ojyNF/eAOjk7pqJlO8kvgrccGNFCV9eDwcGHDPe5AjvJGwVSAcQ5fit9wuA==",
+ "requires": {
+ "babel-types": "7.0.0-beta.0"
+ },
+ "dependencies": {
+ "babel-types": {
+ "version": "7.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-7.0.0-beta.0.tgz",
+ "integrity": "sha512-rJc2kV9iPJGLlqIY71AM3nPcdkoeLRCDuR07GFgfd3lFl4TsBQq76TxYQQIZ2MONg1HpsqmuoCXr9aZ1Oa4wYw==",
+ "requires": {
+ "esutils": "2.0.2",
+ "lodash": "4.17.4",
+ "to-fast-properties": "2.0.0"
+ }
+ },
+ "to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4="
+ }
+ }
+ },
+ "babel-helper-hoist-variables": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz",
+ "integrity": "sha1-HssnaJydJVE+rbyZFKc/VAi+enY=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-helper-optimise-call-expression": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz",
+ "integrity": "sha1-96E0J7qfc/j0+pk8VKl4gtEkQlc=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-helper-regex": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz",
+ "integrity": "sha1-MlxZ+QL4LyS3T6zu0DY5VPZJXnI=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0",
+ "lodash": "4.17.4"
+ }
+ },
+ "babel-helper-remap-async-to-generator": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz",
+ "integrity": "sha1-XsWBgnrXI/7N04HxySg5BnbkVRs=",
+ "requires": {
+ "babel-helper-function-name": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0",
+ "babel-traverse": "6.26.0",
+ "babel-types": "6.26.0"
+ },
+ "dependencies": {
+ "babel-helper-function-name": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz",
+ "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=",
+ "requires": {
+ "babel-helper-get-function-arity": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0",
+ "babel-traverse": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-helper-get-function-arity": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz",
+ "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ }
+ }
+ },
+ "babel-helper-replace-supers": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz",
+ "integrity": "sha1-v22/5Dk40XNpohPKiov3S2qQqxo=",
+ "requires": {
+ "babel-helper-optimise-call-expression": "6.24.1",
+ "babel-messages": "6.23.0",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0",
+ "babel-traverse": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-helpers": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.24.1.tgz",
+ "integrity": "sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0"
+ }
+ },
+ "babel-loader": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-7.1.2.tgz",
+ "integrity": "sha512-jRwlFbINAeyDStqK6Dd5YuY0k5YuzQUvlz2ZamuXrXmxav3pNqe9vfJ402+2G+OmlJSXxCOpB6Uz0INM7RQe2A==",
+ "requires": {
+ "find-cache-dir": "1.0.0",
+ "loader-utils": "1.1.0",
+ "mkdirp": "0.5.1"
+ }
+ },
+ "babel-messages": {
+ "version": "6.23.0",
+ "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz",
+ "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=",
+ "requires": {
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-check-es2015-constants": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz",
+ "integrity": "sha1-NRV7EBQm/S/9PaP3XH0ekYNbv4o=",
+ "requires": {
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-syntax-async-functions": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz",
+ "integrity": "sha1-ytnK0RkbWtY0vzCuCHI5HgZHvpU="
+ },
+ "babel-plugin-syntax-async-generators": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-generators/-/babel-plugin-syntax-async-generators-6.13.0.tgz",
+ "integrity": "sha1-a8lj67FuzLrmuStZbrfzXDQqi5o="
+ },
+ "babel-plugin-syntax-class-properties": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz",
+ "integrity": "sha1-1+sjt5oxf4VDlixQW4J8fWysJ94="
+ },
+ "babel-plugin-syntax-decorators": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-decorators/-/babel-plugin-syntax-decorators-6.13.0.tgz",
+ "integrity": "sha1-MSVjtNvePMgGzuPkFszurd0RrAs="
+ },
+ "babel-plugin-syntax-dynamic-import": {
+ "version": "6.18.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz",
+ "integrity": "sha1-jWomIpyDdFqZgqRBBRVyyqF5sdo="
+ },
+ "babel-plugin-syntax-exponentiation-operator": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz",
+ "integrity": "sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4="
+ },
+ "babel-plugin-syntax-flow": {
+ "version": "6.18.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-flow/-/babel-plugin-syntax-flow-6.18.0.tgz",
+ "integrity": "sha1-TDqyCiryaqIM0lmVw5jE63AxDI0="
+ },
+ "babel-plugin-syntax-jsx": {
+ "version": "6.18.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
+ "integrity": "sha1-CvMqmm4Tyno/1QaeYtew9Y0NiUY="
+ },
+ "babel-plugin-syntax-object-rest-spread": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz",
+ "integrity": "sha1-/WU28rzhODb/o6VFjEkDpZe7O/U="
+ },
+ "babel-plugin-syntax-trailing-function-commas": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz",
+ "integrity": "sha1-ugNgk3+NBuQBgKQ/4NVhb/9TLPM="
+ },
+ "babel-plugin-transform-async-generator-functions": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-generator-functions/-/babel-plugin-transform-async-generator-functions-6.24.1.tgz",
+ "integrity": "sha1-8FiQAUX9PpkHpt3yjaWfIVJYpds=",
+ "requires": {
+ "babel-helper-remap-async-to-generator": "6.24.1",
+ "babel-plugin-syntax-async-generators": "6.13.0",
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-async-to-generator": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz",
+ "integrity": "sha1-ZTbjeK/2yx1VF6wOQOs+n8jQh2E=",
+ "requires": {
+ "babel-helper-remap-async-to-generator": "6.24.1",
+ "babel-plugin-syntax-async-functions": "6.13.0",
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-class-properties": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-class-properties/-/babel-plugin-transform-class-properties-6.24.1.tgz",
+ "integrity": "sha1-anl2PqYdM9NvN7YRqp3vgagbRqw=",
+ "requires": {
+ "babel-helper-function-name": "6.24.1",
+ "babel-plugin-syntax-class-properties": "6.13.0",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0"
+ },
+ "dependencies": {
+ "babel-helper-function-name": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz",
+ "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=",
+ "requires": {
+ "babel-helper-get-function-arity": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0",
+ "babel-traverse": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-helper-get-function-arity": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz",
+ "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ }
+ }
+ },
+ "babel-plugin-transform-decorators": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-decorators/-/babel-plugin-transform-decorators-6.24.1.tgz",
+ "integrity": "sha1-eIAT2PjGtSIr33s0Q5Df13Vp4k0=",
+ "requires": {
+ "babel-helper-explode-class": "6.24.1",
+ "babel-plugin-syntax-decorators": "6.13.0",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-decorators-legacy": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-decorators-legacy/-/babel-plugin-transform-decorators-legacy-1.3.4.tgz",
+ "integrity": "sha1-dBtY9sW86eYCfgiC2cmU8E82aSU=",
+ "requires": {
+ "babel-plugin-syntax-decorators": "6.13.0",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-arrow-functions": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz",
+ "integrity": "sha1-RSaSy3EdX3ncf4XkQM5BufJE0iE=",
+ "requires": {
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-block-scoped-functions": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz",
+ "integrity": "sha1-u8UbSflk1wy42OC5ToICRs46YUE=",
+ "requires": {
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-block-scoping": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz",
+ "integrity": "sha1-1w9SmcEwjQXBL0Y4E7CgnnOxiV8=",
+ "requires": {
+ "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": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz",
+ "integrity": "sha1-WkxYpQyclGHlZLSyo7+ryXolhNs=",
+ "requires": {
+ "babel-helper-define-map": "6.26.0",
+ "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.26.0",
+ "babel-template": "6.26.0",
+ "babel-traverse": "6.26.0",
+ "babel-types": "6.26.0"
+ },
+ "dependencies": {
+ "babel-helper-function-name": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz",
+ "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=",
+ "requires": {
+ "babel-helper-get-function-arity": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0",
+ "babel-traverse": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-helper-get-function-arity": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz",
+ "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ }
+ }
+ },
+ "babel-plugin-transform-es2015-computed-properties": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz",
+ "integrity": "sha1-b+Ko0WiV1WNPTNmZttNICjCBWbM=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-destructuring": {
+ "version": "6.23.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz",
+ "integrity": "sha1-mXux8auWf2gtKwh2/jWNYOdlxW0=",
+ "requires": {
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-duplicate-keys": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz",
+ "integrity": "sha1-c+s9MQypaePvnskcU3QabxV2Qj4=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-for-of": {
+ "version": "6.23.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz",
+ "integrity": "sha1-9HyVsrYT3x0+zC/bdXNiPHUkhpE=",
+ "requires": {
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-function-name": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz",
+ "integrity": "sha1-g0yJhTvDaxrw86TF26qU/Y6sqos=",
+ "requires": {
+ "babel-helper-function-name": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ },
+ "dependencies": {
+ "babel-helper-function-name": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz",
+ "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=",
+ "requires": {
+ "babel-helper-get-function-arity": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0",
+ "babel-traverse": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-helper-get-function-arity": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz",
+ "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ }
+ }
+ },
+ "babel-plugin-transform-es2015-literals": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz",
+ "integrity": "sha1-T1SgLWzWbPkVKAAZox0xklN3yi4=",
+ "requires": {
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-modules-amd": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz",
+ "integrity": "sha1-Oz5UAXI5hC1tGcMBHEvS8AoA0VQ=",
+ "requires": {
+ "babel-plugin-transform-es2015-modules-commonjs": "6.26.0",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-modules-commonjs": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.0.tgz",
+ "integrity": "sha1-DYOUApt9xqvhqX7xgeAHWN0uXYo=",
+ "requires": {
+ "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": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz",
+ "integrity": "sha1-/4mhQrkRmpBhlfXxBuzzBdlAfSM=",
+ "requires": {
+ "babel-helper-hoist-variables": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-modules-umd": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz",
+ "integrity": "sha1-rJl+YoXNGO1hdq22B9YCNErThGg=",
+ "requires": {
+ "babel-plugin-transform-es2015-modules-amd": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-object-super": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz",
+ "integrity": "sha1-JM72muIcuDp/hgPa0CH1cusnj40=",
+ "requires": {
+ "babel-helper-replace-supers": "6.24.1",
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-parameters": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz",
+ "integrity": "sha1-V6w1GrScrxSpfNE7CfZv3wpiXys=",
+ "requires": {
+ "babel-helper-call-delegate": "6.24.1",
+ "babel-helper-get-function-arity": "6.24.1",
+ "babel-runtime": "6.26.0",
+ "babel-template": "6.26.0",
+ "babel-traverse": "6.26.0",
+ "babel-types": "6.26.0"
+ },
+ "dependencies": {
+ "babel-helper-get-function-arity": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz",
+ "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ }
+ }
+ },
+ "babel-plugin-transform-es2015-shorthand-properties": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz",
+ "integrity": "sha1-JPh11nIch2YbvZmkYi5R8U3jiqA=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-spread": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz",
+ "integrity": "sha1-1taKmfia7cRTbIGlQujdnxdG+NE=",
+ "requires": {
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-sticky-regex": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz",
+ "integrity": "sha1-AMHNsaynERLN8M9hJsLta0V8zbw=",
+ "requires": {
+ "babel-helper-regex": "6.26.0",
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-template-literals": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz",
+ "integrity": "sha1-qEs0UPfp+PH2g51taH2oS7EjbY0=",
+ "requires": {
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-typeof-symbol": {
+ "version": "6.23.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz",
+ "integrity": "sha1-3sCfHN3/lLUqxz1QXITfWdzOs3I=",
+ "requires": {
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-unicode-regex": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz",
+ "integrity": "sha1-04sS9C6nMj9yk4fxinxa4frrNek=",
+ "requires": {
+ "babel-helper-regex": "6.26.0",
+ "babel-runtime": "6.26.0",
+ "regexpu-core": "2.0.0"
+ }
+ },
+ "babel-plugin-transform-exponentiation-operator": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz",
+ "integrity": "sha1-KrDJx/MJj6SJB3cruBP+QejeOg4=",
+ "requires": {
+ "babel-helper-builder-binary-assignment-operator-visitor": "6.24.1",
+ "babel-plugin-syntax-exponentiation-operator": "6.13.0",
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-flow-strip-types": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-strip-types/-/babel-plugin-transform-flow-strip-types-6.22.0.tgz",
+ "integrity": "sha1-hMtnKTXUNxT9wyvOhFaNh0Qc988=",
+ "requires": {
+ "babel-plugin-syntax-flow": "6.18.0",
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-object-rest-spread": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-object-rest-spread/-/babel-plugin-transform-object-rest-spread-6.26.0.tgz",
+ "integrity": "sha1-DzZpLVD+9rfi1LOsFHgTepY7ewY=",
+ "requires": {
+ "babel-plugin-syntax-object-rest-spread": "6.13.0",
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-react-display-name": {
+ "version": "6.25.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-display-name/-/babel-plugin-transform-react-display-name-6.25.0.tgz",
+ "integrity": "sha1-Z+K/Hx6ck6sI25Z5LgU5K/LMKNE=",
+ "requires": {
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-react-jsx": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx/-/babel-plugin-transform-react-jsx-6.24.1.tgz",
+ "integrity": "sha1-hAoCjn30YN/DotKfDA2R9jduZqM=",
+ "requires": {
+ "babel-helper-builder-react-jsx": "6.26.0",
+ "babel-plugin-syntax-jsx": "6.18.0",
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-react-jsx-self": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-self/-/babel-plugin-transform-react-jsx-self-6.22.0.tgz",
+ "integrity": "sha1-322AqdomEqEh5t3XVYvL7PBuY24=",
+ "requires": {
+ "babel-plugin-syntax-jsx": "6.18.0",
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-react-jsx-source": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-jsx-source/-/babel-plugin-transform-react-jsx-source-6.22.0.tgz",
+ "integrity": "sha1-ZqwSFT9c0tF7PBkmj0vwGX9E7NY=",
+ "requires": {
+ "babel-plugin-syntax-jsx": "6.18.0",
+ "babel-runtime": "6.26.0"
+ }
+ },
+ "babel-plugin-transform-regenerator": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz",
+ "integrity": "sha1-4HA2lvveJ/Cj78rPi03KL3s6jy8=",
+ "requires": {
+ "regenerator-transform": "0.10.1"
+ }
+ },
+ "babel-plugin-transform-strict-mode": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz",
+ "integrity": "sha1-1fr3qleKZbvlkc9e2uBKDGcCB1g=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0"
+ }
+ },
+ "babel-preset-decorators-legacy": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/babel-preset-decorators-legacy/-/babel-preset-decorators-legacy-1.0.0.tgz",
+ "integrity": "sha1-h3cuxTA8Wjt0jORQyEAJdWYtFzE=",
+ "requires": {
+ "babel-plugin-transform-decorators-legacy": "1.3.4"
+ }
+ },
+ "babel-preset-es2015": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-preset-es2015/-/babel-preset-es2015-6.24.1.tgz",
+ "integrity": "sha1-1EBQ1rwsn+6nAqrzjXJ6AhBTiTk=",
+ "requires": {
+ "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.26.0",
+ "babel-plugin-transform-es2015-classes": "6.24.1",
+ "babel-plugin-transform-es2015-computed-properties": "6.24.1",
+ "babel-plugin-transform-es2015-destructuring": "6.23.0",
+ "babel-plugin-transform-es2015-duplicate-keys": "6.24.1",
+ "babel-plugin-transform-es2015-for-of": "6.23.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.26.0",
+ "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.23.0",
+ "babel-plugin-transform-es2015-unicode-regex": "6.24.1",
+ "babel-plugin-transform-regenerator": "6.26.0"
+ }
+ },
+ "babel-preset-flow": {
+ "version": "6.23.0",
+ "resolved": "https://registry.npmjs.org/babel-preset-flow/-/babel-preset-flow-6.23.0.tgz",
+ "integrity": "sha1-5xIYiHCFrpoktb5Baa/7WZgWxJ0=",
+ "requires": {
+ "babel-plugin-transform-flow-strip-types": "6.22.0"
+ }
+ },
+ "babel-preset-react": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-preset-react/-/babel-preset-react-6.24.1.tgz",
+ "integrity": "sha1-umnfrqRfw+xjm2pOzqbhdwLJE4A=",
+ "requires": {
+ "babel-plugin-syntax-jsx": "6.18.0",
+ "babel-plugin-transform-react-display-name": "6.25.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": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-preset-stage-2/-/babel-preset-stage-2-6.24.1.tgz",
+ "integrity": "sha1-2eKWD7PXEYfw5k7sYrwHdnIZvcE=",
+ "requires": {
+ "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": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-preset-stage-3/-/babel-preset-stage-3-6.24.1.tgz",
+ "integrity": "sha1-g2raCp56f6N8sTj7kyb4eTSkg5U=",
+ "requires": {
+ "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.26.0"
+ }
+ },
+ "babel-register": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.26.0.tgz",
+ "integrity": "sha1-btAhFz4vy0htestFxgCahW9kcHE=",
+ "requires": {
+ "babel-core": "6.26.0",
+ "babel-runtime": "6.26.0",
+ "core-js": "2.5.1",
+ "home-or-tmp": "2.0.0",
+ "lodash": "4.17.4",
+ "mkdirp": "0.5.1",
+ "source-map-support": "0.4.18"
+ }
+ },
+ "babel-runtime": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
+ "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
+ "requires": {
+ "core-js": "2.5.1",
+ "regenerator-runtime": "0.11.0"
+ }
+ },
+ "babel-template": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz",
+ "integrity": "sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI=",
+ "requires": {
+ "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": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz",
+ "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=",
+ "requires": {
+ "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.9",
+ "globals": "9.18.0",
+ "invariant": "2.2.2",
+ "lodash": "4.17.4"
+ }
+ },
+ "babel-types": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz",
+ "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "esutils": "2.0.2",
+ "lodash": "4.17.4",
+ "to-fast-properties": "1.0.3"
+ }
+ },
+ "babylon": {
+ "version": "6.18.0",
+ "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz",
+ "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ=="
+ },
+ "balanced-match": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+ "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
+ },
+ "base64-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.2.1.tgz",
+ "integrity": "sha512-dwVUVIXsBZXwTuwnXI9RK8sBmgq09NDHzyR9SAph9eqk76gKK2JSQmZARC2zRC81JC2QTtxD0ARU5qTS25gIGw=="
+ },
+ "beeper": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/beeper/-/beeper-1.1.1.tgz",
+ "integrity": "sha1-5tXqjF2tABMEpwsiY4RH9pyy+Ak="
+ },
+ "big.js": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz",
+ "integrity": "sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q=="
+ },
+ "binary-extensions": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.10.0.tgz",
+ "integrity": "sha1-muuabF6IY4qtFx4Wf1kAq+JINdA="
+ },
+ "bl": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.1.tgz",
+ "integrity": "sha1-ysMo977kVzDUBLaSID/LWQ4XLV4=",
+ "requires": {
+ "readable-stream": "2.3.3"
+ }
+ },
+ "block-stream": {
+ "version": "0.0.9",
+ "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz",
+ "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=",
+ "requires": {
+ "inherits": "2.0.3"
+ }
+ },
+ "bluebird": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz",
+ "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA=="
+ },
+ "bn.js": {
+ "version": "4.11.8",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz",
+ "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA=="
+ },
+ "body-parser": {
+ "version": "1.14.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.14.2.tgz",
+ "integrity": "sha1-EBXLH+LEQ4WCWVgdtTMy+NDPUPk=",
+ "requires": {
+ "bytes": "2.2.0",
+ "content-type": "1.0.4",
+ "debug": "2.2.0",
+ "depd": "1.1.1",
+ "http-errors": "1.3.1",
+ "iconv-lite": "0.4.13",
+ "on-finished": "2.3.0",
+ "qs": "5.2.0",
+ "raw-body": "2.1.7",
+ "type-is": "1.6.15"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
+ "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=",
+ "requires": {
+ "ms": "0.7.1"
+ }
+ },
+ "iconv-lite": {
+ "version": "0.4.13",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz",
+ "integrity": "sha1-H4irpKsLFQjoMSrMOTRfNumS4vI="
+ },
+ "ms": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz",
+ "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg="
+ },
+ "qs": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-5.2.0.tgz",
+ "integrity": "sha1-qfMRQq9GjLcrJbMBNrokVoNJFr4="
+ }
+ }
+ },
+ "brace-expansion": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz",
+ "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=",
+ "requires": {
+ "balanced-match": "1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "braces": {
+ "version": "1.8.5",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz",
+ "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=",
+ "requires": {
+ "expand-range": "1.8.2",
+ "preserve": "0.2.0",
+ "repeat-element": "1.1.2"
+ }
+ },
+ "brorand": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
+ "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8="
+ },
+ "browserify-aes": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.1.1.tgz",
+ "integrity": "sha512-UGnTYAnB2a3YuYKIRy1/4FB2HdM866E0qC46JXvVTYKlBlZlnvfpSfY6OKfXZAkv70eJ2a1SqzpAo5CRhZGDFg==",
+ "requires": {
+ "buffer-xor": "1.0.3",
+ "cipher-base": "1.0.4",
+ "create-hash": "1.1.3",
+ "evp_bytestokey": "1.0.3",
+ "inherits": "2.0.3",
+ "safe-buffer": "5.1.1"
+ }
+ },
+ "browserify-cipher": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.0.tgz",
+ "integrity": "sha1-mYgkSHS/XtTijalWZtzWasj8Njo=",
+ "requires": {
+ "browserify-aes": "1.1.1",
+ "browserify-des": "1.0.0",
+ "evp_bytestokey": "1.0.3"
+ }
+ },
+ "browserify-des": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.0.tgz",
+ "integrity": "sha1-2qJ3cXRwki7S/hhZQRihdUOXId0=",
+ "requires": {
+ "cipher-base": "1.0.4",
+ "des.js": "1.0.0",
+ "inherits": "2.0.3"
+ }
+ },
+ "browserify-rsa": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz",
+ "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=",
+ "requires": {
+ "bn.js": "4.11.8",
+ "randombytes": "2.0.5"
+ }
+ },
+ "browserify-sign": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.0.4.tgz",
+ "integrity": "sha1-qk62jl17ZYuqa/alfmMMvXqT0pg=",
+ "requires": {
+ "bn.js": "4.11.8",
+ "browserify-rsa": "4.0.1",
+ "create-hash": "1.1.3",
+ "create-hmac": "1.1.6",
+ "elliptic": "6.4.0",
+ "inherits": "2.0.3",
+ "parse-asn1": "5.1.0"
+ }
+ },
+ "browserify-zlib": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz",
+ "integrity": "sha1-uzX4pRn2AOD6a4SFJByXnQFB+y0=",
+ "requires": {
+ "pako": "0.2.9"
+ }
+ },
+ "browserslist": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-2.5.1.tgz",
+ "integrity": "sha512-jAvM2ku7YDJ+leAq3bFH1DE0Ylw+F+EQDq4GkqZfgPEqpWYw9ofQH85uKSB9r3Tv7XDbfqVtE+sdvKJW7IlPJA==",
+ "requires": {
+ "caniuse-lite": "1.0.30000748",
+ "electron-to-chromium": "1.3.27"
+ }
+ },
+ "bser": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/bser/-/bser-2.0.0.tgz",
+ "integrity": "sha1-mseNPtXZFYBP2HrLFYvHlxR6Fxk=",
+ "requires": {
+ "node-int64": "0.4.0"
+ }
+ },
+ "buffer": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz",
+ "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=",
+ "requires": {
+ "base64-js": "1.2.1",
+ "ieee754": "1.1.8",
+ "isarray": "1.0.0"
+ }
+ },
+ "buffer-xor": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
+ "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk="
+ },
+ "bufferstreams": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/bufferstreams/-/bufferstreams-1.0.1.tgz",
+ "integrity": "sha1-z7GtlWjTujz+k1upq92VLeiKqyo=",
+ "requires": {
+ "readable-stream": "1.1.14"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+ "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
+ },
+ "readable-stream": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+ "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
+ "requires": {
+ "core-util-is": "1.0.2",
+ "inherits": "2.0.3",
+ "isarray": "0.0.1",
+ "string_decoder": "0.10.31"
+ }
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+ "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+ }
+ }
+ },
+ "builtin-modules": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz",
+ "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8="
+ },
+ "builtin-status-codes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz",
+ "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug="
+ },
+ "bytes": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.2.0.tgz",
+ "integrity": "sha1-/TVGSkA/b5EXwt42Cez/nK4ABYg="
+ },
+ "caller-path": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz",
+ "integrity": "sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=",
+ "requires": {
+ "callsites": "0.2.0"
+ }
+ },
+ "callsites": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz",
+ "integrity": "sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo="
+ },
+ "camelcase": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz",
+ "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0="
+ },
+ "camelcase-css": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-1.0.1.tgz",
+ "integrity": "sha1-FXxCOCZfXPlKHf/ehkRlUsvz9wU="
+ },
+ "camelcase-keys": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz",
+ "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=",
+ "requires": {
+ "camelcase": "2.1.1",
+ "map-obj": "1.0.1"
+ },
+ "dependencies": {
+ "camelcase": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz",
+ "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8="
+ }
+ }
+ },
+ "caniuse-api": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-1.6.1.tgz",
+ "integrity": "sha1-tTTnxzTE+B7F++isoq0kNUuWLGw=",
+ "requires": {
+ "browserslist": "1.7.7",
+ "caniuse-db": "1.0.30000748",
+ "lodash.memoize": "4.1.2",
+ "lodash.uniq": "4.5.0"
+ },
+ "dependencies": {
+ "browserslist": {
+ "version": "1.7.7",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-1.7.7.tgz",
+ "integrity": "sha1-C9dnBCWL6CmyOYu1Dkti0aFmsLk=",
+ "requires": {
+ "caniuse-db": "1.0.30000748",
+ "electron-to-chromium": "1.3.27"
+ }
+ }
+ }
+ },
+ "caniuse-db": {
+ "version": "1.0.30000748",
+ "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000748.tgz",
+ "integrity": "sha1-eF2e381kW/eVxv887TPEXVgMSKA="
+ },
+ "caniuse-lite": {
+ "version": "1.0.30000748",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000748.tgz",
+ "integrity": "sha1-RMjW2lKtZaXXudyk7+vQvdmCugk="
+ },
+ "center-align": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz",
+ "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=",
+ "requires": {
+ "align-text": "0.1.4",
+ "lazy-cache": "1.0.4"
+ }
+ },
+ "chalk": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.2.0.tgz",
+ "integrity": "sha512-0BMM/2hG3ZaoPfR6F+h/oWpZtsh3b/s62TjSM6MGCJWEbJDN1acqCXvyhhZsDSVFklpebUoQ5O1kKC7lOzrn9g==",
+ "requires": {
+ "ansi-styles": "3.2.0",
+ "escape-string-regexp": "1.0.5",
+ "supports-color": "4.5.0"
+ }
+ },
+ "chokidar": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz",
+ "integrity": "sha1-eY5ol3gVHIB2tLNg5e3SjNortGg=",
+ "requires": {
+ "anymatch": "1.3.2",
+ "async-each": "1.0.1",
+ "glob-parent": "2.0.0",
+ "inherits": "2.0.3",
+ "is-binary-path": "1.0.1",
+ "is-glob": "2.0.1",
+ "path-is-absolute": "1.0.1",
+ "readdirp": "2.1.0"
+ }
+ },
+ "cipher-base": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
+ "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
+ "requires": {
+ "inherits": "2.0.3",
+ "safe-buffer": "5.1.1"
+ }
+ },
+ "circular-json": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz",
+ "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A=="
+ },
+ "clap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/clap/-/clap-1.2.3.tgz",
+ "integrity": "sha512-4CoL/A3hf90V3VIEjeuhSvlGFEHKzOz+Wfc2IVZc+FaUgU0ZQafJTP49fvnULipOPcAfqhyI2duwQyns6xqjYA==",
+ "requires": {
+ "chalk": "1.1.3"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ }
+ },
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "classnames": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz",
+ "integrity": "sha1-+zgB1FNGdknvNgPH1hoCvRKb3m0="
+ },
+ "clean-css": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.1.9.tgz",
+ "integrity": "sha1-Nc7ornaHpJuYA09w3gDE7dOCYwE=",
+ "requires": {
+ "source-map": "0.5.7"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ }
+ }
+ },
+ "cli-cursor": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz",
+ "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=",
+ "requires": {
+ "restore-cursor": "2.0.0"
+ }
+ },
+ "cli-width": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz",
+ "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk="
+ },
+ "clipboard": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-1.7.1.tgz",
+ "integrity": "sha1-Ng1taUbpmnof7zleQrqStem1oWs=",
+ "requires": {
+ "good-listener": "1.2.2",
+ "select": "1.1.2",
+ "tiny-emitter": "2.0.2"
+ }
+ },
+ "cliui": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz",
+ "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=",
+ "requires": {
+ "string-width": "1.0.2",
+ "strip-ansi": "3.0.1",
+ "wrap-ansi": "2.1.0"
+ },
+ "dependencies": {
+ "is-fullwidth-code-point": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+ "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
+ "requires": {
+ "number-is-nan": "1.0.1"
+ }
+ },
+ "string-width": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+ "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
+ "requires": {
+ "code-point-at": "1.1.0",
+ "is-fullwidth-code-point": "1.0.0",
+ "strip-ansi": "3.0.1"
+ }
+ }
+ }
+ },
+ "clone": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.2.tgz",
+ "integrity": "sha1-Jgt6meux7f4kdTgXX3gyQ8sZ0Uk="
+ },
+ "clone-buffer": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/clone-buffer/-/clone-buffer-1.0.0.tgz",
+ "integrity": "sha1-4+JbIHrE5wGvch4staFnksrD3Fg="
+ },
+ "clone-regexp": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-1.0.0.tgz",
+ "integrity": "sha1-6uCiQT9VwJQvgYwin+/OhF1/Oxw=",
+ "requires": {
+ "is-regexp": "1.0.0",
+ "is-supported-regexp-flag": "1.0.0"
+ }
+ },
+ "clone-stats": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-0.0.1.tgz",
+ "integrity": "sha1-uI+UqCzzi4eR1YBG6kAprYjKmdE="
+ },
+ "cloneable-readable": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/cloneable-readable/-/cloneable-readable-1.0.0.tgz",
+ "integrity": "sha1-pikNQT8hemEjL5XkWP84QYz7ARc=",
+ "requires": {
+ "inherits": "2.0.3",
+ "process-nextick-args": "1.0.7",
+ "through2": "2.0.3"
+ }
+ },
+ "co": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
+ "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ="
+ },
+ "coa": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/coa/-/coa-1.0.4.tgz",
+ "integrity": "sha1-qe8VNmDWqGqL3sAomlxoTSF0Mv0=",
+ "requires": {
+ "q": "1.5.1"
+ }
+ },
+ "code-point-at": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+ "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
+ },
+ "color": {
+ "version": "0.11.4",
+ "resolved": "https://registry.npmjs.org/color/-/color-0.11.4.tgz",
+ "integrity": "sha1-bXtcdPtl6EHNSHkq0e1eB7kE12Q=",
+ "requires": {
+ "clone": "1.0.2",
+ "color-convert": "1.9.0",
+ "color-string": "0.3.0"
+ }
+ },
+ "color-convert": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.0.tgz",
+ "integrity": "sha1-Gsz5fdc5uYO/mU1W/sj5WFNkG3o=",
+ "requires": {
+ "color-name": "1.1.3"
+ }
+ },
+ "color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
+ },
+ "color-string": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-0.3.0.tgz",
+ "integrity": "sha1-J9RvtnAlxcL6JZk7+/V55HhBuZE=",
+ "requires": {
+ "color-name": "1.1.3"
+ }
+ },
+ "colormin": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/colormin/-/colormin-1.1.2.tgz",
+ "integrity": "sha1-6i90IKcrlogaOKrlnsEkpvcpgTM=",
+ "requires": {
+ "color": "0.11.4",
+ "css-color-names": "0.0.4",
+ "has": "1.0.1"
+ }
+ },
+ "colors": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz",
+ "integrity": "sha1-FopHAXVran9RoSzgyXv6KMCE7WM="
+ },
+ "commander": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz",
+ "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ=="
+ },
+ "commondir": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+ "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs="
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+ },
+ "concat-stream": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.0.tgz",
+ "integrity": "sha1-CqxmL9Ur54lk1VMvaUeE5wEQrPc=",
+ "requires": {
+ "inherits": "2.0.3",
+ "readable-stream": "2.3.3",
+ "typedarray": "0.0.6"
+ }
+ },
+ "concat-with-sourcemaps": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.0.4.tgz",
+ "integrity": "sha1-9Vs74q60dgGxCi1SWcz7cP0vHdY=",
+ "requires": {
+ "source-map": "0.5.7"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ }
+ }
+ },
+ "console-browserify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz",
+ "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=",
+ "requires": {
+ "date-now": "0.1.4"
+ }
+ },
+ "consolidate": {
+ "version": "0.14.5",
+ "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.14.5.tgz",
+ "integrity": "sha1-WiUEe8dvcwcmZ8jLUsmJiI9JTGM=",
+ "requires": {
+ "bluebird": "3.5.1"
+ }
+ },
+ "constants-browserify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz",
+ "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U="
+ },
+ "content-type": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
+ "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA=="
+ },
+ "convert-source-map": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.5.0.tgz",
+ "integrity": "sha1-ms1whRxtXf3ZPZKC5e35SgP/RrU="
+ },
+ "core-js": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.1.tgz",
+ "integrity": "sha1-rmh03GaTd4m4B1T/VCjfZoGcpQs="
+ },
+ "core-util-is": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
+ },
+ "cosmiconfig": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-2.2.2.tgz",
+ "integrity": "sha512-GiNXLwAFPYHy25XmTPpafYvn3CLAkJ8FLsscq78MQd1Kh0OU6Yzhn4eV2MVF4G9WEQZoWEGltatdR+ntGPMl5A==",
+ "requires": {
+ "is-directory": "0.3.1",
+ "js-yaml": "3.7.0",
+ "minimist": "1.2.0",
+ "object-assign": "4.1.1",
+ "os-homedir": "1.0.2",
+ "parse-json": "2.2.0",
+ "require-from-string": "1.2.1"
+ },
+ "dependencies": {
+ "minimist": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+ "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
+ }
+ }
+ },
+ "create-ecdh": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.0.tgz",
+ "integrity": "sha1-iIxyNZbN92EvZJgjPuvXo1MBc30=",
+ "requires": {
+ "bn.js": "4.11.8",
+ "elliptic": "6.4.0"
+ }
+ },
+ "create-hash": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.1.3.tgz",
+ "integrity": "sha1-YGBCrIuSYnUPSDyt2rD1gZFy2P0=",
+ "requires": {
+ "cipher-base": "1.0.4",
+ "inherits": "2.0.3",
+ "ripemd160": "2.0.1",
+ "sha.js": "2.4.9"
+ }
+ },
+ "create-hmac": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.6.tgz",
+ "integrity": "sha1-rLniIaThe9sHbpBlfEK5PjcmzwY=",
+ "requires": {
+ "cipher-base": "1.0.4",
+ "create-hash": "1.1.3",
+ "inherits": "2.0.3",
+ "ripemd160": "2.0.1",
+ "safe-buffer": "5.1.1",
+ "sha.js": "2.4.9"
+ }
+ },
+ "create-react-class": {
+ "version": "15.6.2",
+ "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.6.2.tgz",
+ "integrity": "sha1-zx7RXxKq1/FO9fLf4F5sQvke8Co=",
+ "requires": {
+ "fbjs": "0.8.16",
+ "loose-envify": "1.3.1",
+ "object-assign": "4.1.1"
+ }
+ },
+ "cross-spawn": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
+ "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=",
+ "requires": {
+ "lru-cache": "4.1.1",
+ "shebang-command": "1.2.0",
+ "which": "1.3.0"
+ }
+ },
+ "crypto-browserify": {
+ "version": "3.11.1",
+ "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.11.1.tgz",
+ "integrity": "sha512-Na7ZlwCOqoaW5RwUK1WpXws2kv8mNhWdTlzob0UXulk6G9BDbyiJaGTYBIX61Ozn9l1EPPJpICZb4DaOpT9NlQ==",
+ "requires": {
+ "browserify-cipher": "1.0.0",
+ "browserify-sign": "4.0.4",
+ "create-ecdh": "4.0.0",
+ "create-hash": "1.1.3",
+ "create-hmac": "1.1.6",
+ "diffie-hellman": "5.0.2",
+ "inherits": "2.0.3",
+ "pbkdf2": "3.0.14",
+ "public-encrypt": "4.0.0",
+ "randombytes": "2.0.5"
+ }
+ },
+ "css": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/css/-/css-2.2.1.tgz",
+ "integrity": "sha1-c6TIHehdtmTU7mdPfUcIXjstVdw=",
+ "requires": {
+ "inherits": "2.0.3",
+ "source-map": "0.1.43",
+ "source-map-resolve": "0.3.1",
+ "urix": "0.1.0"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.1.43",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.1.43.tgz",
+ "integrity": "sha1-wkvBRspRfBRx9drL4lcbK3+eM0Y=",
+ "requires": {
+ "amdefine": "1.0.1"
+ }
+ }
+ }
+ },
+ "css-color-names": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz",
+ "integrity": "sha1-gIrcLnnPhHOAabZGyyDsJ762KeA="
+ },
+ "css-loader": {
+ "version": "0.28.7",
+ "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-0.28.7.tgz",
+ "integrity": "sha512-GxMpax8a/VgcfRrVy0gXD6yLd5ePYbXX/5zGgTVYp4wXtJklS8Z2VaUArJgc//f6/Dzil7BaJObdSv8eKKCPgg==",
+ "requires": {
+ "babel-code-frame": "6.26.0",
+ "css-selector-tokenizer": "0.7.0",
+ "cssnano": "3.10.0",
+ "icss-utils": "2.1.0",
+ "loader-utils": "1.1.0",
+ "lodash.camelcase": "4.3.0",
+ "object-assign": "4.1.1",
+ "postcss": "5.2.18",
+ "postcss-modules-extract-imports": "1.1.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"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "css-selector-tokenizer": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.7.0.tgz",
+ "integrity": "sha1-5piEdK6MlTR3v15+/s/OzNnPTIY=",
+ "requires": {
+ "cssesc": "0.1.0",
+ "fastparse": "1.1.1",
+ "regexpu-core": "1.0.0"
+ },
+ "dependencies": {
+ "regexpu-core": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-1.0.0.tgz",
+ "integrity": "sha1-hqdj9Y7k18L2sQLkdkBQ3n7ZDGs=",
+ "requires": {
+ "regenerate": "1.3.3",
+ "regjsgen": "0.2.0",
+ "regjsparser": "0.1.5"
+ }
+ }
+ }
+ },
+ "cssesc": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-0.1.0.tgz",
+ "integrity": "sha1-yBSQPkViM3GgR3tAEJqq++6t27Q="
+ },
+ "cssnano": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-3.10.0.tgz",
+ "integrity": "sha1-Tzj2zqK5sX+gFJDyPx3GjqZcHDg=",
+ "requires": {
+ "autoprefixer": "6.7.7",
+ "decamelize": "1.2.0",
+ "defined": "1.0.0",
+ "has": "1.0.1",
+ "object-assign": "4.1.1",
+ "postcss": "5.2.18",
+ "postcss-calc": "5.3.1",
+ "postcss-colormin": "2.2.2",
+ "postcss-convert-values": "2.6.1",
+ "postcss-discard-comments": "2.0.4",
+ "postcss-discard-duplicates": "2.1.0",
+ "postcss-discard-empty": "2.1.0",
+ "postcss-discard-overridden": "0.1.1",
+ "postcss-discard-unused": "2.2.3",
+ "postcss-filter-plugins": "2.0.2",
+ "postcss-merge-idents": "2.1.7",
+ "postcss-merge-longhand": "2.0.2",
+ "postcss-merge-rules": "2.1.2",
+ "postcss-minify-font-values": "1.0.5",
+ "postcss-minify-gradients": "1.0.5",
+ "postcss-minify-params": "1.2.2",
+ "postcss-minify-selectors": "2.1.1",
+ "postcss-normalize-charset": "1.1.1",
+ "postcss-normalize-url": "3.0.8",
+ "postcss-ordered-values": "2.2.3",
+ "postcss-reduce-idents": "2.4.0",
+ "postcss-reduce-initial": "1.0.1",
+ "postcss-reduce-transforms": "1.0.4",
+ "postcss-svgo": "2.1.6",
+ "postcss-unique-selectors": "2.0.2",
+ "postcss-value-parser": "3.3.0",
+ "postcss-zindex": "2.2.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "autoprefixer": {
+ "version": "6.7.7",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-6.7.7.tgz",
+ "integrity": "sha1-Hb0cg1ZY41zj+ZhAmdsAWFx4IBQ=",
+ "requires": {
+ "browserslist": "1.7.7",
+ "caniuse-db": "1.0.30000748",
+ "normalize-range": "0.1.2",
+ "num2fraction": "1.2.2",
+ "postcss": "5.2.18",
+ "postcss-value-parser": "3.3.0"
+ }
+ },
+ "browserslist": {
+ "version": "1.7.7",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-1.7.7.tgz",
+ "integrity": "sha1-C9dnBCWL6CmyOYu1Dkti0aFmsLk=",
+ "requires": {
+ "caniuse-db": "1.0.30000748",
+ "electron-to-chromium": "1.3.27"
+ }
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "csso": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/csso/-/csso-2.3.2.tgz",
+ "integrity": "sha1-3dUsWHAz9J6Utx/FVWnyUuj/X4U=",
+ "requires": {
+ "clap": "1.2.3",
+ "source-map": "0.5.7"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ }
+ }
+ },
+ "currently-unhandled": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz",
+ "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=",
+ "requires": {
+ "array-find-index": "1.0.2"
+ }
+ },
+ "d": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz",
+ "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=",
+ "requires": {
+ "es5-ext": "0.10.35"
+ }
+ },
+ "date-now": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz",
+ "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs="
+ },
+ "dateformat": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz",
+ "integrity": "sha1-QGXiATz5+5Ft39gu+1Bq1MZ2kGI="
+ },
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "debug-fabulous": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/debug-fabulous/-/debug-fabulous-0.2.1.tgz",
+ "integrity": "sha512-u0TV6HcfLsZ03xLBhdhSViQMldaiQ2o+8/nSILaXkuNSWvxkx66vYJUAam0Eu7gAilJRX/69J4kKdqajQPaPyw==",
+ "requires": {
+ "debug": "3.1.0",
+ "memoizee": "0.4.11",
+ "object-assign": "4.1.1"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+ "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ }
+ }
+ },
+ "decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA="
+ },
+ "decode-uri-component": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
+ "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
+ },
+ "deep-is": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
+ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ="
+ },
+ "defaults": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
+ "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=",
+ "requires": {
+ "clone": "1.0.2"
+ }
+ },
+ "define-properties": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.2.tgz",
+ "integrity": "sha1-g6c/L+pWmJj7c3GTyPhzyvbUXJQ=",
+ "requires": {
+ "foreach": "2.0.5",
+ "object-keys": "1.0.11"
+ }
+ },
+ "defined": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
+ "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM="
+ },
+ "del": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/del/-/del-3.0.0.tgz",
+ "integrity": "sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU=",
+ "requires": {
+ "globby": "6.1.0",
+ "is-path-cwd": "1.0.0",
+ "is-path-in-cwd": "1.0.0",
+ "p-map": "1.2.0",
+ "pify": "3.0.0",
+ "rimraf": "2.6.2"
+ },
+ "dependencies": {
+ "pify": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+ "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY="
+ }
+ }
+ },
+ "delegate": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.1.3.tgz",
+ "integrity": "sha1-moJRp3fXAl+qVXN7w7BxdCEnqf0="
+ },
+ "depd": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz",
+ "integrity": "sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k="
+ },
+ "deprecated": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/deprecated/-/deprecated-0.0.1.tgz",
+ "integrity": "sha1-+cmvVGSvoeepcUWKi97yqpTVuxk="
+ },
+ "des.js": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz",
+ "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=",
+ "requires": {
+ "inherits": "2.0.3",
+ "minimalistic-assert": "1.0.0"
+ }
+ },
+ "detect-file": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-0.1.0.tgz",
+ "integrity": "sha1-STXe39lIhkjgBrASlWbpOGcR6mM=",
+ "requires": {
+ "fs-exists-sync": "0.1.0"
+ }
+ },
+ "detect-indent": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz",
+ "integrity": "sha1-920GQ1LN9Docts5hnE7jqUdd4gg=",
+ "requires": {
+ "repeating": "2.0.1"
+ }
+ },
+ "detect-newline": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-2.1.0.tgz",
+ "integrity": "sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I="
+ },
+ "diff": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-1.4.0.tgz",
+ "integrity": "sha1-fyjS657nsVqX79ic5j3P2qPMur8="
+ },
+ "diffie-hellman": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.2.tgz",
+ "integrity": "sha1-tYNXOScM/ias9jIJn97SoH8gnl4=",
+ "requires": {
+ "bn.js": "4.11.8",
+ "miller-rabin": "4.0.1",
+ "randombytes": "2.0.5"
+ }
+ },
+ "disparity": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/disparity/-/disparity-2.0.0.tgz",
+ "integrity": "sha1-V92stHMkrl9Y0swNqIbbTOnutxg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "diff": "1.4.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ }
+ }
+ },
+ "disposables": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/disposables/-/disposables-1.0.1.tgz",
+ "integrity": "sha1-BkcnoltU9QK9griaot+4358bOeM="
+ },
+ "dnd-core": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-2.5.4.tgz",
+ "integrity": "sha512-BcI782MfTm3wCxeIS5c7tAutyTwEIANtuu3W6/xkoJRwiqhRXKX3BbGlycUxxyzMsKdvvoavxgrC3EMPFNYL9A==",
+ "requires": {
+ "asap": "2.0.6",
+ "invariant": "2.2.2",
+ "lodash": "4.17.4",
+ "redux": "3.7.2"
+ }
+ },
+ "dnode": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/dnode/-/dnode-1.2.2.tgz",
+ "integrity": "sha1-SsPP4m4pKzs5uCWK59lO3FgTLvo=",
+ "requires": {
+ "dnode-protocol": "0.2.2",
+ "jsonify": "0.0.0"
+ }
+ },
+ "dnode-protocol": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/dnode-protocol/-/dnode-protocol-0.2.2.tgz",
+ "integrity": "sha1-URUdFvw7X4SBXuC5SXoQYdDRlJ0=",
+ "requires": {
+ "jsonify": "0.0.0",
+ "traverse": "0.6.6"
+ }
+ },
+ "doctrine": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.0.0.tgz",
+ "integrity": "sha1-xz2NKQnSIpHhoAejlYBNqLZl/mM=",
+ "requires": {
+ "esutils": "2.0.2",
+ "isarray": "1.0.0"
+ }
+ },
+ "dom-css": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/dom-css/-/dom-css-2.1.0.tgz",
+ "integrity": "sha1-/bwtWgFdCj4YcuEUcrvQ57nmogI=",
+ "requires": {
+ "add-px-to-style": "1.0.0",
+ "prefix-style": "2.0.1",
+ "to-camel-case": "1.0.0"
+ }
+ },
+ "dom-helpers": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.2.1.tgz",
+ "integrity": "sha1-MgPgf+0he9H0JLAZc1WC/Deyglo="
+ },
+ "domain-browser": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz",
+ "integrity": "sha1-hnqksJP6oF8d4IwG9NeyH9+GmLw="
+ },
+ "duplexer": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz",
+ "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E="
+ },
+ "duplexer2": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.0.2.tgz",
+ "integrity": "sha1-xhTc9n4vsUmVqRcR5aYX6KYKMds=",
+ "requires": {
+ "readable-stream": "1.1.14"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+ "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
+ },
+ "readable-stream": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+ "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
+ "requires": {
+ "core-util-is": "1.0.2",
+ "inherits": "2.0.3",
+ "isarray": "0.0.1",
+ "string_decoder": "0.10.31"
+ }
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+ "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+ }
+ }
+ },
+ "ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
+ },
+ "electron-to-chromium": {
+ "version": "1.3.27",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.27.tgz",
+ "integrity": "sha1-eOy4o5kGYYe7N07t412ccFZagD0="
+ },
+ "element-class": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/element-class/-/element-class-0.2.2.tgz",
+ "integrity": "sha1-nTu9B2f5AT744cjr5yLBQCpgBQ4="
+ },
+ "elliptic": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.0.tgz",
+ "integrity": "sha1-ysmvh2LIWDYYcAPI3+GT5eLq5d8=",
+ "requires": {
+ "bn.js": "4.11.8",
+ "brorand": "1.1.0",
+ "hash.js": "1.1.3",
+ "hmac-drbg": "1.0.1",
+ "inherits": "2.0.3",
+ "minimalistic-assert": "1.0.0",
+ "minimalistic-crypto-utils": "1.0.1"
+ }
+ },
+ "emojis-list": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz",
+ "integrity": "sha1-TapNnbAPmBmIDHn6RXrlsJof04k="
+ },
+ "encoding": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz",
+ "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=",
+ "requires": {
+ "iconv-lite": "0.4.19"
+ }
+ },
+ "end-of-stream": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-0.1.5.tgz",
+ "integrity": "sha1-jhdyBsPICDfYVjLouTWd/osvbq8=",
+ "requires": {
+ "once": "1.3.3"
+ },
+ "dependencies": {
+ "once": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz",
+ "integrity": "sha1-suJhVXzkwxTsgwTz+oJmPkKXyiA=",
+ "requires": {
+ "wrappy": "1.0.2"
+ }
+ }
+ }
+ },
+ "enhanced-resolve": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz",
+ "integrity": "sha1-BCHjOf1xQZs9oT0Smzl5BAIwR24=",
+ "requires": {
+ "graceful-fs": "4.1.11",
+ "memory-fs": "0.4.1",
+ "object-assign": "4.1.1",
+ "tapable": "0.2.8"
+ }
+ },
+ "errno": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.4.tgz",
+ "integrity": "sha1-uJbiOp5ei6M4cfyZar02NfyaHH0=",
+ "requires": {
+ "prr": "0.0.0"
+ }
+ },
+ "error-ex": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.1.tgz",
+ "integrity": "sha1-+FWobOYa3E6GIcPNoh56dhLDqNw=",
+ "requires": {
+ "is-arrayish": "0.2.1"
+ }
+ },
+ "es-abstract": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.9.0.tgz",
+ "integrity": "sha512-kk3IJoKo7A3pWJc0OV8yZ/VEX2oSUytfekrJiqoxBlKJMFAJVJVpGdHClCCTdv+Fn2zHfpDHHIelMFhZVfef3Q==",
+ "requires": {
+ "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": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.1.1.tgz",
+ "integrity": "sha1-RTVSSKiJeQNLZ5Lhm7gfK3l13Q0=",
+ "requires": {
+ "is-callable": "1.1.3",
+ "is-date-object": "1.0.1",
+ "is-symbol": "1.0.1"
+ }
+ },
+ "es5-ext": {
+ "version": "0.10.35",
+ "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.35.tgz",
+ "integrity": "sha1-GO6FjOajxFx9eekcFfzKnsVoSU8=",
+ "requires": {
+ "es6-iterator": "2.0.3",
+ "es6-symbol": "3.1.1"
+ }
+ },
+ "es6-iterator": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
+ "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=",
+ "requires": {
+ "d": "1.0.0",
+ "es5-ext": "0.10.35",
+ "es6-symbol": "3.1.1"
+ }
+ },
+ "es6-map": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/es6-map/-/es6-map-0.1.5.tgz",
+ "integrity": "sha1-kTbgUD3MBqMBaQ8LsU/042TpSfA=",
+ "requires": {
+ "d": "1.0.0",
+ "es5-ext": "0.10.35",
+ "es6-iterator": "2.0.3",
+ "es6-set": "0.1.5",
+ "es6-symbol": "3.1.1",
+ "event-emitter": "0.3.5"
+ }
+ },
+ "es6-promise": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz",
+ "integrity": "sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM="
+ },
+ "es6-set": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.5.tgz",
+ "integrity": "sha1-0rPsXU2ADO2BjbU40ol02wpzzLE=",
+ "requires": {
+ "d": "1.0.0",
+ "es5-ext": "0.10.35",
+ "es6-iterator": "2.0.3",
+ "es6-symbol": "3.1.1",
+ "event-emitter": "0.3.5"
+ }
+ },
+ "es6-symbol": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz",
+ "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=",
+ "requires": {
+ "d": "1.0.0",
+ "es5-ext": "0.10.35"
+ }
+ },
+ "es6-weak-map": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.2.tgz",
+ "integrity": "sha1-XjqzIlH/0VOKH45f+hNXdy+S2W8=",
+ "requires": {
+ "d": "1.0.0",
+ "es5-ext": "0.10.35",
+ "es6-iterator": "2.0.3",
+ "es6-symbol": "3.1.1"
+ }
+ },
+ "escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
+ },
+ "escope": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/escope/-/escope-3.6.0.tgz",
+ "integrity": "sha1-4Bl16BJ4GhY6ba392AOY3GTIicM=",
+ "requires": {
+ "es6-map": "0.1.5",
+ "es6-weak-map": "2.0.2",
+ "esrecurse": "4.2.0",
+ "estraverse": "4.2.0"
+ }
+ },
+ "esformatter": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/esformatter/-/esformatter-0.10.0.tgz",
+ "integrity": "sha1-4yHsw9lAgzcs389cb5Qs72/sWdM=",
+ "requires": {
+ "acorn-to-esprima": "2.0.8",
+ "babel-traverse": "6.26.0",
+ "debug": "0.7.4",
+ "disparity": "2.0.0",
+ "esformatter-parser": "1.0.0",
+ "glob": "7.1.2",
+ "minimatch": "3.0.4",
+ "minimist": "1.2.0",
+ "mout": "1.1.0",
+ "npm-run": "3.0.0",
+ "resolve": "1.4.0",
+ "rocambole": "0.7.0",
+ "rocambole-indent": "2.0.4",
+ "rocambole-linebreak": "1.0.2",
+ "rocambole-node": "1.0.0",
+ "rocambole-token": "1.2.1",
+ "rocambole-whitespace": "1.0.0",
+ "stdin": "0.0.1",
+ "strip-json-comments": "0.1.3",
+ "supports-color": "1.3.1",
+ "user-home": "2.0.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-0.7.4.tgz",
+ "integrity": "sha1-BuHqgILCyxTjmAbiLi9vdX+Srzk="
+ },
+ "minimist": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+ "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
+ },
+ "supports-color": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-1.3.1.tgz",
+ "integrity": "sha1-FXWN8J2P87SswwdTn6vicJXhBC0="
+ }
+ }
+ },
+ "esformatter-parser": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/esformatter-parser/-/esformatter-parser-1.0.0.tgz",
+ "integrity": "sha1-CFQHLQSHU57TnK442KVDLBfsEdM=",
+ "requires": {
+ "acorn-to-esprima": "2.0.8",
+ "babel-traverse": "6.26.0",
+ "babylon": "6.18.0",
+ "rocambole": "0.7.0"
+ }
+ },
+ "eslint": {
+ "version": "4.8.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-4.8.0.tgz",
+ "integrity": "sha1-Ip7w41Tg5h2DfHqA/fuoJeGZgV4=",
+ "requires": {
+ "ajv": "5.2.3",
+ "babel-code-frame": "6.26.0",
+ "chalk": "2.2.0",
+ "concat-stream": "1.6.0",
+ "cross-spawn": "5.1.0",
+ "debug": "3.1.0",
+ "doctrine": "2.0.0",
+ "eslint-scope": "3.7.1",
+ "espree": "3.5.1",
+ "esquery": "1.0.0",
+ "estraverse": "4.2.0",
+ "esutils": "2.0.2",
+ "file-entry-cache": "2.0.0",
+ "functional-red-black-tree": "1.0.1",
+ "glob": "7.1.2",
+ "globals": "9.18.0",
+ "ignore": "3.3.6",
+ "imurmurhash": "0.1.4",
+ "inquirer": "3.3.0",
+ "is-resolvable": "1.0.0",
+ "js-yaml": "3.10.0",
+ "json-stable-stringify": "1.0.1",
+ "levn": "0.3.0",
+ "lodash": "4.17.4",
+ "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",
+ "require-uncached": "1.0.3",
+ "semver": "5.4.1",
+ "strip-ansi": "4.0.0",
+ "strip-json-comments": "2.0.1",
+ "table": "4.0.2",
+ "text-table": "0.2.0"
+ },
+ "dependencies": {
+ "ansi-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+ "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
+ },
+ "debug": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+ "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "esprima": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz",
+ "integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw=="
+ },
+ "js-yaml": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.10.0.tgz",
+ "integrity": "sha512-O2v52ffjLa9VeM43J4XocZE//WT9N0IiwDa3KSHH7Tu8CtH+1qM8SIZvnsTh6v+4yFy5KUY3BHUVwjpfAWsjIA==",
+ "requires": {
+ "argparse": "1.0.9",
+ "esprima": "4.0.0"
+ }
+ },
+ "semver": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz",
+ "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg=="
+ },
+ "strip-ansi": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+ "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+ "requires": {
+ "ansi-regex": "3.0.0"
+ }
+ },
+ "strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo="
+ }
+ }
+ },
+ "eslint-loader": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/eslint-loader/-/eslint-loader-1.9.0.tgz",
+ "integrity": "sha512-40aN976qSNPyb9ejTqjEthZITpls1SVKtwguahmH1dzGCwQU/vySE+xX33VZmD8csU0ahVNCtFlsPgKqRBiqgg==",
+ "requires": {
+ "loader-fs-cache": "1.0.1",
+ "loader-utils": "1.1.0",
+ "object-assign": "4.1.1",
+ "object-hash": "1.2.0",
+ "rimraf": "2.6.2"
+ }
+ },
+ "eslint-plugin-filenames": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-filenames/-/eslint-plugin-filenames-1.2.0.tgz",
+ "integrity": "sha1-runByQGJyV0uSZAsFg7O7+zZn1M=",
+ "requires": {
+ "lodash.camelcase": "4.3.0",
+ "lodash.kebabcase": "4.1.1",
+ "lodash.snakecase": "4.1.1",
+ "lodash.upperfirst": "4.3.1"
+ }
+ },
+ "eslint-plugin-react": {
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.4.0.tgz",
+ "integrity": "sha512-tvjU9u3VqmW2vVuYnE8Qptq+6ji4JltjOjJ9u7VAOxVYkUkyBZWRvNYKbDv5fN+L6wiA+4we9+qQahZ0m63XEA==",
+ "requires": {
+ "doctrine": "2.0.0",
+ "has": "1.0.1",
+ "jsx-ast-utils": "2.0.1",
+ "prop-types": "15.6.0"
+ }
+ },
+ "eslint-scope": {
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-3.7.1.tgz",
+ "integrity": "sha1-PWPD7f2gLgbgGkUq2IyqzHzctug=",
+ "requires": {
+ "esrecurse": "4.2.0",
+ "estraverse": "4.2.0"
+ }
+ },
+ "espree": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-3.5.1.tgz",
+ "integrity": "sha1-DJiLirRttTEAoZVK5LqZXd0n2H4=",
+ "requires": {
+ "acorn": "5.1.2",
+ "acorn-jsx": "3.0.1"
+ }
+ },
+ "esprima": {
+ "version": "2.7.3",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz",
+ "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE="
+ },
+ "esprint": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/esprint/-/esprint-0.4.0.tgz",
+ "integrity": "sha1-+JybrONtkEB5aKj5zrCAD/eGqrA=",
+ "requires": {
+ "dnode": "1.2.2",
+ "fb-watchman": "2.0.0",
+ "glob": "7.1.2",
+ "sane": "1.7.0",
+ "worker-farm": "1.5.0",
+ "yargs": "8.0.2"
+ }
+ },
+ "esquery": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.0.tgz",
+ "integrity": "sha1-z7qLV9f7qT8XKYqKAGoEzaE9gPo=",
+ "requires": {
+ "estraverse": "4.2.0"
+ }
+ },
+ "esrecurse": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.0.tgz",
+ "integrity": "sha1-+pVo2Y04I/mkHZHpAtyrnqblsWM=",
+ "requires": {
+ "estraverse": "4.2.0",
+ "object-assign": "4.1.1"
+ }
+ },
+ "estraverse": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz",
+ "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM="
+ },
+ "esutils": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz",
+ "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs="
+ },
+ "event-emitter": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
+ "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=",
+ "requires": {
+ "d": "1.0.0",
+ "es5-ext": "0.10.35"
+ }
+ },
+ "event-stream": {
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz",
+ "integrity": "sha1-SrTJoPWlTbkzi0w02Gv86PSzVXE=",
+ "requires": {
+ "duplexer": "0.1.1",
+ "from": "0.1.7",
+ "map-stream": "0.1.0",
+ "pause-stream": "0.0.11",
+ "split": "0.3.3",
+ "stream-combiner": "0.0.4",
+ "through": "2.3.8"
+ }
+ },
+ "events": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz",
+ "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ="
+ },
+ "evp_bytestokey": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
+ "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==",
+ "requires": {
+ "md5.js": "1.3.4",
+ "safe-buffer": "5.1.1"
+ }
+ },
+ "exec-sh": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.2.1.tgz",
+ "integrity": "sha512-aLt95pexaugVtQerpmE51+4QfWrNc304uez7jvj6fWnN8GeEHpttB8F36n8N7uVhUMbH/1enbxQ9HImZ4w/9qg==",
+ "requires": {
+ "merge": "1.2.0"
+ }
+ },
+ "execa": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz",
+ "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=",
+ "requires": {
+ "cross-spawn": "5.1.0",
+ "get-stream": "3.0.0",
+ "is-stream": "1.1.0",
+ "npm-run-path": "2.0.2",
+ "p-finally": "1.0.0",
+ "signal-exit": "3.0.2",
+ "strip-eof": "1.0.0"
+ }
+ },
+ "execall": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/execall/-/execall-1.0.0.tgz",
+ "integrity": "sha1-c9CQTjlbPKsGWLCNCewlMH8pu3M=",
+ "requires": {
+ "clone-regexp": "1.0.0"
+ }
+ },
+ "exenv": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz",
+ "integrity": "sha1-KueOhdmJQVhnCwPUe+wfA72Ru50="
+ },
+ "expand-brackets": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz",
+ "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=",
+ "requires": {
+ "is-posix-bracket": "0.1.1"
+ }
+ },
+ "expand-range": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz",
+ "integrity": "sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=",
+ "requires": {
+ "fill-range": "2.2.3"
+ }
+ },
+ "expand-tilde": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-1.2.2.tgz",
+ "integrity": "sha1-C4HrqJflo9MdHD0QL48BRB5VlEk=",
+ "requires": {
+ "os-homedir": "1.0.2"
+ }
+ },
+ "extend": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz",
+ "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ="
+ },
+ "external-editor": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.0.5.tgz",
+ "integrity": "sha512-Msjo64WT5W+NhOpQXh0nOHm+n0RfU1QUwDnKYvJ8dEJ8zlwLrqXNTv5mSUTJpepf41PDJGyhueTw2vNZW+Fr/w==",
+ "requires": {
+ "iconv-lite": "0.4.19",
+ "jschardet": "1.5.1",
+ "tmp": "0.0.33"
+ }
+ },
+ "extglob": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz",
+ "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=",
+ "requires": {
+ "is-extglob": "1.0.0"
+ }
+ },
+ "extract-text-webpack-plugin": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/extract-text-webpack-plugin/-/extract-text-webpack-plugin-3.0.1.tgz",
+ "integrity": "sha512-zv0/Cg2mU8uMzeQQ3oyfJvZU4Iv/GbQYUIr/HU+8pZetT/0W3xj6XAbxoG4gsp8SbnYcFd4BOsCAZPl9NvplPw==",
+ "requires": {
+ "async": "2.5.0",
+ "loader-utils": "1.1.0",
+ "schema-utils": "0.3.0",
+ "webpack-sources": "1.0.1"
+ }
+ },
+ "fancy-log": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.0.tgz",
+ "integrity": "sha1-Rb4X0Cu5kX1gzP/UmVyZnmyMmUg=",
+ "requires": {
+ "chalk": "1.1.3",
+ "time-stamp": "1.1.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ }
+ },
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "fast-deep-equal": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz",
+ "integrity": "sha1-liVqO8l1WV6zbYLpkp0GDYk0Of8="
+ },
+ "fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc="
+ },
+ "fastparse": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.1.tgz",
+ "integrity": "sha1-0eJkOzipTXWDtHkGDmxK/8lAcfg="
+ },
+ "faye-websocket": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.7.3.tgz",
+ "integrity": "sha1-zEB0x/Sk39A69U3WXDVLE1EyzhE=",
+ "requires": {
+ "websocket-driver": "0.7.0"
+ }
+ },
+ "fb-watchman": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.0.tgz",
+ "integrity": "sha1-VOmr99+i8mzZsWNsWIwa/AXeXVg=",
+ "requires": {
+ "bser": "2.0.0"
+ }
+ },
+ "fbjs": {
+ "version": "0.8.16",
+ "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.16.tgz",
+ "integrity": "sha1-XmdDL1UNxBtXK/VYR7ispk5TN9s=",
+ "requires": {
+ "core-js": "1.2.7",
+ "isomorphic-fetch": "2.2.1",
+ "loose-envify": "1.3.1",
+ "object-assign": "4.1.1",
+ "promise": "7.3.1",
+ "setimmediate": "1.0.5",
+ "ua-parser-js": "0.7.17"
+ },
+ "dependencies": {
+ "core-js": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz",
+ "integrity": "sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY="
+ }
+ }
+ },
+ "figures": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz",
+ "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=",
+ "requires": {
+ "escape-string-regexp": "1.0.5"
+ }
+ },
+ "file-entry-cache": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz",
+ "integrity": "sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=",
+ "requires": {
+ "flat-cache": "1.3.0",
+ "object-assign": "4.1.1"
+ }
+ },
+ "file-loader": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-1.1.5.tgz",
+ "integrity": "sha512-RzGHDatcVNpGISTvCpfUfOGpYuSR7HSsSg87ki+wF6rw1Hm0RALPTiAdsxAq1UwLf0RRhbe22/eHK6nhXspiOQ==",
+ "requires": {
+ "loader-utils": "1.1.0",
+ "schema-utils": "0.3.0"
+ }
+ },
+ "filename-regex": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz",
+ "integrity": "sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY="
+ },
+ "filesize": {
+ "version": "3.5.10",
+ "resolved": "https://registry.npmjs.org/filesize/-/filesize-3.5.10.tgz",
+ "integrity": "sha1-/I+iPdtO+eXgq24eZPZ5okpWdh8="
+ },
+ "fill-range": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.3.tgz",
+ "integrity": "sha1-ULd9/X5Gm8dJJHCWNpn+eoSFpyM=",
+ "requires": {
+ "is-number": "2.1.0",
+ "isobject": "2.1.0",
+ "randomatic": "1.1.7",
+ "repeat-element": "1.1.2",
+ "repeat-string": "1.6.1"
+ }
+ },
+ "find-cache-dir": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-1.0.0.tgz",
+ "integrity": "sha1-kojj6ePMN0hxfTnq3hfPcfww7m8=",
+ "requires": {
+ "commondir": "1.0.1",
+ "make-dir": "1.0.0",
+ "pkg-dir": "2.0.0"
+ }
+ },
+ "find-index": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/find-index/-/find-index-0.1.1.tgz",
+ "integrity": "sha1-Z101iyyjiS15Whq0cjL4tuLg3eQ="
+ },
+ "find-up": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
+ "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=",
+ "requires": {
+ "locate-path": "2.0.0"
+ }
+ },
+ "findup-sync": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.4.3.tgz",
+ "integrity": "sha1-QAQ5Kee8YK3wt/SCfExudaDeyhI=",
+ "requires": {
+ "detect-file": "0.1.0",
+ "is-glob": "2.0.1",
+ "micromatch": "2.3.11",
+ "resolve-dir": "0.1.1"
+ }
+ },
+ "fined": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fined/-/fined-1.1.0.tgz",
+ "integrity": "sha1-s33IRLdqL15wgeiE98CuNE8VNHY=",
+ "requires": {
+ "expand-tilde": "2.0.2",
+ "is-plain-object": "2.0.4",
+ "object.defaults": "1.1.0",
+ "object.pick": "1.3.0",
+ "parse-filepath": "1.0.1"
+ },
+ "dependencies": {
+ "expand-tilde": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
+ "integrity": "sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=",
+ "requires": {
+ "homedir-polyfill": "1.0.1"
+ }
+ }
+ }
+ },
+ "first-chunk-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz",
+ "integrity": "sha1-Wb+1DNkF9g18OUzT2ayqtOatk04="
+ },
+ "flagged-respawn": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-0.3.2.tgz",
+ "integrity": "sha1-/xke3c1wiKZ1smEP/8l2vpuAdLU="
+ },
+ "flat-cache": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.3.0.tgz",
+ "integrity": "sha1-0wMLMrOBVPTjt+nHCfSQ9++XxIE=",
+ "requires": {
+ "circular-json": "0.3.3",
+ "del": "2.2.2",
+ "graceful-fs": "4.1.11",
+ "write": "0.2.1"
+ },
+ "dependencies": {
+ "del": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz",
+ "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=",
+ "requires": {
+ "globby": "5.0.0",
+ "is-path-cwd": "1.0.0",
+ "is-path-in-cwd": "1.0.0",
+ "object-assign": "4.1.1",
+ "pify": "2.3.0",
+ "pinkie-promise": "2.0.1",
+ "rimraf": "2.6.2"
+ }
+ },
+ "globby": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz",
+ "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=",
+ "requires": {
+ "array-union": "1.0.2",
+ "arrify": "1.0.1",
+ "glob": "7.1.2",
+ "object-assign": "4.1.1",
+ "pify": "2.3.0",
+ "pinkie-promise": "2.0.1"
+ }
+ }
+ }
+ },
+ "flatten": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/flatten/-/flatten-1.0.2.tgz",
+ "integrity": "sha1-2uRqnXj74lKSJYzB54CkHZXAN4I="
+ },
+ "for-in": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
+ "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA="
+ },
+ "for-own": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz",
+ "integrity": "sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=",
+ "requires": {
+ "for-in": "1.0.2"
+ }
+ },
+ "foreach": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz",
+ "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k="
+ },
+ "from": {
+ "version": "0.1.7",
+ "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz",
+ "integrity": "sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4="
+ },
+ "fs-exists-sync": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz",
+ "integrity": "sha1-mC1ok6+RjnLQjeyehnP/K1qNat0="
+ },
+ "fs-readfile-promise": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/fs-readfile-promise/-/fs-readfile-promise-2.0.1.tgz",
+ "integrity": "sha1-gAI4I5gfn//+AWCei+Zo9prknnA=",
+ "requires": {
+ "graceful-fs": "4.1.11"
+ }
+ },
+ "fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+ },
+ "fstream": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz",
+ "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=",
+ "requires": {
+ "graceful-fs": "4.1.11",
+ "inherits": "2.0.3",
+ "mkdirp": "0.5.1",
+ "rimraf": "2.6.2"
+ }
+ },
+ "function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
+ },
+ "functional-red-black-tree": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
+ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc="
+ },
+ "gaze": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/gaze/-/gaze-0.5.2.tgz",
+ "integrity": "sha1-QLcJU30k0dRXZ9takIaJ3+aaxE8=",
+ "requires": {
+ "globule": "0.1.0"
+ }
+ },
+ "get-caller-file": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.2.tgz",
+ "integrity": "sha1-9wLmMSfn4jHBYKgMFVSstw1QR+U="
+ },
+ "get-node-dimensions": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/get-node-dimensions/-/get-node-dimensions-1.2.0.tgz",
+ "integrity": "sha1-lSmOMqdSoVXynrcQ4GlVe0p/6Yw="
+ },
+ "get-stdin": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-5.0.1.tgz",
+ "integrity": "sha1-Ei4WFZHiH/TFJTAwVpPyDmOTo5g="
+ },
+ "get-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz",
+ "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ="
+ },
+ "glob": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz",
+ "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
+ "requires": {
+ "fs.realpath": "1.0.0",
+ "inflight": "1.0.6",
+ "inherits": "2.0.3",
+ "minimatch": "3.0.4",
+ "once": "1.4.0",
+ "path-is-absolute": "1.0.1"
+ }
+ },
+ "glob-base": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz",
+ "integrity": "sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=",
+ "requires": {
+ "glob-parent": "2.0.0",
+ "is-glob": "2.0.1"
+ }
+ },
+ "glob-parent": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz",
+ "integrity": "sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=",
+ "requires": {
+ "is-glob": "2.0.1"
+ }
+ },
+ "glob-stream": {
+ "version": "3.1.18",
+ "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-3.1.18.tgz",
+ "integrity": "sha1-kXCl8St5Awb9/lmPMT+PeVT9FDs=",
+ "requires": {
+ "glob": "4.5.3",
+ "glob2base": "0.0.12",
+ "minimatch": "2.0.10",
+ "ordered-read-streams": "0.1.0",
+ "through2": "0.6.5",
+ "unique-stream": "1.0.0"
+ },
+ "dependencies": {
+ "glob": {
+ "version": "4.5.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-4.5.3.tgz",
+ "integrity": "sha1-xstz0yJsHv7wTePFbQEvAzd+4V8=",
+ "requires": {
+ "inflight": "1.0.6",
+ "inherits": "2.0.3",
+ "minimatch": "2.0.10",
+ "once": "1.4.0"
+ }
+ },
+ "isarray": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+ "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
+ },
+ "minimatch": {
+ "version": "2.0.10",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-2.0.10.tgz",
+ "integrity": "sha1-jQh8OcazjAAbl/ynzm0OHoCvusc=",
+ "requires": {
+ "brace-expansion": "1.1.8"
+ }
+ },
+ "readable-stream": {
+ "version": "1.0.34",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
+ "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=",
+ "requires": {
+ "core-util-is": "1.0.2",
+ "inherits": "2.0.3",
+ "isarray": "0.0.1",
+ "string_decoder": "0.10.31"
+ }
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+ "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+ },
+ "through2": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz",
+ "integrity": "sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=",
+ "requires": {
+ "readable-stream": "1.0.34",
+ "xtend": "4.0.1"
+ }
+ }
+ }
+ },
+ "glob-watcher": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-0.0.6.tgz",
+ "integrity": "sha1-uVtKjfdLOcgymLDAXJeLTZo7cQs=",
+ "requires": {
+ "gaze": "0.5.2"
+ }
+ },
+ "glob2base": {
+ "version": "0.0.12",
+ "resolved": "https://registry.npmjs.org/glob2base/-/glob2base-0.0.12.tgz",
+ "integrity": "sha1-nUGbPijxLoOjYhZKJ3BVkiycDVY=",
+ "requires": {
+ "find-index": "0.1.1"
+ }
+ },
+ "global-modules": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-0.2.3.tgz",
+ "integrity": "sha1-6lo77ULG1s6ZWk+KEmm12uIjgo0=",
+ "requires": {
+ "global-prefix": "0.1.5",
+ "is-windows": "0.2.0"
+ }
+ },
+ "global-prefix": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-0.1.5.tgz",
+ "integrity": "sha1-jTvGuNo8qBEqFg2NSW/wRiv+948=",
+ "requires": {
+ "homedir-polyfill": "1.0.1",
+ "ini": "1.3.4",
+ "is-windows": "0.2.0",
+ "which": "1.3.0"
+ }
+ },
+ "globals": {
+ "version": "9.18.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz",
+ "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ=="
+ },
+ "globby": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz",
+ "integrity": "sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=",
+ "requires": {
+ "array-union": "1.0.2",
+ "glob": "7.1.2",
+ "object-assign": "4.1.1",
+ "pify": "2.3.0",
+ "pinkie-promise": "2.0.1"
+ }
+ },
+ "globjoin": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz",
+ "integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM="
+ },
+ "globule": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/globule/-/globule-0.1.0.tgz",
+ "integrity": "sha1-2cjt3h2nnRJaFRt5UzuXhnY0auU=",
+ "requires": {
+ "glob": "3.1.21",
+ "lodash": "1.0.2",
+ "minimatch": "0.2.14"
+ },
+ "dependencies": {
+ "glob": {
+ "version": "3.1.21",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-3.1.21.tgz",
+ "integrity": "sha1-0p4KBV3qUTj00H7UDomC6DwgZs0=",
+ "requires": {
+ "graceful-fs": "1.2.3",
+ "inherits": "1.0.2",
+ "minimatch": "0.2.14"
+ }
+ },
+ "graceful-fs": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-1.2.3.tgz",
+ "integrity": "sha1-FaSAaldUfLLS2/J/QuiajDRRs2Q="
+ },
+ "inherits": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-1.0.2.tgz",
+ "integrity": "sha1-ykMJ2t7mtUzAuNJH6NfHoJdb3Js="
+ },
+ "lodash": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-1.0.2.tgz",
+ "integrity": "sha1-j1dWDIO1n8JwvT1WG2kAQ0MOJVE="
+ },
+ "lru-cache": {
+ "version": "2.7.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz",
+ "integrity": "sha1-bUUk6LlV+V1PW1iFHOId1y+06VI="
+ },
+ "minimatch": {
+ "version": "0.2.14",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz",
+ "integrity": "sha1-x054BXT2PG+aCQ6Q775u9TpqdWo=",
+ "requires": {
+ "lru-cache": "2.7.3",
+ "sigmund": "1.0.1"
+ }
+ }
+ }
+ },
+ "glogg": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/glogg/-/glogg-1.0.0.tgz",
+ "integrity": "sha1-f+DxmfV6yQbPUS/urY+Q7kooT8U=",
+ "requires": {
+ "sparkles": "1.0.0"
+ }
+ },
+ "good-listener": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",
+ "integrity": "sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=",
+ "requires": {
+ "delegate": "3.1.3"
+ }
+ },
+ "graceful-fs": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
+ "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg="
+ },
+ "gulp": {
+ "version": "3.9.1",
+ "resolved": "https://registry.npmjs.org/gulp/-/gulp-3.9.1.tgz",
+ "integrity": "sha1-VxzkWSjdQK9lFPxAEYZgFsE4RbQ=",
+ "requires": {
+ "archy": "1.0.0",
+ "chalk": "1.1.3",
+ "deprecated": "0.0.1",
+ "gulp-util": "3.0.8",
+ "interpret": "1.0.4",
+ "liftoff": "2.3.0",
+ "minimist": "1.2.0",
+ "orchestrator": "0.3.8",
+ "pretty-hrtime": "1.0.3",
+ "semver": "4.3.6",
+ "tildify": "1.2.0",
+ "v8flags": "2.1.1",
+ "vinyl-fs": "0.3.14"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ }
+ },
+ "minimist": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+ "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
+ },
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "gulp-cached": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/gulp-cached/-/gulp-cached-1.1.1.tgz",
+ "integrity": "sha1-/nzU+H83YB5gc8/t7lwr2vi2rM4=",
+ "requires": {
+ "lodash.defaults": "4.2.0",
+ "through2": "2.0.3"
+ }
+ },
+ "gulp-clean-css": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/gulp-clean-css/-/gulp-clean-css-3.9.0.tgz",
+ "integrity": "sha512-CsqaSO2ZTMQI/WwbWloZWBudhsRMKgxBthzxt4bbcbWrjOY4pRFziyK9IH6YbTpaWAPKEwWpopPkpiAEoDofxw==",
+ "requires": {
+ "clean-css": "4.1.9",
+ "gulp-util": "3.0.8",
+ "through2": "2.0.3",
+ "vinyl-sourcemaps-apply": "0.2.1"
+ }
+ },
+ "gulp-concat": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/gulp-concat/-/gulp-concat-2.6.1.tgz",
+ "integrity": "sha1-Yz0WyV2IUEYorQJmVmPO5aR5M1M=",
+ "requires": {
+ "concat-with-sourcemaps": "1.0.4",
+ "through2": "2.0.3",
+ "vinyl": "2.1.0"
+ },
+ "dependencies": {
+ "clone": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.1.tgz",
+ "integrity": "sha1-0hfR6WERjjrJpLi7oyhVU79kfNs="
+ },
+ "clone-stats": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-1.0.0.tgz",
+ "integrity": "sha1-s3gt/4u1R04Yuba/D9/ngvh3doA="
+ },
+ "replace-ext": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.0.tgz",
+ "integrity": "sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs="
+ },
+ "vinyl": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.1.0.tgz",
+ "integrity": "sha1-Ah+cLPlR1rk5lDyJ617lrdT9kkw=",
+ "requires": {
+ "clone": "2.1.1",
+ "clone-buffer": "1.0.0",
+ "clone-stats": "1.0.0",
+ "cloneable-readable": "1.0.0",
+ "remove-trailing-separator": "1.1.0",
+ "replace-ext": "1.0.0"
+ }
+ }
+ }
+ },
+ "gulp-declare": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/gulp-declare/-/gulp-declare-0.3.0.tgz",
+ "integrity": "sha1-hoMPxvqojgY4IWLIZkuOlJV6/Nk=",
+ "requires": {
+ "nsdeclare": "0.1.0",
+ "vinyl-map": "1.0.2",
+ "xtend": "4.0.1"
+ }
+ },
+ "gulp-livereload": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/gulp-livereload/-/gulp-livereload-3.8.1.tgz",
+ "integrity": "sha1-APdEstdJ0+njdGWJyKRKysd5tQ8=",
+ "requires": {
+ "chalk": "0.5.1",
+ "debug": "2.6.9",
+ "event-stream": "3.3.4",
+ "gulp-util": "3.0.8",
+ "lodash.assign": "3.2.0",
+ "mini-lr": "0.1.9"
+ },
+ "dependencies": {
+ "ansi-regex": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-0.2.1.tgz",
+ "integrity": "sha1-DY6UaWej2BQ/k+JOKYUl/BsiNfk="
+ },
+ "ansi-styles": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-1.1.0.tgz",
+ "integrity": "sha1-6uy/Zs1waIJ2Cy9GkVgrj1XXp94="
+ },
+ "chalk": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-0.5.1.tgz",
+ "integrity": "sha1-Zjs6ZItotV0EaQ1JFnqoN4WPIXQ=",
+ "requires": {
+ "ansi-styles": "1.1.0",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "0.1.0",
+ "strip-ansi": "0.3.0",
+ "supports-color": "0.2.0"
+ }
+ },
+ "has-ansi": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-0.1.0.tgz",
+ "integrity": "sha1-hPJlqujA5qiKEtcCKJS3VoiUxi4=",
+ "requires": {
+ "ansi-regex": "0.2.1"
+ }
+ },
+ "strip-ansi": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-0.3.0.tgz",
+ "integrity": "sha1-JfSOoiynkYfzF0pNuHWTR7sSYiA=",
+ "requires": {
+ "ansi-regex": "0.2.1"
+ }
+ },
+ "supports-color": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-0.2.0.tgz",
+ "integrity": "sha1-2S3iaU6z9nMjlz1649i1W0wiGQo="
+ }
+ }
+ },
+ "gulp-postcss": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/gulp-postcss/-/gulp-postcss-7.0.0.tgz",
+ "integrity": "sha1-z7YqGfqUf4vmfOnsronOuVnwz5M=",
+ "requires": {
+ "gulp-util": "3.0.8",
+ "postcss": "6.0.13",
+ "postcss-load-config": "1.2.0",
+ "vinyl-sourcemaps-apply": "0.2.1"
+ }
+ },
+ "gulp-print": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/gulp-print/-/gulp-print-2.0.1.tgz",
+ "integrity": "sha1-Gs7ljqyK8tPErTMp2+RldYOTxBQ=",
+ "requires": {
+ "gulp-util": "3.0.8",
+ "map-stream": "0.0.7"
+ },
+ "dependencies": {
+ "map-stream": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz",
+ "integrity": "sha1-ih8HiW2CsQkmvTdEokIACfiJdKg="
+ }
+ }
+ },
+ "gulp-sourcemaps": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/gulp-sourcemaps/-/gulp-sourcemaps-2.6.1.tgz",
+ "integrity": "sha512-1qHCI3hdmsMdq/SUotxwUh/L8YzlI6J9zQ5ifNOtx4Y6KV5y5sGuORv1KZzWhuKtz/mXNh5xLESUtwC4EndCjA==",
+ "requires": {
+ "@gulp-sourcemaps/identity-map": "1.0.1",
+ "@gulp-sourcemaps/map-sources": "1.0.0",
+ "acorn": "4.0.13",
+ "convert-source-map": "1.5.0",
+ "css": "2.2.1",
+ "debug-fabulous": "0.2.1",
+ "detect-newline": "2.1.0",
+ "graceful-fs": "4.1.11",
+ "source-map": "0.6.1",
+ "strip-bom-string": "1.0.0",
+ "through2": "2.0.3",
+ "vinyl": "1.2.0"
+ },
+ "dependencies": {
+ "acorn": {
+ "version": "4.0.13",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz",
+ "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c="
+ },
+ "vinyl": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-1.2.0.tgz",
+ "integrity": "sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=",
+ "requires": {
+ "clone": "1.0.2",
+ "clone-stats": "0.0.1",
+ "replace-ext": "0.0.1"
+ }
+ }
+ }
+ },
+ "gulp-stripbom": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/gulp-stripbom/-/gulp-stripbom-1.0.4.tgz",
+ "integrity": "sha1-WMHQPoXgCKeqtH2BsSl8jBvIKOs=",
+ "requires": {
+ "gulp-util": "3.0.8",
+ "log-symbols": "1.0.2",
+ "strip-bom": "1.0.0",
+ "through2": "0.5.1"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+ "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
+ },
+ "readable-stream": {
+ "version": "1.0.34",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
+ "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=",
+ "requires": {
+ "core-util-is": "1.0.2",
+ "inherits": "2.0.3",
+ "isarray": "0.0.1",
+ "string_decoder": "0.10.31"
+ }
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+ "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+ },
+ "strip-bom": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-1.0.0.tgz",
+ "integrity": "sha1-hbiGLzhEtabV7IRnqTWYFzo295Q=",
+ "requires": {
+ "first-chunk-stream": "1.0.0",
+ "is-utf8": "0.2.1"
+ }
+ },
+ "through2": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-0.5.1.tgz",
+ "integrity": "sha1-390BLrnHAOIyP9M084rGIqs3Lac=",
+ "requires": {
+ "readable-stream": "1.0.34",
+ "xtend": "3.0.0"
+ }
+ },
+ "xtend": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz",
+ "integrity": "sha1-XM50B7r2Qsunvs2laBEcST9ZZlo="
+ }
+ }
+ },
+ "gulp-util": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/gulp-util/-/gulp-util-3.0.8.tgz",
+ "integrity": "sha1-AFTh50RQLifATBh8PsxQXdVLu08=",
+ "requires": {
+ "array-differ": "1.0.0",
+ "array-uniq": "1.0.3",
+ "beeper": "1.1.1",
+ "chalk": "1.1.3",
+ "dateformat": "2.2.0",
+ "fancy-log": "1.3.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.6.2",
+ "minimist": "1.2.0",
+ "multipipe": "0.1.2",
+ "object-assign": "3.0.0",
+ "replace-ext": "0.0.1",
+ "through2": "2.0.3",
+ "vinyl": "0.5.3"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ }
+ },
+ "minimist": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+ "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
+ },
+ "object-assign": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz",
+ "integrity": "sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I="
+ },
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "gulp-watch": {
+ "version": "4.3.11",
+ "resolved": "https://registry.npmjs.org/gulp-watch/-/gulp-watch-4.3.11.tgz",
+ "integrity": "sha1-Fi/FY96fx3DpH5p845VVE6mhGMA=",
+ "requires": {
+ "anymatch": "1.3.2",
+ "chokidar": "1.7.0",
+ "glob-parent": "3.1.0",
+ "gulp-util": "3.0.8",
+ "object-assign": "4.1.1",
+ "path-is-absolute": "1.0.1",
+ "readable-stream": "2.3.3",
+ "slash": "1.0.0",
+ "vinyl": "1.2.0",
+ "vinyl-file": "2.0.0"
+ },
+ "dependencies": {
+ "glob-parent": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz",
+ "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=",
+ "requires": {
+ "is-glob": "3.1.0",
+ "path-dirname": "1.0.2"
+ }
+ },
+ "is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI="
+ },
+ "is-glob": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz",
+ "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=",
+ "requires": {
+ "is-extglob": "2.1.1"
+ }
+ },
+ "vinyl": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-1.2.0.tgz",
+ "integrity": "sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=",
+ "requires": {
+ "clone": "1.0.2",
+ "clone-stats": "0.0.1",
+ "replace-ext": "0.0.1"
+ }
+ }
+ }
+ },
+ "gulp-wrap": {
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/gulp-wrap/-/gulp-wrap-0.13.0.tgz",
+ "integrity": "sha1-kPsLSieiZkM4Mv98YSLbXB7olMY=",
+ "requires": {
+ "consolidate": "0.14.5",
+ "es6-promise": "3.3.1",
+ "fs-readfile-promise": "2.0.1",
+ "gulp-util": "3.0.8",
+ "js-yaml": "3.7.0",
+ "lodash": "4.17.4",
+ "node.extend": "1.1.6",
+ "through2": "2.0.3",
+ "tryit": "1.0.3",
+ "vinyl-bufferstream": "1.0.1"
+ }
+ },
+ "gulplog": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-1.0.0.tgz",
+ "integrity": "sha1-4oxNRdBey77YGDY86PnFkmIp/+U=",
+ "requires": {
+ "glogg": "1.0.0"
+ }
+ },
+ "has": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.1.tgz",
+ "integrity": "sha1-hGFzP1OLCDfJNh45qauelwTcLyg=",
+ "requires": {
+ "function-bind": "1.1.1"
+ }
+ },
+ "has-ansi": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
+ "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=",
+ "requires": {
+ "ansi-regex": "2.1.1"
+ }
+ },
+ "has-flag": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz",
+ "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE="
+ },
+ "has-gulplog": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/has-gulplog/-/has-gulplog-0.1.0.tgz",
+ "integrity": "sha1-ZBTIKRNpfaUVkDl9r7EvIpZ4Ec4=",
+ "requires": {
+ "sparkles": "1.0.0"
+ }
+ },
+ "hash-base": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-2.0.2.tgz",
+ "integrity": "sha1-ZuodhW206KVHDK32/OI65SRO8uE=",
+ "requires": {
+ "inherits": "2.0.3"
+ }
+ },
+ "hash.js": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.3.tgz",
+ "integrity": "sha512-/UETyP0W22QILqS+6HowevwhEFJ3MBJnwTf75Qob9Wz9t0DPuisL8kW8YZMK62dHAKE1c1p+gY1TtOLY+USEHA==",
+ "requires": {
+ "inherits": "2.0.3",
+ "minimalistic-assert": "1.0.0"
+ }
+ },
+ "history": {
+ "version": "4.7.2",
+ "resolved": "https://registry.npmjs.org/history/-/history-4.7.2.tgz",
+ "integrity": "sha512-1zkBRWW6XweO0NBcjiphtVJVsIQ+SXF29z9DVkceeaSLVMFXHool+fdCZD4spDCfZJCILPILc3bm7Bc+HRi0nA==",
+ "requires": {
+ "invariant": "2.2.2",
+ "loose-envify": "1.3.1",
+ "resolve-pathname": "2.2.0",
+ "value-equal": "0.4.0",
+ "warning": "3.0.0"
+ }
+ },
+ "hmac-drbg": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
+ "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=",
+ "requires": {
+ "hash.js": "1.1.3",
+ "minimalistic-assert": "1.0.0",
+ "minimalistic-crypto-utils": "1.0.1"
+ }
+ },
+ "hoist-non-react-statics": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.3.1.tgz",
+ "integrity": "sha1-ND24TGAYxlB3iJgkATWhQg7iLOA="
+ },
+ "home-or-tmp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz",
+ "integrity": "sha1-42w/LSyufXRqhX440Y1fMqeILbg=",
+ "requires": {
+ "os-homedir": "1.0.2",
+ "os-tmpdir": "1.0.2"
+ }
+ },
+ "homedir-polyfill": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.1.tgz",
+ "integrity": "sha1-TCu8inWJmP7r9e1oWA921GdotLw=",
+ "requires": {
+ "parse-passwd": "1.0.0"
+ }
+ },
+ "hosted-git-info": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.5.0.tgz",
+ "integrity": "sha512-pNgbURSuab90KbTqvRPsseaTxOJCZBD0a7t+haSN33piP9cCM4l0CqdzAif2hUqm716UovKB2ROmiabGAKVXyg=="
+ },
+ "html-comment-regex": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.1.tgz",
+ "integrity": "sha1-ZouTd26q5V696POtRkswekljYl4="
+ },
+ "html-tags": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-2.0.0.tgz",
+ "integrity": "sha1-ELMKOGCF9Dzt41PMj6fLDe7qZos="
+ },
+ "http-errors": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.3.1.tgz",
+ "integrity": "sha1-GX4izevUGYWF6GlO9nhhl7ke2UI=",
+ "requires": {
+ "inherits": "2.0.3",
+ "statuses": "1.4.0"
+ }
+ },
+ "http-parser-js": {
+ "version": "0.4.9",
+ "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.4.9.tgz",
+ "integrity": "sha1-6hoE+2St/wJC6ZdPKX3Uw8rSceE="
+ },
+ "https-browserify": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-0.0.1.tgz",
+ "integrity": "sha1-P5E2XKvmC3ftDruiS0VOPgnZWoI="
+ },
+ "iconv-lite": {
+ "version": "0.4.19",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz",
+ "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ=="
+ },
+ "icss-replace-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz",
+ "integrity": "sha1-Bupvg2ead0njhs/h/oEq5dsiPe0="
+ },
+ "icss-utils": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-2.1.0.tgz",
+ "integrity": "sha1-g/Cg7DeL8yRheLbCrZE28TWxyWI=",
+ "requires": {
+ "postcss": "6.0.13"
+ }
+ },
+ "ieee754": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz",
+ "integrity": "sha1-vjPUCsEO8ZJnAfbwii2G+/0a0+Q="
+ },
+ "ignore": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.6.tgz",
+ "integrity": "sha512-HrxmNxKTGZ9a3uAl/FNG66Sdt0G9L4TtMbbUQjP1WhGmSj0FOyHvSgx7623aGJvXfPOur8MwmarlHT+37jmzlw=="
+ },
+ "imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o="
+ },
+ "indent-string": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz",
+ "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=",
+ "requires": {
+ "repeating": "2.0.1"
+ }
+ },
+ "indexes-of": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz",
+ "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc="
+ },
+ "indexof": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz",
+ "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10="
+ },
+ "inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+ "requires": {
+ "once": "1.4.0",
+ "wrappy": "1.0.2"
+ }
+ },
+ "inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+ },
+ "ini": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.4.tgz",
+ "integrity": "sha1-BTfLedr1m1mhpRff9wbIbsA5Fi4="
+ },
+ "inquirer": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-3.3.0.tgz",
+ "integrity": "sha512-h+xtnyk4EwKvFWHrUYsWErEVR+igKtLdchu+o0Z1RL7VU/jVMFbYir2bp6bAj8efFNxWqHX0dIss6fJQ+/+qeQ==",
+ "requires": {
+ "ansi-escapes": "3.0.0",
+ "chalk": "2.2.0",
+ "cli-cursor": "2.1.0",
+ "cli-width": "2.2.0",
+ "external-editor": "2.0.5",
+ "figures": "2.0.0",
+ "lodash": "4.17.4",
+ "mute-stream": "0.0.7",
+ "run-async": "2.3.0",
+ "rx-lite": "4.0.8",
+ "rx-lite-aggregates": "4.0.8",
+ "string-width": "2.1.1",
+ "strip-ansi": "4.0.0",
+ "through": "2.3.8"
+ },
+ "dependencies": {
+ "ansi-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+ "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
+ },
+ "strip-ansi": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+ "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+ "requires": {
+ "ansi-regex": "3.0.0"
+ }
+ }
+ }
+ },
+ "interpret": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.0.4.tgz",
+ "integrity": "sha1-ggzdWIuGj/sZGoCVBtbJyPISsbA="
+ },
+ "invariant": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.2.tgz",
+ "integrity": "sha1-nh9WrArNtr8wMwbzOL47IErmA2A=",
+ "requires": {
+ "loose-envify": "1.3.1"
+ }
+ },
+ "invert-kv": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz",
+ "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY="
+ },
+ "is": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/is/-/is-3.2.1.tgz",
+ "integrity": "sha1-0Kwq1V63sL7JJqUmb2xmKqqD3KU="
+ },
+ "is-absolute": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-0.2.6.tgz",
+ "integrity": "sha1-IN5p89uULvLYe5wto28XIjWxtes=",
+ "requires": {
+ "is-relative": "0.2.1",
+ "is-windows": "0.2.0"
+ }
+ },
+ "is-absolute-url": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-2.1.0.tgz",
+ "integrity": "sha1-UFMN+4T8yap9vnhS6Do3uTufKqY="
+ },
+ "is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0="
+ },
+ "is-binary-path": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz",
+ "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=",
+ "requires": {
+ "binary-extensions": "1.10.0"
+ }
+ },
+ "is-buffer": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.5.tgz",
+ "integrity": "sha1-Hzsm72E7IUuIy8ojzGwB2Hlh7sw="
+ },
+ "is-builtin-module": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz",
+ "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=",
+ "requires": {
+ "builtin-modules": "1.1.1"
+ }
+ },
+ "is-callable": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.3.tgz",
+ "integrity": "sha1-hut1OSgF3cM69xySoO7fdO52BLI="
+ },
+ "is-date-object": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz",
+ "integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY="
+ },
+ "is-directory": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz",
+ "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE="
+ },
+ "is-dotfile": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/is-dotfile/-/is-dotfile-1.0.3.tgz",
+ "integrity": "sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE="
+ },
+ "is-equal-shallow": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz",
+ "integrity": "sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=",
+ "requires": {
+ "is-primitive": "2.0.0"
+ }
+ },
+ "is-extendable": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+ "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik="
+ },
+ "is-extglob": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz",
+ "integrity": "sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA="
+ },
+ "is-finite": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz",
+ "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=",
+ "requires": {
+ "number-is-nan": "1.0.1"
+ }
+ },
+ "is-fullwidth-code-point": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+ "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
+ },
+ "is-glob": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz",
+ "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=",
+ "requires": {
+ "is-extglob": "1.0.0"
+ }
+ },
+ "is-number": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz",
+ "integrity": "sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=",
+ "requires": {
+ "kind-of": "3.2.2"
+ }
+ },
+ "is-path-cwd": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz",
+ "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0="
+ },
+ "is-path-in-cwd": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.0.tgz",
+ "integrity": "sha1-ZHdYK4IU1gI0YJRWcAO+ip6sBNw=",
+ "requires": {
+ "is-path-inside": "1.0.0"
+ }
+ },
+ "is-path-inside": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.0.tgz",
+ "integrity": "sha1-/AbloWg/vaE95mev9xe7wQpI838=",
+ "requires": {
+ "path-is-inside": "1.0.2"
+ }
+ },
+ "is-plain-obj": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz",
+ "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4="
+ },
+ "is-plain-object": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
+ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
+ "requires": {
+ "isobject": "3.0.1"
+ },
+ "dependencies": {
+ "isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
+ }
+ }
+ },
+ "is-posix-bracket": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz",
+ "integrity": "sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q="
+ },
+ "is-primitive": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-2.0.0.tgz",
+ "integrity": "sha1-IHurkWOEmcB7Kt8kCkGochADRXU="
+ },
+ "is-promise": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz",
+ "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o="
+ },
+ "is-regex": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz",
+ "integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=",
+ "requires": {
+ "has": "1.0.1"
+ }
+ },
+ "is-regexp": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
+ "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk="
+ },
+ "is-relative": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-0.2.1.tgz",
+ "integrity": "sha1-0n9MfVFtF1+2ENuEu+7yPDvJeqU=",
+ "requires": {
+ "is-unc-path": "0.1.2"
+ }
+ },
+ "is-resolvable": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.0.0.tgz",
+ "integrity": "sha1-jfV8YeouPFAUCNEA+wE8+NbgzGI=",
+ "requires": {
+ "tryit": "1.0.3"
+ }
+ },
+ "is-stream": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
+ "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ="
+ },
+ "is-supported-regexp-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-supported-regexp-flag/-/is-supported-regexp-flag-1.0.0.tgz",
+ "integrity": "sha1-i1IMhfrnolM4LUsCZS4EVXbhO7g="
+ },
+ "is-svg": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-2.1.0.tgz",
+ "integrity": "sha1-z2EJDaDZ77yrhyLeum8DIgjbsOk=",
+ "requires": {
+ "html-comment-regex": "1.1.1"
+ }
+ },
+ "is-symbol": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.1.tgz",
+ "integrity": "sha1-PMWfAAJRlLarLjjbrmaJJWtmBXI="
+ },
+ "is-unc-path": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-0.1.2.tgz",
+ "integrity": "sha1-arBTpyVzwQJQ/0FqOBTDUXivObk=",
+ "requires": {
+ "unc-path-regex": "0.1.2"
+ }
+ },
+ "is-utf8": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz",
+ "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI="
+ },
+ "is-windows": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-0.2.0.tgz",
+ "integrity": "sha1-3hqm1j6indJIc3tp8f+LgALSEIw="
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+ },
+ "isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
+ },
+ "isobject": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz",
+ "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=",
+ "requires": {
+ "isarray": "1.0.0"
+ }
+ },
+ "isomorphic-fetch": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz",
+ "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=",
+ "requires": {
+ "node-fetch": "1.7.3",
+ "whatwg-fetch": "2.0.3"
+ }
+ },
+ "isstream": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
+ "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo="
+ },
+ "jdu": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/jdu/-/jdu-1.0.0.tgz",
+ "integrity": "sha1-KPHjiFAXha4KHZPpPtCxTdQeUc4="
+ },
+ "jquery": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.2.1.tgz",
+ "integrity": "sha1-XE2d5lKvbNCncBVKYxu6ErAVx4c="
+ },
+ "js-base64": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.3.2.tgz",
+ "integrity": "sha512-Y2/+DnfJJXT1/FCwUebUhLWb3QihxiSC42+ctHLGogmW2jPY6LCapMdFZXRvVP2z6qyKW7s6qncE/9gSqZiArw=="
+ },
+ "js-tokens": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
+ "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls="
+ },
+ "js-yaml": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.7.0.tgz",
+ "integrity": "sha1-XJZ93YN6m/3KXy3oQlOr6KHAO4A=",
+ "requires": {
+ "argparse": "1.0.9",
+ "esprima": "2.7.3"
+ }
+ },
+ "jschardet": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/jschardet/-/jschardet-1.5.1.tgz",
+ "integrity": "sha512-vE2hT1D0HLZCLLclfBSfkfTTedhVj0fubHpJBHKwwUWX0nSbhPAfk+SG9rTX95BYNmau8rGFfCeaT6T5OW1C2A=="
+ },
+ "jsesc": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz",
+ "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s="
+ },
+ "json-loader": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/json-loader/-/json-loader-0.5.7.tgz",
+ "integrity": "sha512-QLPs8Dj7lnf3e3QYS1zkCo+4ZwqOiF9d/nZnYozTISxXWCfNs9yuky5rJw4/W34s7POaNlbZmQGaB5NiXCbP4w=="
+ },
+ "json-schema-traverse": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz",
+ "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A="
+ },
+ "json-stable-stringify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz",
+ "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=",
+ "requires": {
+ "jsonify": "0.0.0"
+ }
+ },
+ "json5": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz",
+ "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE="
+ },
+ "jsonify": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
+ "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM="
+ },
+ "jsx-ast-utils": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-2.0.1.tgz",
+ "integrity": "sha1-6AGxs5mF4g//yHtA43SAgOLcrH8=",
+ "requires": {
+ "array-includes": "3.0.3"
+ }
+ },
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
+ "requires": {
+ "is-buffer": "1.1.5"
+ }
+ },
+ "known-css-properties": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.4.1.tgz",
+ "integrity": "sha512-n+ThoCKhyMFKkMfksdLMP5ndp+VzwDRzQdH6JlmZ2GTpUenYB2EeEKjOue2SErAAG/MmBSUISpwvawDhydWQdQ=="
+ },
+ "lazy-cache": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz",
+ "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4="
+ },
+ "lcid": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz",
+ "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=",
+ "requires": {
+ "invert-kv": "1.0.0"
+ }
+ },
+ "levn": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
+ "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=",
+ "requires": {
+ "prelude-ls": "1.1.2",
+ "type-check": "0.3.2"
+ }
+ },
+ "liftoff": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-2.3.0.tgz",
+ "integrity": "sha1-qY8v9nGD2Lp8+soQVIvX/wVQs4U=",
+ "requires": {
+ "extend": "3.0.1",
+ "findup-sync": "0.4.3",
+ "fined": "1.1.0",
+ "flagged-respawn": "0.3.2",
+ "lodash.isplainobject": "4.0.6",
+ "lodash.isstring": "4.0.1",
+ "lodash.mapvalues": "4.6.0",
+ "rechoir": "0.6.2",
+ "resolve": "1.4.0"
+ }
+ },
+ "livereload-js": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-2.2.2.tgz",
+ "integrity": "sha1-bIclfmSKtHW8JOoldFftzB+NC8I="
+ },
+ "load-json-file": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz",
+ "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=",
+ "requires": {
+ "graceful-fs": "4.1.11",
+ "parse-json": "2.2.0",
+ "pify": "2.3.0",
+ "strip-bom": "3.0.0"
+ }
+ },
+ "loader-fs-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/loader-fs-cache/-/loader-fs-cache-1.0.1.tgz",
+ "integrity": "sha1-VuC/CL2XCLJqdltoUJhAyN7J/bw=",
+ "requires": {
+ "find-cache-dir": "0.1.1",
+ "mkdirp": "0.5.1"
+ },
+ "dependencies": {
+ "find-cache-dir": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-0.1.1.tgz",
+ "integrity": "sha1-yN765XyKUqinhPnjHFfHQumToLk=",
+ "requires": {
+ "commondir": "1.0.1",
+ "mkdirp": "0.5.1",
+ "pkg-dir": "1.0.0"
+ }
+ },
+ "find-up": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz",
+ "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=",
+ "requires": {
+ "path-exists": "2.1.0",
+ "pinkie-promise": "2.0.1"
+ }
+ },
+ "path-exists": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz",
+ "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=",
+ "requires": {
+ "pinkie-promise": "2.0.1"
+ }
+ },
+ "pkg-dir": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-1.0.0.tgz",
+ "integrity": "sha1-ektQio1bstYp1EcFb/TpyTFM89Q=",
+ "requires": {
+ "find-up": "1.1.2"
+ }
+ }
+ }
+ },
+ "loader-runner": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.3.0.tgz",
+ "integrity": "sha1-9IKuqC1UPgeSFwDVpG7yb9rGuKI="
+ },
+ "loader-utils": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.1.0.tgz",
+ "integrity": "sha1-yYrvSIvM7aL/teLeZG1qdUQp9c0=",
+ "requires": {
+ "big.js": "3.2.0",
+ "emojis-list": "2.1.0",
+ "json5": "0.5.1"
+ }
+ },
+ "locate-path": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
+ "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=",
+ "requires": {
+ "p-locate": "2.0.0",
+ "path-exists": "3.0.0"
+ }
+ },
+ "lodash": {
+ "version": "4.17.4",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz",
+ "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4="
+ },
+ "lodash-es": {
+ "version": "4.17.4",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.4.tgz",
+ "integrity": "sha1-3MHXVS4VCgZABzupyzHXDwMpUOc="
+ },
+ "lodash._baseassign": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz",
+ "integrity": "sha1-jDigmVAPIVrQnlnxci/QxSv+Ck4=",
+ "requires": {
+ "lodash._basecopy": "3.0.1",
+ "lodash.keys": "3.1.2"
+ }
+ },
+ "lodash._basecopy": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz",
+ "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY="
+ },
+ "lodash._basetostring": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-3.0.1.tgz",
+ "integrity": "sha1-0YYdh3+CSlL2aYMtyvPuFVZqB9U="
+ },
+ "lodash._basevalues": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/lodash._basevalues/-/lodash._basevalues-3.0.0.tgz",
+ "integrity": "sha1-W3dXYoAr3j0yl1A+JjAIIP32Ybc="
+ },
+ "lodash._bindcallback": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz",
+ "integrity": "sha1-5THCdkTPi1epnhftlbNcdIeJOS4="
+ },
+ "lodash._createassigner": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz",
+ "integrity": "sha1-g4pbri/aymOsIt7o4Z+k5taXCxE=",
+ "requires": {
+ "lodash._bindcallback": "3.0.1",
+ "lodash._isiterateecall": "3.0.9",
+ "lodash.restparam": "3.6.1"
+ }
+ },
+ "lodash._getnative": {
+ "version": "3.9.1",
+ "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz",
+ "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U="
+ },
+ "lodash._isiterateecall": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz",
+ "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw="
+ },
+ "lodash._reescape": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/lodash._reescape/-/lodash._reescape-3.0.0.tgz",
+ "integrity": "sha1-Kx1vXf4HyKNVdT5fJ/rH8c3hYWo="
+ },
+ "lodash._reevaluate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/lodash._reevaluate/-/lodash._reevaluate-3.0.0.tgz",
+ "integrity": "sha1-WLx0xAZklTrgsSTYBpltrKQx4u0="
+ },
+ "lodash._reinterpolate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz",
+ "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0="
+ },
+ "lodash._root": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz",
+ "integrity": "sha1-+6HEUkwZ7ppfgTa0YJ8BfPTe1pI="
+ },
+ "lodash.assign": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-3.2.0.tgz",
+ "integrity": "sha1-POnwI0tLIiPilrj6CsH+6OvKZPo=",
+ "requires": {
+ "lodash._baseassign": "3.2.0",
+ "lodash._createassigner": "3.1.1",
+ "lodash.keys": "3.1.2"
+ }
+ },
+ "lodash.camelcase": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+ "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
+ },
+ "lodash.clone": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz",
+ "integrity": "sha1-GVhwRQ9aExkkeN9Lw9I9LeoZB7Y="
+ },
+ "lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw="
+ },
+ "lodash.escape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-3.2.0.tgz",
+ "integrity": "sha1-mV7g3BjBtIzJLv+ucaEKq1tIdpg=",
+ "requires": {
+ "lodash._root": "3.0.1"
+ }
+ },
+ "lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo="
+ },
+ "lodash.isarray": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz",
+ "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U="
+ },
+ "lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs="
+ },
+ "lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE="
+ },
+ "lodash.kebabcase": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz",
+ "integrity": "sha1-hImxyw0p/4gZXM7KRI/21swpXDY="
+ },
+ "lodash.keys": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz",
+ "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=",
+ "requires": {
+ "lodash._getnative": "3.9.1",
+ "lodash.isarguments": "3.1.0",
+ "lodash.isarray": "3.0.4"
+ }
+ },
+ "lodash.mapvalues": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz",
+ "integrity": "sha1-G6+lAF3p3W9PJmaMMMo3IwzJaJw="
+ },
+ "lodash.memoize": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
+ "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4="
+ },
+ "lodash.restparam": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/lodash.restparam/-/lodash.restparam-3.6.1.tgz",
+ "integrity": "sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU="
+ },
+ "lodash.snakecase": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
+ "integrity": "sha1-OdcUo1NXFHg3rv1ktdy7Fr7Nj40="
+ },
+ "lodash.some": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/lodash.some/-/lodash.some-4.6.0.tgz",
+ "integrity": "sha1-G7nzFO9ri63tE7VJFpsqlF62jk0="
+ },
+ "lodash.template": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-3.6.2.tgz",
+ "integrity": "sha1-+M3sxhaaJVvpCYrosMU9N4kx0U8=",
+ "requires": {
+ "lodash._basecopy": "3.0.1",
+ "lodash._basetostring": "3.0.1",
+ "lodash._basevalues": "3.0.0",
+ "lodash._isiterateecall": "3.0.9",
+ "lodash._reinterpolate": "3.0.0",
+ "lodash.escape": "3.2.0",
+ "lodash.keys": "3.1.2",
+ "lodash.restparam": "3.6.1",
+ "lodash.templatesettings": "3.1.1"
+ }
+ },
+ "lodash.templatesettings": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-3.1.1.tgz",
+ "integrity": "sha1-+zB4RHU7Zrnxr6VOJix0UwfbqOU=",
+ "requires": {
+ "lodash._reinterpolate": "3.0.0",
+ "lodash.escape": "3.2.0"
+ }
+ },
+ "lodash.uniq": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
+ "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M="
+ },
+ "lodash.upperfirst": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz",
+ "integrity": "sha1-E2Xt9DFIBIHvDRxolXpe2Z1J984="
+ },
+ "log-symbols": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-1.0.2.tgz",
+ "integrity": "sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=",
+ "requires": {
+ "chalk": "1.1.3"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ }
+ },
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "longest": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz",
+ "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc="
+ },
+ "loose-envify": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz",
+ "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=",
+ "requires": {
+ "js-tokens": "3.0.2"
+ }
+ },
+ "loud-rejection": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz",
+ "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=",
+ "requires": {
+ "currently-unhandled": "0.4.1",
+ "signal-exit": "3.0.2"
+ }
+ },
+ "lru-cache": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.1.tgz",
+ "integrity": "sha512-q4spe4KTfsAS1SUHLO0wz8Qiyf1+vMIAgpRYioFYDMNqKfHQbg+AVDH3i4fvpl71/P1L0dBl+fQi+P37UYf0ew==",
+ "requires": {
+ "pseudomap": "1.0.2",
+ "yallist": "2.1.2"
+ }
+ },
+ "lru-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz",
+ "integrity": "sha1-Jzi9nw089PhEkMVzbEhpmsYyzaM=",
+ "requires": {
+ "es5-ext": "0.10.35"
+ }
+ },
+ "macaddress": {
+ "version": "0.2.8",
+ "resolved": "https://registry.npmjs.org/macaddress/-/macaddress-0.2.8.tgz",
+ "integrity": "sha1-WQTcU3w57G2+/q6QIycTX6hRHxI="
+ },
+ "make-dir": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.0.0.tgz",
+ "integrity": "sha1-l6ARdR6R3YfPre9Ygy67BJNt6Xg=",
+ "requires": {
+ "pify": "2.3.0"
+ }
+ },
+ "makeerror": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.11.tgz",
+ "integrity": "sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw=",
+ "requires": {
+ "tmpl": "1.0.4"
+ }
+ },
+ "map-cache": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
+ "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8="
+ },
+ "map-obj": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz",
+ "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0="
+ },
+ "map-stream": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz",
+ "integrity": "sha1-5WqpTEyAVaFkBKBnS3jyFffI4ZQ="
+ },
+ "math-expression-evaluator": {
+ "version": "1.2.17",
+ "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz",
+ "integrity": "sha1-3oGf282E3M2PrlnGrreWFbnSZqw="
+ },
+ "mathml-tag-names": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.0.1.tgz",
+ "integrity": "sha1-jUEmgWi/htEQK5gQnijlMeejRXg="
+ },
+ "md5.js": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz",
+ "integrity": "sha1-6b296UogpawYsENA/Fdk1bCdkB0=",
+ "requires": {
+ "hash-base": "3.0.4",
+ "inherits": "2.0.3"
+ },
+ "dependencies": {
+ "hash-base": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz",
+ "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=",
+ "requires": {
+ "inherits": "2.0.3",
+ "safe-buffer": "5.1.1"
+ }
+ }
+ }
+ },
+ "media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
+ },
+ "mem": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/mem/-/mem-1.1.0.tgz",
+ "integrity": "sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y=",
+ "requires": {
+ "mimic-fn": "1.1.0"
+ }
+ },
+ "memoizee": {
+ "version": "0.4.11",
+ "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.11.tgz",
+ "integrity": "sha1-vemBdmPJ5A/bKk6hw2cpYIeujI8=",
+ "requires": {
+ "d": "1.0.0",
+ "es5-ext": "0.10.35",
+ "es6-weak-map": "2.0.2",
+ "event-emitter": "0.3.5",
+ "is-promise": "2.1.0",
+ "lru-queue": "0.1.0",
+ "next-tick": "1.0.0",
+ "timers-ext": "0.1.2"
+ }
+ },
+ "memory-fs": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
+ "integrity": "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=",
+ "requires": {
+ "errno": "0.1.4",
+ "readable-stream": "2.3.3"
+ }
+ },
+ "meow": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz",
+ "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=",
+ "requires": {
+ "camelcase-keys": "2.1.0",
+ "decamelize": "1.2.0",
+ "loud-rejection": "1.6.0",
+ "map-obj": "1.0.1",
+ "minimist": "1.2.0",
+ "normalize-package-data": "2.4.0",
+ "object-assign": "4.1.1",
+ "read-pkg-up": "1.0.1",
+ "redent": "1.0.0",
+ "trim-newlines": "1.0.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz",
+ "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=",
+ "requires": {
+ "path-exists": "2.1.0",
+ "pinkie-promise": "2.0.1"
+ }
+ },
+ "load-json-file": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
+ "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=",
+ "requires": {
+ "graceful-fs": "4.1.11",
+ "parse-json": "2.2.0",
+ "pify": "2.3.0",
+ "pinkie-promise": "2.0.1",
+ "strip-bom": "2.0.0"
+ }
+ },
+ "minimist": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+ "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
+ },
+ "path-exists": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz",
+ "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=",
+ "requires": {
+ "pinkie-promise": "2.0.1"
+ }
+ },
+ "path-type": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz",
+ "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=",
+ "requires": {
+ "graceful-fs": "4.1.11",
+ "pify": "2.3.0",
+ "pinkie-promise": "2.0.1"
+ }
+ },
+ "read-pkg": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz",
+ "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=",
+ "requires": {
+ "load-json-file": "1.1.0",
+ "normalize-package-data": "2.4.0",
+ "path-type": "1.1.0"
+ }
+ },
+ "read-pkg-up": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz",
+ "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=",
+ "requires": {
+ "find-up": "1.1.2",
+ "read-pkg": "1.1.0"
+ }
+ },
+ "strip-bom": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz",
+ "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=",
+ "requires": {
+ "is-utf8": "0.2.1"
+ }
+ }
+ }
+ },
+ "merge": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/merge/-/merge-1.2.0.tgz",
+ "integrity": "sha1-dTHjnUlJwoGma4xabgJl6LBYlNo="
+ },
+ "micromatch": {
+ "version": "2.3.11",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz",
+ "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=",
+ "requires": {
+ "arr-diff": "2.0.0",
+ "array-unique": "0.2.1",
+ "braces": "1.8.5",
+ "expand-brackets": "0.1.5",
+ "extglob": "0.3.2",
+ "filename-regex": "2.0.1",
+ "is-extglob": "1.0.0",
+ "is-glob": "2.0.1",
+ "kind-of": "3.2.2",
+ "normalize-path": "2.1.1",
+ "object.omit": "2.0.1",
+ "parse-glob": "3.0.4",
+ "regex-cache": "0.4.4"
+ }
+ },
+ "miller-rabin": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
+ "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==",
+ "requires": {
+ "bn.js": "4.11.8",
+ "brorand": "1.1.0"
+ }
+ },
+ "mime": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz",
+ "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ=="
+ },
+ "mime-db": {
+ "version": "1.30.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.30.0.tgz",
+ "integrity": "sha1-dMZD2i3Z1qRTmZY0ZbJtXKfXHwE="
+ },
+ "mime-types": {
+ "version": "2.1.17",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.17.tgz",
+ "integrity": "sha1-Cdejk/A+mVp5+K+Fe3Cp4KsWVXo=",
+ "requires": {
+ "mime-db": "1.30.0"
+ }
+ },
+ "mimic-fn": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.1.0.tgz",
+ "integrity": "sha1-5md4PZLonb00KBi1IwudYqZyrRg="
+ },
+ "mini-lr": {
+ "version": "0.1.9",
+ "resolved": "https://registry.npmjs.org/mini-lr/-/mini-lr-0.1.9.tgz",
+ "integrity": "sha1-AhmdJzR5U9H9HW297UJh8Yey0PY=",
+ "requires": {
+ "body-parser": "1.14.2",
+ "debug": "2.6.9",
+ "faye-websocket": "0.7.3",
+ "livereload-js": "2.2.2",
+ "parseurl": "1.3.2",
+ "qs": "2.2.5"
+ },
+ "dependencies": {
+ "qs": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-2.2.5.tgz",
+ "integrity": "sha1-EIirr53MCuWuRbcJ5sa1iIsjkjw="
+ }
+ }
+ },
+ "minimalistic-assert": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.0.tgz",
+ "integrity": "sha1-cCvi3aazf0g2vLP121ZkG2Sh09M="
+ },
+ "minimalistic-crypto-utils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
+ "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo="
+ },
+ "minimatch": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+ "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+ "requires": {
+ "brace-expansion": "1.1.8"
+ }
+ },
+ "minimist": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
+ "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
+ },
+ "mkdirp": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+ "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
+ "requires": {
+ "minimist": "0.0.8"
+ }
+ },
+ "mobile-detect": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/mobile-detect/-/mobile-detect-1.3.7.tgz",
+ "integrity": "sha512-thfQQA1gz4mezj6x3Rr55uEqcyELvgCYQLQEHHgQDY6V0uTh3GTgPJGFd5rB+n085fv7TDJSTqYAaKa9+B1wrg=="
+ },
+ "moment": {
+ "version": "2.18.1",
+ "resolved": "https://registry.npmjs.org/moment/-/moment-2.18.1.tgz",
+ "integrity": "sha1-w2GT3Tzhwu7SrbfIAtu8d6gbHA8="
+ },
+ "mousetrap": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.1.tgz",
+ "integrity": "sha1-KghfXHUSlMdefoH27CVFspy/Qtk="
+ },
+ "mout": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/mout/-/mout-1.1.0.tgz",
+ "integrity": "sha512-XsP0vf4As6BfqglxZqbqQ8SR6KQot2AgxvR0gG+WtUkf90vUXchMOZQtPf/Hml1rEffJupqL/tIrU6EYhsUQjw=="
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+ },
+ "multipipe": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/multipipe/-/multipipe-0.1.2.tgz",
+ "integrity": "sha1-Ko8t33Du1WTf8tV/HhoTfZ8FB4s=",
+ "requires": {
+ "duplexer2": "0.0.2"
+ }
+ },
+ "mute-stream": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz",
+ "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s="
+ },
+ "natives": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/natives/-/natives-1.1.0.tgz",
+ "integrity": "sha1-6f+EFBimsux6SV6TmYT3jxY+bjE="
+ },
+ "natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc="
+ },
+ "new-from": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/new-from/-/new-from-0.0.3.tgz",
+ "integrity": "sha1-HErRNhPePhXWMhtw7Vwjk36iXmc=",
+ "requires": {
+ "readable-stream": "1.1.14"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+ "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
+ },
+ "readable-stream": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+ "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
+ "requires": {
+ "core-util-is": "1.0.2",
+ "inherits": "2.0.3",
+ "isarray": "0.0.1",
+ "string_decoder": "0.10.31"
+ }
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+ "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+ }
+ }
+ },
+ "next-tick": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
+ "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw="
+ },
+ "node-fetch": {
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz",
+ "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==",
+ "requires": {
+ "encoding": "0.1.12",
+ "is-stream": "1.1.0"
+ }
+ },
+ "node-int64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+ "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs="
+ },
+ "node-libs-browser": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.0.0.tgz",
+ "integrity": "sha1-o6WeyXAkmFtG6Vg3lkb5bEthZkY=",
+ "requires": {
+ "assert": "1.4.1",
+ "browserify-zlib": "0.1.4",
+ "buffer": "4.9.1",
+ "console-browserify": "1.1.0",
+ "constants-browserify": "1.0.0",
+ "crypto-browserify": "3.11.1",
+ "domain-browser": "1.1.7",
+ "events": "1.1.1",
+ "https-browserify": "0.0.1",
+ "os-browserify": "0.2.1",
+ "path-browserify": "0.0.0",
+ "process": "0.11.10",
+ "punycode": "1.4.1",
+ "querystring-es3": "0.2.1",
+ "readable-stream": "2.3.3",
+ "stream-browserify": "2.0.1",
+ "stream-http": "2.7.2",
+ "string_decoder": "0.10.31",
+ "timers-browserify": "2.0.4",
+ "tty-browserify": "0.0.0",
+ "url": "0.11.0",
+ "util": "0.10.3",
+ "vm-browserify": "0.0.4"
+ },
+ "dependencies": {
+ "string_decoder": {
+ "version": "0.10.31",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+ "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+ }
+ }
+ },
+ "node.extend": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/node.extend/-/node.extend-1.1.6.tgz",
+ "integrity": "sha1-p7iCyC1sk6SGOlUEvV3o7IYli5Y=",
+ "requires": {
+ "is": "3.2.1"
+ }
+ },
+ "normalize-package-data": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
+ "integrity": "sha512-9jjUFbTPfEy3R/ad/2oNbKtW9Hgovl5O1FvFWKkKblNXoN/Oou6+9+KKohPK13Yc3/TyunyWhJp6gvRNR/PPAw==",
+ "requires": {
+ "hosted-git-info": "2.5.0",
+ "is-builtin-module": "1.0.0",
+ "semver": "4.3.6",
+ "validate-npm-package-license": "3.0.1"
+ }
+ },
+ "normalize-path": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz",
+ "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=",
+ "requires": {
+ "remove-trailing-separator": "1.1.0"
+ }
+ },
+ "normalize-range": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+ "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI="
+ },
+ "normalize-selector": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/normalize-selector/-/normalize-selector-0.2.0.tgz",
+ "integrity": "sha1-0LFF62kRicY6eNIB3E/bEpPvDAM="
+ },
+ "normalize-url": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-1.9.1.tgz",
+ "integrity": "sha1-LMDWazHqIwNkWENuNiDYWVTGbDw=",
+ "requires": {
+ "object-assign": "4.1.1",
+ "prepend-http": "1.0.4",
+ "query-string": "4.3.4",
+ "sort-keys": "1.1.2"
+ },
+ "dependencies": {
+ "query-string": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz",
+ "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=",
+ "requires": {
+ "object-assign": "4.1.1",
+ "strict-uri-encode": "1.1.0"
+ }
+ }
+ }
+ },
+ "normalize.css": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-7.0.0.tgz",
+ "integrity": "sha1-q/sd2CRwZ04DIrU86xqvQSk45L8="
+ },
+ "npm-path": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/npm-path/-/npm-path-1.1.0.tgz",
+ "integrity": "sha1-BHSuAEGcMn1UcBt88s0F3Ii+EUA=",
+ "requires": {
+ "which": "1.3.0"
+ }
+ },
+ "npm-run": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/npm-run/-/npm-run-3.0.0.tgz",
+ "integrity": "sha1-Vokg+ECpj9jiKZ22ayYW4kdsr2k=",
+ "requires": {
+ "minimist": "1.2.0",
+ "npm-path": "1.1.0",
+ "npm-which": "2.0.0",
+ "serializerr": "1.0.3",
+ "sync-exec": "0.6.2"
+ },
+ "dependencies": {
+ "minimist": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+ "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
+ }
+ }
+ },
+ "npm-run-path": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
+ "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=",
+ "requires": {
+ "path-key": "2.0.1"
+ }
+ },
+ "npm-which": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/npm-which/-/npm-which-2.0.0.tgz",
+ "integrity": "sha1-DEaYIWC3gwk2YdHQG9RJbS/qu6w=",
+ "requires": {
+ "commander": "2.11.0",
+ "npm-path": "1.1.0",
+ "which": "1.3.0"
+ }
+ },
+ "nsdeclare": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/nsdeclare/-/nsdeclare-0.1.0.tgz",
+ "integrity": "sha1-ENqhU2QjgtPPLAGpFvTrIKEosZ8="
+ },
+ "num2fraction": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz",
+ "integrity": "sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4="
+ },
+ "number-is-nan": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+ "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
+ },
+ "object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+ },
+ "object-hash": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-1.2.0.tgz",
+ "integrity": "sha512-smRWXzkvxw72VquyZ0wggySl7PFUtoDhvhpdwgESXxUrH7vVhhp9asfup1+rVLrhsl7L45Ee1Q/l5R2Ul4MwUg=="
+ },
+ "object-keys": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.11.tgz",
+ "integrity": "sha1-xUYBd4rVYPEULODgG8yotW0TQm0="
+ },
+ "object.defaults": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz",
+ "integrity": "sha1-On+GgzS0B96gbaFtiNXNKeQ1/s8=",
+ "requires": {
+ "array-each": "1.0.1",
+ "array-slice": "1.0.0",
+ "for-own": "1.0.0",
+ "isobject": "3.0.1"
+ },
+ "dependencies": {
+ "for-own": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz",
+ "integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=",
+ "requires": {
+ "for-in": "1.0.2"
+ }
+ },
+ "isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
+ }
+ }
+ },
+ "object.omit": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.1.tgz",
+ "integrity": "sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=",
+ "requires": {
+ "for-own": "0.1.5",
+ "is-extendable": "0.1.1"
+ }
+ },
+ "object.pick": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
+ "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=",
+ "requires": {
+ "isobject": "3.0.1"
+ },
+ "dependencies": {
+ "isobject": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+ "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
+ }
+ }
+ },
+ "on-finished": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+ "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
+ "requires": {
+ "ee-first": "1.1.1"
+ }
+ },
+ "once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+ "requires": {
+ "wrappy": "1.0.2"
+ }
+ },
+ "onetime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz",
+ "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=",
+ "requires": {
+ "mimic-fn": "1.1.0"
+ }
+ },
+ "optionator": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz",
+ "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=",
+ "requires": {
+ "deep-is": "0.1.3",
+ "fast-levenshtein": "2.0.6",
+ "levn": "0.3.0",
+ "prelude-ls": "1.1.2",
+ "type-check": "0.3.2",
+ "wordwrap": "1.0.0"
+ }
+ },
+ "orchestrator": {
+ "version": "0.3.8",
+ "resolved": "https://registry.npmjs.org/orchestrator/-/orchestrator-0.3.8.tgz",
+ "integrity": "sha1-FOfp4nZPcxX7rBhOUGx6pt+UrX4=",
+ "requires": {
+ "end-of-stream": "0.1.5",
+ "sequencify": "0.0.7",
+ "stream-consume": "0.1.0"
+ }
+ },
+ "ordered-read-streams": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-0.1.0.tgz",
+ "integrity": "sha1-/VZamvjrRHO6abbtijQ1LLVS8SY="
+ },
+ "os-browserify": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.2.1.tgz",
+ "integrity": "sha1-Y/xMzuXS13Y9Jrv4YBB45sLgBE8="
+ },
+ "os-homedir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+ "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M="
+ },
+ "os-locale": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-2.1.0.tgz",
+ "integrity": "sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA==",
+ "requires": {
+ "execa": "0.7.0",
+ "lcid": "1.0.0",
+ "mem": "1.1.0"
+ }
+ },
+ "os-tmpdir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+ "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ="
+ },
+ "p-finally": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+ "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
+ },
+ "p-limit": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.1.0.tgz",
+ "integrity": "sha1-sH/y2aXYi+yAYDWJWiurZqJ5iLw="
+ },
+ "p-locate": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
+ "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=",
+ "requires": {
+ "p-limit": "1.1.0"
+ }
+ },
+ "p-map": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-1.2.0.tgz",
+ "integrity": "sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA=="
+ },
+ "pako": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+ "integrity": "sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU="
+ },
+ "parse-asn1": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.0.tgz",
+ "integrity": "sha1-N8T5t+06tlx0gXtfJICTf7+XxxI=",
+ "requires": {
+ "asn1.js": "4.9.1",
+ "browserify-aes": "1.1.1",
+ "create-hash": "1.1.3",
+ "evp_bytestokey": "1.0.3",
+ "pbkdf2": "3.0.14"
+ }
+ },
+ "parse-filepath": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.1.tgz",
+ "integrity": "sha1-FZ1hVdQ5BNFsEO9piRHaHpGWm3M=",
+ "requires": {
+ "is-absolute": "0.2.6",
+ "map-cache": "0.2.2",
+ "path-root": "0.1.1"
+ }
+ },
+ "parse-glob": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz",
+ "integrity": "sha1-ssN2z7EfNVE7rdFz7wu246OIORw=",
+ "requires": {
+ "glob-base": "0.3.0",
+ "is-dotfile": "1.0.3",
+ "is-extglob": "1.0.0",
+ "is-glob": "2.0.1"
+ }
+ },
+ "parse-json": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz",
+ "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=",
+ "requires": {
+ "error-ex": "1.3.1"
+ }
+ },
+ "parse-passwd": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz",
+ "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY="
+ },
+ "parseurl": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz",
+ "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M="
+ },
+ "path-browserify": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.0.tgz",
+ "integrity": "sha1-oLhwcpquIUAFt9UDLsLLuw+0RRo="
+ },
+ "path-dirname": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz",
+ "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA="
+ },
+ "path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU="
+ },
+ "path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
+ },
+ "path-is-inside": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz",
+ "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM="
+ },
+ "path-key": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
+ "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A="
+ },
+ "path-parse": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz",
+ "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME="
+ },
+ "path-root": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz",
+ "integrity": "sha1-mkpoFMrBwM1zNgqV8yCDyOpHRbc=",
+ "requires": {
+ "path-root-regex": "0.1.2"
+ }
+ },
+ "path-root-regex": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz",
+ "integrity": "sha1-v8zcjfWxLcUsi0PsONGNcsBLqW0="
+ },
+ "path-to-regexp": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz",
+ "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=",
+ "requires": {
+ "isarray": "0.0.1"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+ "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
+ }
+ }
+ },
+ "path-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz",
+ "integrity": "sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=",
+ "requires": {
+ "pify": "2.3.0"
+ }
+ },
+ "pause-stream": {
+ "version": "0.0.11",
+ "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz",
+ "integrity": "sha1-/lo0sMvOErWqaitAPuLnO2AvFEU=",
+ "requires": {
+ "through": "2.3.8"
+ }
+ },
+ "pbkdf2": {
+ "version": "3.0.14",
+ "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.14.tgz",
+ "integrity": "sha512-gjsZW9O34fm0R7PaLHRJmLLVfSoesxztjPjE9o6R+qtVJij90ltg1joIovN9GKrRW3t1PzhDDG3UMEMFfZ+1wA==",
+ "requires": {
+ "create-hash": "1.1.3",
+ "create-hmac": "1.1.6",
+ "ripemd160": "2.0.1",
+ "safe-buffer": "5.1.1",
+ "sha.js": "2.4.9"
+ }
+ },
+ "performance-now": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
+ },
+ "pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw="
+ },
+ "pinkie": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz",
+ "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA="
+ },
+ "pinkie-promise": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz",
+ "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=",
+ "requires": {
+ "pinkie": "2.0.4"
+ }
+ },
+ "pkg-dir": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz",
+ "integrity": "sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=",
+ "requires": {
+ "find-up": "2.1.0"
+ }
+ },
+ "pluralize": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-7.0.0.tgz",
+ "integrity": "sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow=="
+ },
+ "postcss": {
+ "version": "6.0.13",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.13.tgz",
+ "integrity": "sha512-nHsrD1PPTMSJDfU+osVsLtPkSP9YGeoOz4FDLN4r1DW4N5vqL1J+gACzTQHsfwIiWG/0/nV4yCzjTMo1zD8U1g==",
+ "requires": {
+ "chalk": "2.2.0",
+ "source-map": "0.6.1",
+ "supports-color": "4.5.0"
+ }
+ },
+ "postcss-calc": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-5.3.1.tgz",
+ "integrity": "sha1-d7rnypKK2FcW4v2kLyYb98HWW14=",
+ "requires": {
+ "postcss": "5.2.18",
+ "postcss-message-helpers": "2.0.0",
+ "reduce-css-calc": "1.3.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-colormin": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-2.2.2.tgz",
+ "integrity": "sha1-ZjFBfV8OkJo9fsJrJMio0eT5bks=",
+ "requires": {
+ "colormin": "1.1.2",
+ "postcss": "5.2.18",
+ "postcss-value-parser": "3.3.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-convert-values": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-2.6.1.tgz",
+ "integrity": "sha1-u9hZPFwf0uPRwyK7kl3K6Nrk1i0=",
+ "requires": {
+ "postcss": "5.2.18",
+ "postcss-value-parser": "3.3.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-discard-comments": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-2.0.4.tgz",
+ "integrity": "sha1-vv6J+v1bPazlzM5Rt2uBUUvgDj0=",
+ "requires": {
+ "postcss": "5.2.18"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-discard-duplicates": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-2.1.0.tgz",
+ "integrity": "sha1-uavye4isGIFYpesSq8riAmO5GTI=",
+ "requires": {
+ "postcss": "5.2.18"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-discard-empty": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-2.1.0.tgz",
+ "integrity": "sha1-0rS9nVztXr2Nyt52QMfXzX9PkrU=",
+ "requires": {
+ "postcss": "5.2.18"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-discard-overridden": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-0.1.1.tgz",
+ "integrity": "sha1-ix6vVU9ob7KIzYdMVWZ7CqNmjVg=",
+ "requires": {
+ "postcss": "5.2.18"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-discard-unused": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-2.2.3.tgz",
+ "integrity": "sha1-vOMLLMWR/8Y0Mitfs0ZLbZNPRDM=",
+ "requires": {
+ "postcss": "5.2.18",
+ "uniqs": "2.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-filter-plugins": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/postcss-filter-plugins/-/postcss-filter-plugins-2.0.2.tgz",
+ "integrity": "sha1-bYWGJTTXNaxCDkqFgG4fXUKG2Ew=",
+ "requires": {
+ "postcss": "5.2.18",
+ "uniqid": "4.1.1"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-js": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-1.0.1.tgz",
+ "integrity": "sha512-smhUUMF5o5W1ZCQSyh5A3lNOXFLdNrxqyhWbLsGolZH2AgVmlyhxhYbIixfsdKE6r1vG5i7O40DPcvEvE1mvjw==",
+ "requires": {
+ "camelcase-css": "1.0.1",
+ "postcss": "6.0.13"
+ }
+ },
+ "postcss-less": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/postcss-less/-/postcss-less-1.1.1.tgz",
+ "integrity": "sha512-zl0EEqq8Urh37Ppdv9zzhpZpLHrgkxmt6e3O4ftRa7/b8Uq2LV+/KBVM8/KuzmHNu+mthhOArg1lxbfqQ3NUdg==",
+ "requires": {
+ "postcss": "5.2.18"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-load-config": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-1.2.0.tgz",
+ "integrity": "sha1-U56a/J3chiASHr+djDZz4M5Q0oo=",
+ "requires": {
+ "cosmiconfig": "2.2.2",
+ "object-assign": "4.1.1",
+ "postcss-load-options": "1.2.0",
+ "postcss-load-plugins": "2.3.0"
+ }
+ },
+ "postcss-load-options": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-load-options/-/postcss-load-options-1.2.0.tgz",
+ "integrity": "sha1-sJixVZ3awt8EvAuzdfmaXP4rbYw=",
+ "requires": {
+ "cosmiconfig": "2.2.2",
+ "object-assign": "4.1.1"
+ }
+ },
+ "postcss-load-plugins": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/postcss-load-plugins/-/postcss-load-plugins-2.3.0.tgz",
+ "integrity": "sha1-dFdoEWWZrKLwCfrUJrABdQSdjZI=",
+ "requires": {
+ "cosmiconfig": "2.2.2",
+ "object-assign": "4.1.1"
+ }
+ },
+ "postcss-loader": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-2.0.6.tgz",
+ "integrity": "sha512-HIq7yy1hh9KI472Y38iSRV4WupZUNy6zObkxQM/ZuInoaE2+PyX4NcO6jjP5HG5mXL7j5kcNEl0fAG4Kva7O9w==",
+ "requires": {
+ "loader-utils": "1.1.0",
+ "postcss": "6.0.13",
+ "postcss-load-config": "1.2.0",
+ "schema-utils": "0.3.0"
+ }
+ },
+ "postcss-media-query-parser": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
+ "integrity": "sha1-J7Ocb02U+Bsac7j3Y1HGCeXO8kQ="
+ },
+ "postcss-merge-idents": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-2.1.7.tgz",
+ "integrity": "sha1-TFUwMTwI4dWzu/PSu8dH4njuonA=",
+ "requires": {
+ "has": "1.0.1",
+ "postcss": "5.2.18",
+ "postcss-value-parser": "3.3.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-merge-longhand": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-2.0.2.tgz",
+ "integrity": "sha1-I9kM0Sewp3mUkVMyc5A0oaTz1lg=",
+ "requires": {
+ "postcss": "5.2.18"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-merge-rules": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-2.1.2.tgz",
+ "integrity": "sha1-0d9d+qexrMO+VT8OnhDofGG19yE=",
+ "requires": {
+ "browserslist": "1.7.7",
+ "caniuse-api": "1.6.1",
+ "postcss": "5.2.18",
+ "postcss-selector-parser": "2.2.3",
+ "vendors": "1.0.1"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "browserslist": {
+ "version": "1.7.7",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-1.7.7.tgz",
+ "integrity": "sha1-C9dnBCWL6CmyOYu1Dkti0aFmsLk=",
+ "requires": {
+ "caniuse-db": "1.0.30000748",
+ "electron-to-chromium": "1.3.27"
+ }
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-message-helpers": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz",
+ "integrity": "sha1-pPL0+rbk/gAvCu0ABHjN9S+bpg4="
+ },
+ "postcss-minify-font-values": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-1.0.5.tgz",
+ "integrity": "sha1-S1jttWZB66fIR0qzUmyv17vey2k=",
+ "requires": {
+ "object-assign": "4.1.1",
+ "postcss": "5.2.18",
+ "postcss-value-parser": "3.3.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-minify-gradients": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-1.0.5.tgz",
+ "integrity": "sha1-Xb2hE3NwP4PPtKPqOIHY11/15uE=",
+ "requires": {
+ "postcss": "5.2.18",
+ "postcss-value-parser": "3.3.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-minify-params": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-1.2.2.tgz",
+ "integrity": "sha1-rSzgcTc7lDs9kwo/pZo1jCjW8fM=",
+ "requires": {
+ "alphanum-sort": "1.0.2",
+ "postcss": "5.2.18",
+ "postcss-value-parser": "3.3.0",
+ "uniqs": "2.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-minify-selectors": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-2.1.1.tgz",
+ "integrity": "sha1-ssapjAByz5G5MtGkllCBFDEXNb8=",
+ "requires": {
+ "alphanum-sort": "1.0.2",
+ "has": "1.0.1",
+ "postcss": "5.2.18",
+ "postcss-selector-parser": "2.2.3"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-mixins": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/postcss-mixins/-/postcss-mixins-6.1.1.tgz",
+ "integrity": "sha512-wjVUSlKczG4D5xlO5a8UTEJ2FPiuRwNc6qdXm3b4ZucCu2bYfihG2ELTfeDj14k1vBPxMP1mPWK65LeGkszQzw==",
+ "requires": {
+ "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": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.1.0.tgz",
+ "integrity": "sha1-thTJcgvmgW6u41+zpfqh26agXds=",
+ "requires": {
+ "postcss": "6.0.13"
+ }
+ },
+ "postcss-modules-local-by-default": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz",
+ "integrity": "sha1-99gMOYxaOT+nlkRmvRlQCn1hwGk=",
+ "requires": {
+ "css-selector-tokenizer": "0.7.0",
+ "postcss": "6.0.13"
+ }
+ },
+ "postcss-modules-scope": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz",
+ "integrity": "sha1-1upkmUx5+XtipytCb75gVqGUu5A=",
+ "requires": {
+ "css-selector-tokenizer": "0.7.0",
+ "postcss": "6.0.13"
+ }
+ },
+ "postcss-modules-values": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz",
+ "integrity": "sha1-7P+p1+GSUYOJ9CrQ6D9yrsRW6iA=",
+ "requires": {
+ "icss-replace-symbols": "1.1.0",
+ "postcss": "6.0.13"
+ }
+ },
+ "postcss-nested": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-2.1.2.tgz",
+ "integrity": "sha512-CU7KjbFOZSNrbFwrl8+KJHTj29GjCEhL86kCKyvf+k633fc+FQA6IuhGyPze5e+a4O5d2fP7hDlMOlVDXia1Xg==",
+ "requires": {
+ "postcss": "6.0.13",
+ "postcss-selector-parser": "2.2.3"
+ }
+ },
+ "postcss-normalize-charset": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-1.1.1.tgz",
+ "integrity": "sha1-757nEhLX/nWceO0WL2HtYrXLk/E=",
+ "requires": {
+ "postcss": "5.2.18"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-normalize-url": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-3.0.8.tgz",
+ "integrity": "sha1-EI90s/L82viRov+j6kWSJ5/HgiI=",
+ "requires": {
+ "is-absolute-url": "2.1.0",
+ "normalize-url": "1.9.1",
+ "postcss": "5.2.18",
+ "postcss-value-parser": "3.3.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-ordered-values": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-2.2.3.tgz",
+ "integrity": "sha1-7sbCpntsQSqNsgQud/6NpD+VwR0=",
+ "requires": {
+ "postcss": "5.2.18",
+ "postcss-value-parser": "3.3.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-reduce-idents": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-2.4.0.tgz",
+ "integrity": "sha1-wsbSDMlYKE9qv75j92Cb9AkFmtM=",
+ "requires": {
+ "postcss": "5.2.18",
+ "postcss-value-parser": "3.3.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-reduce-initial": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-1.0.1.tgz",
+ "integrity": "sha1-aPgGlfBF0IJjqHmtJA343WT2ROo=",
+ "requires": {
+ "postcss": "5.2.18"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-reduce-transforms": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-1.0.4.tgz",
+ "integrity": "sha1-/3b02CEkN7McKYpC0uFEQCV3GuE=",
+ "requires": {
+ "has": "1.0.1",
+ "postcss": "5.2.18",
+ "postcss-value-parser": "3.3.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-reporter": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-reporter/-/postcss-reporter-5.0.0.tgz",
+ "integrity": "sha512-rBkDbaHAu5uywbCR2XE8a25tats3xSOsGNx6mppK6Q9kSFGKc/FyAzfci+fWM2l+K402p1D0pNcfDGxeje5IKg==",
+ "requires": {
+ "chalk": "2.2.0",
+ "lodash": "4.17.4",
+ "log-symbols": "2.1.0",
+ "postcss": "6.0.13"
+ },
+ "dependencies": {
+ "log-symbols": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.1.0.tgz",
+ "integrity": "sha512-zLeLrzMA1A2vRF1e/0Mo+LNINzi6jzBylHj5WqvQ/WK/5WCZt8si9SyN4p9llr/HRYvVR1AoXHRHl4WTHyQAzQ==",
+ "requires": {
+ "chalk": "2.2.0"
+ }
+ }
+ }
+ },
+ "postcss-resolve-nested-selector": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz",
+ "integrity": "sha1-Kcy8fDfe36wwTp//C/FZaz9qDk4="
+ },
+ "postcss-safe-parser": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-3.0.1.tgz",
+ "integrity": "sha1-t1Pv9sfArqXoN1++TN6L+QY/8UI=",
+ "requires": {
+ "postcss": "6.0.13"
+ }
+ },
+ "postcss-scss": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-1.0.2.tgz",
+ "integrity": "sha1-/0XPM1S4ee6JpOtoaA9GrJuxT5Q=",
+ "requires": {
+ "postcss": "6.0.13"
+ }
+ },
+ "postcss-selector-parser": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-2.2.3.tgz",
+ "integrity": "sha1-+UN3iGBsPJrO4W/+jYsWKX8nu5A=",
+ "requires": {
+ "flatten": "1.0.2",
+ "indexes-of": "1.0.1",
+ "uniq": "1.0.1"
+ }
+ },
+ "postcss-simple-vars": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-simple-vars/-/postcss-simple-vars-4.1.0.tgz",
+ "integrity": "sha512-J/TRomA8EqXhS4VjQJsPCYTFIa9FYN/dkJK/8oZ0BYeVIPx91goqM8T+ljsP57+4bwSEywFOuB7EZ8n1gjjxZw==",
+ "requires": {
+ "postcss": "6.0.13"
+ }
+ },
+ "postcss-sorting": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/postcss-sorting/-/postcss-sorting-3.0.2.tgz",
+ "integrity": "sha1-c+aRO3FUJiAdIuihdpsFAio3qvw=",
+ "requires": {
+ "lodash": "4.17.4",
+ "postcss": "6.0.13"
+ }
+ },
+ "postcss-svgo": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-2.1.6.tgz",
+ "integrity": "sha1-tt8YqmE7Zm4TPwittSGcJoSsEI0=",
+ "requires": {
+ "is-svg": "2.1.0",
+ "postcss": "5.2.18",
+ "postcss-value-parser": "3.3.0",
+ "svgo": "0.7.2"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-unique-selectors": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-2.0.2.tgz",
+ "integrity": "sha1-mB1X0p3csz57Hf4f1DuGSfkzyh0=",
+ "requires": {
+ "alphanum-sort": "1.0.2",
+ "postcss": "5.2.18",
+ "uniqs": "2.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "postcss-value-parser": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz",
+ "integrity": "sha1-h/OPnxj3dKSrTIojL1xc6IcqnRU="
+ },
+ "postcss-zindex": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-2.2.0.tgz",
+ "integrity": "sha1-0hCd3AVbka9n/EyzsCWUZjnSryI=",
+ "requires": {
+ "has": "1.0.1",
+ "postcss": "5.2.18",
+ "uniqs": "2.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "has-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz",
+ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo="
+ },
+ "postcss": {
+ "version": "5.2.18",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz",
+ "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==",
+ "requires": {
+ "chalk": "1.1.3",
+ "js-base64": "2.3.2",
+ "source-map": "0.5.7",
+ "supports-color": "3.2.3"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "supports-color": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
+ "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=",
+ "requires": {
+ "has-flag": "1.0.0"
+ }
+ }
+ }
+ },
+ "prefix-style": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/prefix-style/-/prefix-style-2.0.1.tgz",
+ "integrity": "sha1-ZrupqHDP2jCKXcIOhekSCTLJWgY="
+ },
+ "prelude-ls": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
+ "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ="
+ },
+ "prepend-http": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz",
+ "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw="
+ },
+ "preserve": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz",
+ "integrity": "sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks="
+ },
+ "pretty-hrtime": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz",
+ "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE="
+ },
+ "private": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz",
+ "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg=="
+ },
+ "process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI="
+ },
+ "process-nextick-args": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz",
+ "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M="
+ },
+ "progress": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.0.tgz",
+ "integrity": "sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8="
+ },
+ "promise": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
+ "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
+ "requires": {
+ "asap": "2.0.6"
+ }
+ },
+ "prop-types": {
+ "version": "15.6.0",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.0.tgz",
+ "integrity": "sha1-zq8IMCL8RrSjX2nhPvda7Q1jmFY=",
+ "requires": {
+ "fbjs": "0.8.16",
+ "loose-envify": "1.3.1",
+ "object-assign": "4.1.1"
+ }
+ },
+ "protochain": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/protochain/-/protochain-1.0.5.tgz",
+ "integrity": "sha1-mRxAfpneJkqt+PgVBLXn+ve/omA="
+ },
+ "prr": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/prr/-/prr-0.0.0.tgz",
+ "integrity": "sha1-GoS4WQgyVQFBGFPQCB7j+obikmo="
+ },
+ "pseudomap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
+ "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM="
+ },
+ "public-encrypt": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.0.tgz",
+ "integrity": "sha1-OfaZ86RlYN1eusvKaTyvfGXBjMY=",
+ "requires": {
+ "bn.js": "4.11.8",
+ "browserify-rsa": "4.0.1",
+ "create-hash": "1.1.3",
+ "parse-asn1": "5.1.0",
+ "randombytes": "2.0.5"
+ }
+ },
+ "punycode": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
+ "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4="
+ },
+ "q": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
+ "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc="
+ },
+ "qs": {
+ "version": "6.5.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz",
+ "integrity": "sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A=="
+ },
+ "query-string": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.0.0.tgz",
+ "integrity": "sha1-+99wBLTSr/eS+YcZgbeieU9VWUc=",
+ "requires": {
+ "decode-uri-component": "0.2.0",
+ "object-assign": "4.1.1",
+ "strict-uri-encode": "1.1.0"
+ }
+ },
+ "querystring": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
+ "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA="
+ },
+ "querystring-es3": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz",
+ "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM="
+ },
+ "raf": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.0.tgz",
+ "integrity": "sha512-pDP/NMRAXoTfrhCfyfSEwJAKLaxBU9eApMeBPB1TkDouZmvPerIClV8lTAd+uF8ZiTaVl69e1FCxQrAd/VTjGw==",
+ "requires": {
+ "performance-now": "2.1.0"
+ }
+ },
+ "randomatic": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.7.tgz",
+ "integrity": "sha512-D5JUjPyJbaJDkuAazpVnSfVkLlpeO3wDlPROTMLGKG1zMFNFRgrciKo1ltz/AzNTkqE0HzDx655QOL51N06how==",
+ "requires": {
+ "is-number": "3.0.0",
+ "kind-of": "4.0.0"
+ },
+ "dependencies": {
+ "is-number": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
+ "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
+ "requires": {
+ "kind-of": "3.2.2"
+ },
+ "dependencies": {
+ "kind-of": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz",
+ "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=",
+ "requires": {
+ "is-buffer": "1.1.5"
+ }
+ }
+ }
+ },
+ "kind-of": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz",
+ "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=",
+ "requires": {
+ "is-buffer": "1.1.5"
+ }
+ }
+ }
+ },
+ "randombytes": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.5.tgz",
+ "integrity": "sha512-8T7Zn1AhMsQ/HI1SjcCfT/t4ii3eAqco3yOcSzS4mozsOz69lHLsoMXmF9nZgnFanYscnSlUSgs8uZyKzpE6kg==",
+ "requires": {
+ "safe-buffer": "5.1.1"
+ }
+ },
+ "raven-for-redux": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/raven-for-redux/-/raven-for-redux-1.0.0.tgz",
+ "integrity": "sha1-NnHC/TmxVbkucBOJWAb/df8tXpY="
+ },
+ "raven-js": {
+ "version": "3.17.0",
+ "resolved": "https://registry.npmjs.org/raven-js/-/raven-js-3.17.0.tgz",
+ "integrity": "sha1-d5RXrHkQUSw8LMm7bQqe61mpaew="
+ },
+ "raw-body": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.1.7.tgz",
+ "integrity": "sha1-rf6s4uT7MJgFgBTQjActzFl1h3Q=",
+ "requires": {
+ "bytes": "2.4.0",
+ "iconv-lite": "0.4.13",
+ "unpipe": "1.0.0"
+ },
+ "dependencies": {
+ "bytes": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-2.4.0.tgz",
+ "integrity": "sha1-fZcZb51br39pNeJZhVSe3SpsIzk="
+ },
+ "iconv-lite": {
+ "version": "0.4.13",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.13.tgz",
+ "integrity": "sha1-H4irpKsLFQjoMSrMOTRfNumS4vI="
+ }
+ }
+ },
+ "react": {
+ "version": "15.6.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-15.6.0.tgz",
+ "integrity": "sha1-wjKZtI4w7TAlCM6J4aAskZ+Ca84=",
+ "requires": {
+ "create-react-class": "15.6.2",
+ "fbjs": "0.8.16",
+ "loose-envify": "1.3.1",
+ "object-assign": "4.1.1",
+ "prop-types": "15.6.0"
+ }
+ },
+ "react-addons-shallow-compare": {
+ "version": "15.6.2",
+ "resolved": "https://registry.npmjs.org/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.2.tgz",
+ "integrity": "sha1-GYoAuR/DdiPbZKKP0XtZa6NicC8=",
+ "requires": {
+ "fbjs": "0.8.16",
+ "object-assign": "4.1.1"
+ }
+ },
+ "react-async-script": {
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-0.9.1.tgz",
+ "integrity": "sha1-1KSWxyb6sMbvBZmKsW5Qx0KCC2A=",
+ "requires": {
+ "prop-types": "15.6.0"
+ }
+ },
+ "react-autosuggest": {
+ "version": "9.3.2",
+ "resolved": "https://registry.npmjs.org/react-autosuggest/-/react-autosuggest-9.3.2.tgz",
+ "integrity": "sha512-/wY64zaFVny9OqcRvLwhcJz9SzpYK5qni+7sjrQOPKN1gFKi5cxy+cMOPEE7u5ZfXLj5mDxf+RNxNWM1wo39Xg==",
+ "requires": {
+ "prop-types": "15.6.0",
+ "react-autowhatever": "10.1.0",
+ "shallow-equal": "1.0.0"
+ }
+ },
+ "react-autowhatever": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/react-autowhatever/-/react-autowhatever-10.1.0.tgz",
+ "integrity": "sha512-LMZggoRgcmldAMyABY3Dz/DRiTQViMsQllXtOsDrZeBRwPIfn0RAOySaQMUNyECrHaCB5pm66jgQvkyNSh/BjA==",
+ "requires": {
+ "prop-types": "15.6.0",
+ "react-themeable": "1.1.0",
+ "section-iterator": "2.0.0"
+ }
+ },
+ "react-custom-scrollbars": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/react-custom-scrollbars/-/react-custom-scrollbars-4.1.2.tgz",
+ "integrity": "sha1-DmDEpGxKYfnkmUp2Y+K5y7xRh6M=",
+ "requires": {
+ "dom-css": "2.1.0",
+ "prop-types": "15.6.0",
+ "raf": "3.4.0"
+ }
+ },
+ "react-dnd": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-2.5.4.tgz",
+ "integrity": "sha512-y9YmnusURc+3KPgvhYKvZ9oCucj51MSZWODyaeV0KFU0cquzA7dCD1g/OIYUKtNoZ+MXtacDngkdud2TklMSjw==",
+ "requires": {
+ "disposables": "1.0.1",
+ "dnd-core": "2.5.4",
+ "hoist-non-react-statics": "2.3.1",
+ "invariant": "2.2.2",
+ "lodash": "4.17.4",
+ "prop-types": "15.6.0"
+ }
+ },
+ "react-dnd-html5-backend": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-2.5.4.tgz",
+ "integrity": "sha512-jDqAkm/hI8Tl4HcsbhkBgB6HgpJR1e+ML1SbfxaegXYiuMxEVQm0FOwEH5WxUoo6fmIG4N+H0rSm59POuZOCaA==",
+ "requires": {
+ "lodash": "4.17.4"
+ }
+ },
+ "react-document-title": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/react-document-title/-/react-document-title-2.0.3.tgz",
+ "integrity": "sha1-u/kioNcUEvyUgkXkKDskEt9w8rk=",
+ "requires": {
+ "prop-types": "15.6.0",
+ "react-side-effect": "1.1.3"
+ }
+ },
+ "react-dom": {
+ "version": "15.6.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-15.6.0.tgz",
+ "integrity": "sha1-i8I8sMgOcGNVt2yp+M5Hz3vfttE=",
+ "requires": {
+ "fbjs": "0.8.16",
+ "loose-envify": "1.3.1",
+ "object-assign": "4.1.1",
+ "prop-types": "15.5.10"
+ },
+ "dependencies": {
+ "prop-types": {
+ "version": "15.5.10",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.5.10.tgz",
+ "integrity": "sha1-J5ffwxJhguOpXj37suiT3ddFYVQ=",
+ "requires": {
+ "fbjs": "0.8.16",
+ "loose-envify": "1.3.1"
+ }
+ }
+ }
+ },
+ "react-google-recaptcha": {
+ "version": "0.9.7",
+ "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-0.9.7.tgz",
+ "integrity": "sha1-E62UQBHQJWbovQbuhWes4hodDtY=",
+ "requires": {
+ "prop-types": "15.6.0"
+ }
+ },
+ "react-lazyload": {
+ "version": "2.2.7",
+ "resolved": "https://registry.npmjs.org/react-lazyload/-/react-lazyload-2.2.7.tgz",
+ "integrity": "sha1-at8HE/MiQKIcYwq10ejPYtDCZK8=",
+ "requires": {
+ "prop-types": "15.6.0"
+ }
+ },
+ "react-measure": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/react-measure/-/react-measure-1.4.7.tgz",
+ "integrity": "sha1-odLKDc/vBJeLesJjp2XctqCTb9s=",
+ "requires": {
+ "get-node-dimensions": "1.2.0",
+ "prop-types": "15.6.0",
+ "resize-observer-polyfill": "1.4.2"
+ }
+ },
+ "react-portal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/react-portal/-/react-portal-3.1.0.tgz",
+ "integrity": "sha1-hlxE+3Kh2hBsZJIGk2VZzoke6Jk=",
+ "requires": {
+ "prop-types": "15.6.0"
+ }
+ },
+ "react-redux": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.0.6.tgz",
+ "integrity": "sha512-8taaaGu+J7PMJQDJrk/xiWEYQmdo3mkXw6wPr3K3LxvXis3Fymiq7c13S+Tpls/AyNUAsoONkU81AP0RA6y6Vw==",
+ "requires": {
+ "hoist-non-react-statics": "2.3.1",
+ "invariant": "2.2.2",
+ "lodash": "4.17.4",
+ "lodash-es": "4.17.4",
+ "loose-envify": "1.3.1",
+ "prop-types": "15.6.0"
+ }
+ },
+ "react-router": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-4.2.0.tgz",
+ "integrity": "sha512-DY6pjwRhdARE4TDw7XjxjZsbx9lKmIcyZoZ+SDO7SBJ1KUeWNxT22Kara2AC7u6/c2SYEHlEDLnzBCcNhLE8Vg==",
+ "requires": {
+ "history": "4.7.2",
+ "hoist-non-react-statics": "2.3.1",
+ "invariant": "2.2.2",
+ "loose-envify": "1.3.1",
+ "path-to-regexp": "1.7.0",
+ "prop-types": "15.6.0",
+ "warning": "3.0.0"
+ }
+ },
+ "react-router-dom": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-4.2.2.tgz",
+ "integrity": "sha512-cHMFC1ZoLDfEaMFoKTjN7fry/oczMgRt5BKfMAkTu5zEuJvUiPp1J8d0eXSVTnBh6pxlbdqDhozunOOLtmKfPA==",
+ "requires": {
+ "history": "4.7.2",
+ "invariant": "2.2.2",
+ "loose-envify": "1.3.1",
+ "prop-types": "15.6.0",
+ "react-router": "4.2.0",
+ "warning": "3.0.0"
+ }
+ },
+ "react-router-redux": {
+ "version": "5.0.0-alpha.6",
+ "resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-5.0.0-alpha.6.tgz",
+ "integrity": "sha1-dBhmPC7NPFG+hW/PKPPR3uzBpXY=",
+ "requires": {
+ "history": "4.7.2",
+ "prop-types": "15.6.0",
+ "react-router": "4.2.0"
+ }
+ },
+ "react-side-effect": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.1.3.tgz",
+ "integrity": "sha1-USwlq+DewXKDTEAB7FxR4E1BvFw=",
+ "requires": {
+ "exenv": "1.2.2",
+ "shallowequal": "1.0.2"
+ }
+ },
+ "react-slider": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/react-slider/-/react-slider-0.9.0.tgz",
+ "integrity": "sha512-xAcW33uW82317OcJw0vcpT2N949MqwNSy53CiuPwXOEx/g4BdFbOgfemlsueEd+3q7DvAAP/Kj8fiy+1rekfiA=="
+ },
+ "react-tabs": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-2.1.0.tgz",
+ "integrity": "sha512-40jhOZ5ptJFRhO/6hxCesjv24f83HBWKjV2hJTIN5qZBKmZmTDMbIcgEjUUZ5fPSRhAvhv8rVrmZpIMIRnR9BQ==",
+ "requires": {
+ "classnames": "2.2.5",
+ "prop-types": "15.6.0"
+ }
+ },
+ "react-tag-autocomplete": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-5.4.1.tgz",
+ "integrity": "sha1-lBJmxz8m4y8VHtlPyShSWP3/Dv8="
+ },
+ "react-tether": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/react-tether/-/react-tether-0.5.7.tgz",
+ "integrity": "sha1-QY6mEEG2W5WCcUeEibcaNXLwFCI=",
+ "requires": {
+ "prop-types": "15.6.0",
+ "tether": "1.4.0"
+ }
+ },
+ "react-text-truncate": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/react-text-truncate/-/react-text-truncate-0.12.0.tgz",
+ "integrity": "sha1-SJsZnyGL5Iqz35vh31eEU0j3fiM=",
+ "requires": {
+ "prop-types": "15.6.0"
+ }
+ },
+ "react-themeable": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz",
+ "integrity": "sha1-fURm3ZsrX6dQWHJ4JenxUro3mg4=",
+ "requires": {
+ "object-assign": "3.0.0"
+ },
+ "dependencies": {
+ "object-assign": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz",
+ "integrity": "sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I="
+ }
+ }
+ },
+ "react-truncate": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/react-truncate/-/react-truncate-2.2.2.tgz",
+ "integrity": "sha512-9WEnc2xopxCnCIipm4kvoV1KJof1lyrH17vq0qkCUrY7LRpL7baftX6es3WQfNGLsm74XKBMqQWTLdP1Rjek1w=="
+ },
+ "react-virtualized": {
+ "version": "9.10.1",
+ "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.10.1.tgz",
+ "integrity": "sha512-A58MfDt5hyy3FoIni+5HDZM1jihgvHG81Mzld34Auhi6X7tRYcN4OCYuRb9eH6w/6CAAKC2MHd+f9z7yeYDCqA==",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "classnames": "2.2.5",
+ "dom-helpers": "3.2.1",
+ "loose-envify": "1.3.1",
+ "prop-types": "15.6.0"
+ }
+ },
+ "read-pkg": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz",
+ "integrity": "sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=",
+ "requires": {
+ "load-json-file": "2.0.0",
+ "normalize-package-data": "2.4.0",
+ "path-type": "2.0.0"
+ }
+ },
+ "read-pkg-up": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz",
+ "integrity": "sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=",
+ "requires": {
+ "find-up": "2.1.0",
+ "read-pkg": "2.0.0"
+ }
+ },
+ "readable-stream": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz",
+ "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==",
+ "requires": {
+ "core-util-is": "1.0.2",
+ "inherits": "2.0.3",
+ "isarray": "1.0.0",
+ "process-nextick-args": "1.0.7",
+ "safe-buffer": "5.1.1",
+ "string_decoder": "1.0.3",
+ "util-deprecate": "1.0.2"
+ }
+ },
+ "readdirp": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.1.0.tgz",
+ "integrity": "sha1-TtCtBg3zBzMAxIRANz9y0cxkLXg=",
+ "requires": {
+ "graceful-fs": "4.1.11",
+ "minimatch": "3.0.4",
+ "readable-stream": "2.3.3",
+ "set-immediate-shim": "1.0.1"
+ }
+ },
+ "rechoir": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz",
+ "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=",
+ "requires": {
+ "resolve": "1.4.0"
+ }
+ },
+ "redent": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz",
+ "integrity": "sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=",
+ "requires": {
+ "indent-string": "2.1.0",
+ "strip-indent": "1.0.1"
+ }
+ },
+ "reduce-css-calc": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz",
+ "integrity": "sha1-dHyRTgSWFKTJz7umKYca0dKSdxY=",
+ "requires": {
+ "balanced-match": "0.4.2",
+ "math-expression-evaluator": "1.2.17",
+ "reduce-function-call": "1.0.2"
+ },
+ "dependencies": {
+ "balanced-match": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz",
+ "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg="
+ }
+ }
+ },
+ "reduce-function-call": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.2.tgz",
+ "integrity": "sha1-WiAL+S4ON3UXUv5FsKszD9S2vpk=",
+ "requires": {
+ "balanced-match": "0.4.2"
+ },
+ "dependencies": {
+ "balanced-match": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz",
+ "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg="
+ }
+ }
+ },
+ "reduce-reducers": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/reduce-reducers/-/reduce-reducers-0.1.2.tgz",
+ "integrity": "sha1-+htHGLxSkqcd3R5dg5yb6pdw8Us="
+ },
+ "redux": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz",
+ "integrity": "sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A==",
+ "requires": {
+ "lodash": "4.17.4",
+ "lodash-es": "4.17.4",
+ "loose-envify": "1.3.1",
+ "symbol-observable": "1.0.4"
+ }
+ },
+ "redux-actions": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/redux-actions/-/redux-actions-2.2.1.tgz",
+ "integrity": "sha1-1kGGslZJoTwFR4VH1811N7iSQQ0=",
+ "requires": {
+ "invariant": "2.2.2",
+ "lodash": "4.17.4",
+ "lodash-es": "4.17.4",
+ "reduce-reducers": "0.1.2"
+ }
+ },
+ "redux-batched-actions": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/redux-batched-actions/-/redux-batched-actions-0.2.0.tgz",
+ "integrity": "sha1-2gAAyIKw5shhqW1YI702rfXZwN0="
+ },
+ "redux-localstorage": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/redux-localstorage/-/redux-localstorage-0.4.1.tgz",
+ "integrity": "sha1-+vbXGcWBOXKU2BFHP/zt7gZckzw="
+ },
+ "redux-thunk": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.2.0.tgz",
+ "integrity": "sha1-5hWhbha0ehmlFXZhM9Hj6Zt4UuU="
+ },
+ "regenerate": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.3.3.tgz",
+ "integrity": "sha512-jVpo1GadrDAK59t/0jRx5VxYWQEDkkEKi6+HjE3joFVLfDOh9Xrdh0dF1eSq+BI/SwvTQ44gSscJ8N5zYL61sg=="
+ },
+ "regenerator-runtime": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.0.tgz",
+ "integrity": "sha512-/aA0kLeRb5N9K0d4fw7ooEbI+xDe+DKD499EQqygGqeS8N3xto15p09uY2xj7ixP81sNPXvRLnAQIqdVStgb1A=="
+ },
+ "regenerator-transform": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.10.1.tgz",
+ "integrity": "sha512-PJepbvDbuK1xgIgnau7Y90cwaAmO/LCLMI2mPvaXq2heGMR3aWW5/BQvYrhJ8jgmQjXewXvBjzfqKcVOmhjZ6Q==",
+ "requires": {
+ "babel-runtime": "6.26.0",
+ "babel-types": "6.26.0",
+ "private": "0.1.8"
+ }
+ },
+ "regex-cache": {
+ "version": "0.4.4",
+ "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.4.tgz",
+ "integrity": "sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==",
+ "requires": {
+ "is-equal-shallow": "0.1.3"
+ }
+ },
+ "regexpu-core": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-2.0.0.tgz",
+ "integrity": "sha1-SdA4g3uNz4v6W5pCE5k45uoq4kA=",
+ "requires": {
+ "regenerate": "1.3.3",
+ "regjsgen": "0.2.0",
+ "regjsparser": "0.1.5"
+ }
+ },
+ "regjsgen": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz",
+ "integrity": "sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc="
+ },
+ "regjsparser": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz",
+ "integrity": "sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=",
+ "requires": {
+ "jsesc": "0.5.0"
+ },
+ "dependencies": {
+ "jsesc": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
+ "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0="
+ }
+ }
+ },
+ "remove-trailing-separator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
+ "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8="
+ },
+ "repeat-element": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.2.tgz",
+ "integrity": "sha1-7wiaF40Ug7quTZPrmLT55OEdmQo="
+ },
+ "repeat-string": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
+ "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc="
+ },
+ "repeating": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz",
+ "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=",
+ "requires": {
+ "is-finite": "1.0.2"
+ }
+ },
+ "replace-ext": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-0.0.1.tgz",
+ "integrity": "sha1-KbvZIHinOfC8zitO5B6DeVNSKSQ="
+ },
+ "require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I="
+ },
+ "require-from-string": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-1.2.1.tgz",
+ "integrity": "sha1-UpyczvJzgK3+yaL5ZbZJu+5jZBg="
+ },
+ "require-main-filename": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz",
+ "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE="
+ },
+ "require-nocache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/require-nocache/-/require-nocache-1.0.0.tgz",
+ "integrity": "sha1-pmXQtgoH6CSYdXkKTTUCGdPIX6M="
+ },
+ "require-uncached": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz",
+ "integrity": "sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=",
+ "requires": {
+ "caller-path": "0.1.0",
+ "resolve-from": "1.0.1"
+ }
+ },
+ "reselect": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz",
+ "integrity": "sha1-79qpjqdFEyTQkrKyFjpqHXqaIUc="
+ },
+ "resize-observer-polyfill": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.4.2.tgz",
+ "integrity": "sha1-o3GY5iCeiIrLFTKplo4G04tniOU="
+ },
+ "resolve": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.4.0.tgz",
+ "integrity": "sha512-aW7sVKPufyHqOmyyLzg/J+8606v5nevBgaliIlV7nUpVMsDnoBGV/cbSLNjZAg9q0Cfd/+easKVKQ8vOu8fn1Q==",
+ "requires": {
+ "path-parse": "1.0.5"
+ }
+ },
+ "resolve-dir": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-0.1.1.tgz",
+ "integrity": "sha1-shklmlYC+sXFxJatiUpujMQwJh4=",
+ "requires": {
+ "expand-tilde": "1.2.2",
+ "global-modules": "0.2.3"
+ }
+ },
+ "resolve-from": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz",
+ "integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY="
+ },
+ "resolve-pathname": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-2.2.0.tgz",
+ "integrity": "sha512-bAFz9ld18RzJfddgrO2e/0S2O81710++chRMUxHjXOYKF6jTAMrUNZrEZ1PvV0zlhfjidm08iRPdTLPno1FuRg=="
+ },
+ "resolve-url": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
+ "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo="
+ },
+ "restore-cursor": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz",
+ "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=",
+ "requires": {
+ "onetime": "2.0.1",
+ "signal-exit": "3.0.2"
+ }
+ },
+ "right-align": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz",
+ "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=",
+ "requires": {
+ "align-text": "0.1.4"
+ }
+ },
+ "rimraf": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz",
+ "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==",
+ "requires": {
+ "glob": "7.1.2"
+ }
+ },
+ "ripemd160": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.1.tgz",
+ "integrity": "sha1-D0WEKVxTo2KK9+bXmsohzlfRxuc=",
+ "requires": {
+ "hash-base": "2.0.2",
+ "inherits": "2.0.3"
+ }
+ },
+ "rocambole": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/rocambole/-/rocambole-0.7.0.tgz",
+ "integrity": "sha1-9seVBVF9xCtvuECEK4uVOw+WhYU=",
+ "requires": {
+ "esprima": "2.7.3"
+ }
+ },
+ "rocambole-indent": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/rocambole-indent/-/rocambole-indent-2.0.4.tgz",
+ "integrity": "sha1-oYokl3ygQAuGHapGMehh3LUtCFw=",
+ "requires": {
+ "debug": "2.6.9",
+ "mout": "0.11.1",
+ "rocambole-token": "1.2.1"
+ },
+ "dependencies": {
+ "mout": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/mout/-/mout-0.11.1.tgz",
+ "integrity": "sha1-ujYR318OWx/7/QEWa48C0fX6K5k="
+ }
+ }
+ },
+ "rocambole-linebreak": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/rocambole-linebreak/-/rocambole-linebreak-1.0.2.tgz",
+ "integrity": "sha1-A2IVFbQ7RyHJflocG8paA2Y2jy8=",
+ "requires": {
+ "debug": "2.6.9",
+ "rocambole-token": "1.2.1",
+ "semver": "4.3.6"
+ }
+ },
+ "rocambole-node": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/rocambole-node/-/rocambole-node-1.0.0.tgz",
+ "integrity": "sha1-21tJ3nQHsAgN1RSHLyjjk9D3/z8="
+ },
+ "rocambole-token": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/rocambole-token/-/rocambole-token-1.2.1.tgz",
+ "integrity": "sha1-x4XfdCjcPLJ614lwR71SOMwHDTU="
+ },
+ "rocambole-whitespace": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/rocambole-whitespace/-/rocambole-whitespace-1.0.0.tgz",
+ "integrity": "sha1-YzMJSSVrKZQfWbGQRZ+ZnGsdO/k=",
+ "requires": {
+ "debug": "2.6.9",
+ "repeat-string": "1.6.1",
+ "rocambole-token": "1.2.1"
+ }
+ },
+ "run-async": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz",
+ "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=",
+ "requires": {
+ "is-promise": "2.1.0"
+ }
+ },
+ "run-sequence": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/run-sequence/-/run-sequence-2.2.0.tgz",
+ "integrity": "sha512-xW5DmUwdvoyYQUMPKN8UW7TZSFs7AxtT59xo1m5y91jHbvwGlGgOmdV1Yw5P68fkjf3aHUZ4G1o1mZCtNe0qtw==",
+ "requires": {
+ "chalk": "1.1.3",
+ "gulp-util": "3.0.8"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "2.2.1",
+ "escape-string-regexp": "1.0.5",
+ "has-ansi": "2.0.0",
+ "strip-ansi": "3.0.1",
+ "supports-color": "2.0.0"
+ }
+ },
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ }
+ }
+ },
+ "rx-lite": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-4.0.8.tgz",
+ "integrity": "sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ="
+ },
+ "rx-lite-aggregates": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz",
+ "integrity": "sha1-dTuHqJoRyVRnxKwWJsTvxOBcZ74=",
+ "requires": {
+ "rx-lite": "4.0.8"
+ }
+ },
+ "safe-buffer": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
+ "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg=="
+ },
+ "sane": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/sane/-/sane-1.7.0.tgz",
+ "integrity": "sha1-s1ebzLRclM8gNVzIESSZDf00bjA=",
+ "requires": {
+ "anymatch": "1.3.2",
+ "exec-sh": "0.2.1",
+ "fb-watchman": "2.0.0",
+ "minimatch": "3.0.4",
+ "minimist": "1.2.0",
+ "walker": "1.0.7",
+ "watch": "0.10.0"
+ },
+ "dependencies": {
+ "minimist": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
+ "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
+ }
+ }
+ },
+ "sax": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
+ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
+ },
+ "schema-utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.3.0.tgz",
+ "integrity": "sha1-9YdyIs4+kx7a4DnxfrNxbnE3+M8=",
+ "requires": {
+ "ajv": "5.2.3"
+ }
+ },
+ "section-iterator": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/section-iterator/-/section-iterator-2.0.0.tgz",
+ "integrity": "sha1-v0RNev7rlK1Dw5rS+yYVFifMuio="
+ },
+ "select": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
+ "integrity": "sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0="
+ },
+ "semver": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.6.tgz",
+ "integrity": "sha1-MAvG4OhjdPe6YQaLWx7NV/xlMto="
+ },
+ "sequencify": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/sequencify/-/sequencify-0.0.7.tgz",
+ "integrity": "sha1-kM/xnQLgcCf9dn9erT57ldHnOAw="
+ },
+ "serializerr": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/serializerr/-/serializerr-1.0.3.tgz",
+ "integrity": "sha1-EtTFqhw/+49tHcXzlaqUVVacP5E=",
+ "requires": {
+ "protochain": "1.0.5"
+ }
+ },
+ "set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
+ },
+ "set-immediate-shim": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz",
+ "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E="
+ },
+ "setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU="
+ },
+ "sha.js": {
+ "version": "2.4.9",
+ "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.9.tgz",
+ "integrity": "sha512-G8zektVqbiPHrylgew9Zg1VRB1L/DtXNUVAM6q4QLy8NE3qtHlFXTf8VLL4k1Yl6c7NMjtZUTdXV+X44nFaT6A==",
+ "requires": {
+ "inherits": "2.0.3",
+ "safe-buffer": "5.1.1"
+ }
+ },
+ "shallow-equal": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.0.0.tgz",
+ "integrity": "sha1-UI0YOLPeWQq4dXsBGyXkMJAJRfc="
+ },
+ "shallowequal": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.0.2.tgz",
+ "integrity": "sha512-zlVXeVUKvo+HEv1e2KQF/csyeMKx2oHvatQ9l6XjCUj3agvC8XGf6R9HvIPDSmp8FNPvx7b5kaEJTRi7CqxtEw=="
+ },
+ "shebang-command": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+ "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=",
+ "requires": {
+ "shebang-regex": "1.0.0"
+ }
+ },
+ "shebang-regex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
+ "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM="
+ },
+ "sigmund": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz",
+ "integrity": "sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA="
+ },
+ "signal-exit": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
+ "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0="
+ },
+ "signalr": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/signalr/-/signalr-2.2.2.tgz",
+ "integrity": "sha1-ugeRXrXgjPvId2UEfsaXJpTQCOI=",
+ "requires": {
+ "jquery": "3.2.1"
+ }
+ },
+ "slash": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz",
+ "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU="
+ },
+ "slice-ansi": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-1.0.0.tgz",
+ "integrity": "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg==",
+ "requires": {
+ "is-fullwidth-code-point": "2.0.0"
+ }
+ },
+ "sort-keys": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz",
+ "integrity": "sha1-RBttTTRnmPG05J6JIK37oOVD+a0=",
+ "requires": {
+ "is-plain-obj": "1.1.0"
+ }
+ },
+ "source-list-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.0.tgz",
+ "integrity": "sha512-I2UmuJSRr/T8jisiROLU3A3ltr+swpniSmNPI4Ml3ZCX6tVnDsuZzK7F2hl5jTqbZBWCEKlj5HRQiPExXLgE8A=="
+ },
+ "source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
+ },
+ "source-map-resolve": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.3.1.tgz",
+ "integrity": "sha1-YQ9hIqRFuN1RU1oqcbeD38Ekh2E=",
+ "requires": {
+ "atob": "1.1.3",
+ "resolve-url": "0.2.1",
+ "source-map-url": "0.3.0",
+ "urix": "0.1.0"
+ }
+ },
+ "source-map-support": {
+ "version": "0.4.18",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz",
+ "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==",
+ "requires": {
+ "source-map": "0.5.7"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ }
+ }
+ },
+ "source-map-url": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.3.0.tgz",
+ "integrity": "sha1-fsrxO1e80J2opAxdJp2zN5nUqvk="
+ },
+ "sparkles": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-1.0.0.tgz",
+ "integrity": "sha1-Gsu/tZJDbRC76PeFt8xvgoFQEsM="
+ },
+ "spdx-correct": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-1.0.2.tgz",
+ "integrity": "sha1-SzBz2TP/UfORLwOsVRlJikFQ20A=",
+ "requires": {
+ "spdx-license-ids": "1.2.2"
+ }
+ },
+ "spdx-expression-parse": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-1.0.4.tgz",
+ "integrity": "sha1-m98vIOH0DtRH++JzJmGR/O1RYmw="
+ },
+ "spdx-license-ids": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-1.2.2.tgz",
+ "integrity": "sha1-yd96NCRZSt5r0RkA1ZZpbcBrrFc="
+ },
+ "specificity": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/specificity/-/specificity-0.3.2.tgz",
+ "integrity": "sha512-Nc/QN/A425Qog7j9aHmwOrlwX2e7pNI47ciwxwy4jOlvbbMHkNNJchit+FX+UjF3IAdiaaV5BKeWuDUnws6G1A=="
+ },
+ "split": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz",
+ "integrity": "sha1-zQ7qXmOiEd//frDwkcQTPi0N0o8=",
+ "requires": {
+ "through": "2.3.8"
+ }
+ },
+ "sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
+ },
+ "statuses": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz",
+ "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew=="
+ },
+ "stdin": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/stdin/-/stdin-0.0.1.tgz",
+ "integrity": "sha1-0wQZgarsPf28d6GzjWNy449ftx4="
+ },
+ "stream-browserify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz",
+ "integrity": "sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds=",
+ "requires": {
+ "inherits": "2.0.3",
+ "readable-stream": "2.3.3"
+ }
+ },
+ "stream-combiner": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz",
+ "integrity": "sha1-TV5DPBhSYd3mI8o/RMWGvPXErRQ=",
+ "requires": {
+ "duplexer": "0.1.1"
+ }
+ },
+ "stream-consume": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/stream-consume/-/stream-consume-0.1.0.tgz",
+ "integrity": "sha1-pB6tGm1ggc63n2WwYZAbbY89HQ8="
+ },
+ "stream-http": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.7.2.tgz",
+ "integrity": "sha512-c0yTD2rbQzXtSsFSVhtpvY/vS6u066PcXOX9kBB3mSO76RiUQzL340uJkGBWnlBg4/HZzqiUXtaVA7wcRcJgEw==",
+ "requires": {
+ "builtin-status-codes": "3.0.0",
+ "inherits": "2.0.3",
+ "readable-stream": "2.3.3",
+ "to-arraybuffer": "1.0.1",
+ "xtend": "4.0.1"
+ }
+ },
+ "streamqueue": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/streamqueue/-/streamqueue-1.1.1.tgz",
+ "integrity": "sha1-0612aGvpJLv5yix0qBSiGCR11tc=",
+ "requires": {
+ "isstream": "0.1.2",
+ "readable-stream": "1.0.34"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+ "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
+ },
+ "readable-stream": {
+ "version": "1.0.34",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
+ "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=",
+ "requires": {
+ "core-util-is": "1.0.2",
+ "inherits": "2.0.3",
+ "isarray": "0.0.1",
+ "string_decoder": "0.10.31"
+ }
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+ "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+ }
+ }
+ },
+ "strict-uri-encode": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
+ "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM="
+ },
+ "string-width": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
+ "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
+ "requires": {
+ "is-fullwidth-code-point": "2.0.0",
+ "strip-ansi": "4.0.0"
+ },
+ "dependencies": {
+ "ansi-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+ "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
+ },
+ "strip-ansi": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+ "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+ "requires": {
+ "ansi-regex": "3.0.0"
+ }
+ }
+ }
+ },
+ "string_decoder": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz",
+ "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==",
+ "requires": {
+ "safe-buffer": "5.1.1"
+ }
+ },
+ "strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+ "requires": {
+ "ansi-regex": "2.1.1"
+ }
+ },
+ "strip-bom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+ "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM="
+ },
+ "strip-bom-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom-stream/-/strip-bom-stream-2.0.0.tgz",
+ "integrity": "sha1-+H217yYT9paKpUWr/h7HKLaoKco=",
+ "requires": {
+ "first-chunk-stream": "2.0.0",
+ "strip-bom": "2.0.0"
+ },
+ "dependencies": {
+ "first-chunk-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-2.0.0.tgz",
+ "integrity": "sha1-G97NuOCDwGZLkZRVgVd6Q6nzHXA=",
+ "requires": {
+ "readable-stream": "2.3.3"
+ }
+ },
+ "strip-bom": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz",
+ "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=",
+ "requires": {
+ "is-utf8": "0.2.1"
+ }
+ }
+ }
+ },
+ "strip-bom-string": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
+ "integrity": "sha1-5SEekiQ2n7uB1jOi8ABE3IztrZI="
+ },
+ "strip-eof": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
+ "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8="
+ },
+ "strip-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz",
+ "integrity": "sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=",
+ "requires": {
+ "get-stdin": "4.0.1"
+ },
+ "dependencies": {
+ "get-stdin": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz",
+ "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4="
+ }
+ }
+ },
+ "strip-json-comments": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-0.1.3.tgz",
+ "integrity": "sha1-Fkxk43Coo8wAyeAbU55WmCPw7lQ="
+ },
+ "style-loader": {
+ "version": "0.19.0",
+ "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-0.19.0.tgz",
+ "integrity": "sha512-9mx9sC9nX1dgP96MZOODpGC6l1RzQBITI2D5WJhu+wnbrSYVKLGuy14XJSLVQih/0GFrPpjelt+s//VcZQ2Evw==",
+ "requires": {
+ "loader-utils": "1.1.0",
+ "schema-utils": "0.3.0"
+ }
+ },
+ "style-search": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz",
+ "integrity": "sha1-eVjHk+R+MuB9K1yv5cC/jhLneQI="
+ },
+ "stylelint": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-8.2.0.tgz",
+ "integrity": "sha512-57JWIz/1Uh9ehZMZyAqlFC0EDfQrMXCH8yqt8ZuJQQvV3LBKgAM/JYd+CWi1hC4eJtRODSPbIIBYKdGjkPZdMg==",
+ "requires": {
+ "autoprefixer": "7.1.5",
+ "balanced-match": "1.0.0",
+ "chalk": "2.2.0",
+ "cosmiconfig": "3.1.0",
+ "debug": "3.1.0",
+ "execall": "1.0.0",
+ "file-entry-cache": "2.0.0",
+ "get-stdin": "5.0.1",
+ "globby": "6.1.0",
+ "globjoin": "0.1.4",
+ "html-tags": "2.0.0",
+ "ignore": "3.3.6",
+ "imurmurhash": "0.1.4",
+ "known-css-properties": "0.4.1",
+ "lodash": "4.17.4",
+ "log-symbols": "2.1.0",
+ "mathml-tag-names": "2.0.1",
+ "meow": "3.7.0",
+ "micromatch": "2.3.11",
+ "normalize-selector": "0.2.0",
+ "pify": "3.0.0",
+ "postcss": "6.0.13",
+ "postcss-less": "1.1.1",
+ "postcss-media-query-parser": "0.2.3",
+ "postcss-reporter": "5.0.0",
+ "postcss-resolve-nested-selector": "0.1.1",
+ "postcss-safe-parser": "3.0.1",
+ "postcss-scss": "1.0.2",
+ "postcss-selector-parser": "2.2.3",
+ "postcss-value-parser": "3.3.0",
+ "resolve-from": "4.0.0",
+ "specificity": "0.3.2",
+ "string-width": "2.1.1",
+ "style-search": "0.1.0",
+ "sugarss": "1.0.0",
+ "svg-tags": "1.0.0",
+ "table": "4.0.2"
+ },
+ "dependencies": {
+ "cosmiconfig": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-3.1.0.tgz",
+ "integrity": "sha512-zedsBhLSbPBms+kE7AH4vHg6JsKDz6epSv2/+5XHs8ILHlgDciSJfSWf8sX9aQ52Jb7KI7VswUTsLpR/G0cr2Q==",
+ "requires": {
+ "is-directory": "0.3.1",
+ "js-yaml": "3.10.0",
+ "parse-json": "3.0.0",
+ "require-from-string": "2.0.1"
+ }
+ },
+ "debug": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+ "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "esprima": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz",
+ "integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw=="
+ },
+ "js-yaml": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.10.0.tgz",
+ "integrity": "sha512-O2v52ffjLa9VeM43J4XocZE//WT9N0IiwDa3KSHH7Tu8CtH+1qM8SIZvnsTh6v+4yFy5KUY3BHUVwjpfAWsjIA==",
+ "requires": {
+ "argparse": "1.0.9",
+ "esprima": "4.0.0"
+ }
+ },
+ "log-symbols": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.1.0.tgz",
+ "integrity": "sha512-zLeLrzMA1A2vRF1e/0Mo+LNINzi6jzBylHj5WqvQ/WK/5WCZt8si9SyN4p9llr/HRYvVR1AoXHRHl4WTHyQAzQ==",
+ "requires": {
+ "chalk": "2.2.0"
+ }
+ },
+ "parse-json": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-3.0.0.tgz",
+ "integrity": "sha1-+m9HsY4jgm6tMvJj50TQ4ehH+xM=",
+ "requires": {
+ "error-ex": "1.3.1"
+ }
+ },
+ "pify": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+ "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY="
+ },
+ "require-from-string": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.1.tgz",
+ "integrity": "sha1-xUUjPp19pmFunVmt+zn8n1iGdv8="
+ },
+ "resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
+ }
+ }
+ },
+ "stylelint-order": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/stylelint-order/-/stylelint-order-0.7.0.tgz",
+ "integrity": "sha1-zqtcviSqM/pjWQAkmVOV9u38mrc=",
+ "requires": {
+ "lodash": "4.17.4",
+ "postcss": "6.0.13",
+ "postcss-sorting": "3.0.2"
+ }
+ },
+ "sugarss": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-1.0.0.tgz",
+ "integrity": "sha1-ZeUbOVhDL7cNVFGmi7M+MtDPHvc=",
+ "requires": {
+ "postcss": "6.0.13"
+ }
+ },
+ "supports-color": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.5.0.tgz",
+ "integrity": "sha1-vnoN5ITexcXN34s9WRJQRJEvY1s=",
+ "requires": {
+ "has-flag": "2.0.0"
+ }
+ },
+ "svg-tags": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz",
+ "integrity": "sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q="
+ },
+ "svgo": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/svgo/-/svgo-0.7.2.tgz",
+ "integrity": "sha1-n1dyQTlSE1xv779Ar+ak+qiLS7U=",
+ "requires": {
+ "coa": "1.0.4",
+ "colors": "1.1.2",
+ "csso": "2.3.2",
+ "js-yaml": "3.7.0",
+ "mkdirp": "0.5.1",
+ "sax": "1.2.4",
+ "whet.extend": "0.9.9"
+ }
+ },
+ "symbol-observable": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.4.tgz",
+ "integrity": "sha1-Kb9hXUqnEhvdiYsi1LP5vE4qoD0="
+ },
+ "sync-exec": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/sync-exec/-/sync-exec-0.6.2.tgz",
+ "integrity": "sha1-cX0izFPwzh3vVZQ2LzqJouu5EQU="
+ },
+ "table": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/table/-/table-4.0.2.tgz",
+ "integrity": "sha512-UUkEAPdSGxtRpiV9ozJ5cMTtYiqz7Ni1OGqLXRCynrvzdtR1p+cfOWe2RJLwvUG8hNanaSRjecIqwOjqeatDsA==",
+ "requires": {
+ "ajv": "5.2.3",
+ "ajv-keywords": "2.1.0",
+ "chalk": "2.2.0",
+ "lodash": "4.17.4",
+ "slice-ansi": "1.0.0",
+ "string-width": "2.1.1"
+ }
+ },
+ "tapable": {
+ "version": "0.2.8",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.2.8.tgz",
+ "integrity": "sha1-mTcqXJmb8t8WCvwNdL7U9HlIzSI="
+ },
+ "tar": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz",
+ "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=",
+ "requires": {
+ "block-stream": "0.0.9",
+ "fstream": "1.0.11",
+ "inherits": "2.0.3"
+ }
+ },
+ "tar.gz": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/tar.gz/-/tar.gz-1.0.5.tgz",
+ "integrity": "sha1-4a2n5F7yJBtLHuWBI8j0C108G8Q=",
+ "requires": {
+ "bluebird": "2.11.0",
+ "commander": "2.11.0",
+ "fstream": "1.0.11",
+ "mout": "0.11.1",
+ "tar": "2.2.1"
+ },
+ "dependencies": {
+ "bluebird": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz",
+ "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE="
+ },
+ "mout": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/mout/-/mout-0.11.1.tgz",
+ "integrity": "sha1-ujYR318OWx/7/QEWa48C0fX6K5k="
+ }
+ }
+ },
+ "tether": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/tether/-/tether-1.4.0.tgz",
+ "integrity": "sha1-D5+hcfdb9YSF2BSelHmdeudNHBo="
+ },
+ "text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ="
+ },
+ "through": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
+ "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU="
+ },
+ "through2": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz",
+ "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=",
+ "requires": {
+ "readable-stream": "2.3.3",
+ "xtend": "4.0.1"
+ }
+ },
+ "tildify": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/tildify/-/tildify-1.2.0.tgz",
+ "integrity": "sha1-3OwD9V3Km3qj5bBPIYF+tW5jWIo=",
+ "requires": {
+ "os-homedir": "1.0.2"
+ }
+ },
+ "time-stamp": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.1.0.tgz",
+ "integrity": "sha1-dkpaEa9QVhkhsTPztE5hhofg9cM="
+ },
+ "timers-browserify": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.4.tgz",
+ "integrity": "sha512-uZYhyU3EX8O7HQP+J9fTVYwsq90Vr68xPEFo7yrVImIxYvHgukBEgOB/SgGoorWVTzGM/3Z+wUNnboA4M8jWrg==",
+ "requires": {
+ "setimmediate": "1.0.5"
+ }
+ },
+ "timers-ext": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.2.tgz",
+ "integrity": "sha1-YcxHp2wavTGV8UUn+XjViulMUgQ=",
+ "requires": {
+ "es5-ext": "0.10.35",
+ "next-tick": "1.0.0"
+ }
+ },
+ "tiny-emitter": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.0.2.tgz",
+ "integrity": "sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow=="
+ },
+ "tmp": {
+ "version": "0.0.33",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
+ "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
+ "requires": {
+ "os-tmpdir": "1.0.2"
+ }
+ },
+ "tmpl": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz",
+ "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE="
+ },
+ "to-arraybuffer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz",
+ "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M="
+ },
+ "to-camel-case": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/to-camel-case/-/to-camel-case-1.0.0.tgz",
+ "integrity": "sha1-GlYFSy+daWKYzmamCJcyK29CPkY=",
+ "requires": {
+ "to-space-case": "1.0.0"
+ }
+ },
+ "to-fast-properties": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz",
+ "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc="
+ },
+ "to-no-case": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/to-no-case/-/to-no-case-1.0.2.tgz",
+ "integrity": "sha1-xyKQcWTvaxeBMsjmmTAhLRtKoWo="
+ },
+ "to-space-case": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/to-space-case/-/to-space-case-1.0.0.tgz",
+ "integrity": "sha1-sFLar7Gysp3HcM6gFj5ewOvJ/Bc=",
+ "requires": {
+ "to-no-case": "1.0.2"
+ }
+ },
+ "traverse": {
+ "version": "0.6.6",
+ "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz",
+ "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc="
+ },
+ "trim-newlines": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz",
+ "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM="
+ },
+ "trim-right": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz",
+ "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM="
+ },
+ "tryit": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tryit/-/tryit-1.0.3.tgz",
+ "integrity": "sha1-OTvnMKlEb9Hq1tpZoBQwjzbCics="
+ },
+ "tty-browserify": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
+ "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY="
+ },
+ "type-check": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
+ "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=",
+ "requires": {
+ "prelude-ls": "1.1.2"
+ }
+ },
+ "type-is": {
+ "version": "1.6.15",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.15.tgz",
+ "integrity": "sha1-yrEPtJCeRByChC6v4a1kbIGARBA=",
+ "requires": {
+ "media-typer": "0.3.0",
+ "mime-types": "2.1.17"
+ }
+ },
+ "typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
+ },
+ "ua-parser-js": {
+ "version": "0.7.17",
+ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.17.tgz",
+ "integrity": "sha512-uRdSdu1oA1rncCQL7sCj8vSyZkgtL7faaw9Tc9rZ3mGgraQ7+Pdx7w5mnOSF3gw9ZNG6oc+KXfkon3bKuROm0g=="
+ },
+ "uglify-js": {
+ "version": "2.8.29",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz",
+ "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=",
+ "requires": {
+ "source-map": "0.5.7",
+ "uglify-to-browserify": "1.0.2",
+ "yargs": "3.10.0"
+ },
+ "dependencies": {
+ "camelcase": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz",
+ "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk="
+ },
+ "cliui": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz",
+ "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=",
+ "requires": {
+ "center-align": "0.1.3",
+ "right-align": "0.1.3",
+ "wordwrap": "0.0.2"
+ }
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ },
+ "wordwrap": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz",
+ "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8="
+ },
+ "yargs": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz",
+ "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=",
+ "requires": {
+ "camelcase": "1.2.1",
+ "cliui": "2.1.0",
+ "decamelize": "1.2.0",
+ "window-size": "0.1.0"
+ }
+ }
+ }
+ },
+ "uglify-to-browserify": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz",
+ "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=",
+ "optional": true
+ },
+ "uglifyjs-webpack-plugin": {
+ "version": "0.4.6",
+ "resolved": "https://registry.npmjs.org/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-0.4.6.tgz",
+ "integrity": "sha1-uVH0q7a9YX5m9j64kUmOORdj4wk=",
+ "requires": {
+ "source-map": "0.5.7",
+ "uglify-js": "2.8.29",
+ "webpack-sources": "1.0.1"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ }
+ }
+ },
+ "unc-path-regex": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz",
+ "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo="
+ },
+ "uniq": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",
+ "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8="
+ },
+ "uniqid": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/uniqid/-/uniqid-4.1.1.tgz",
+ "integrity": "sha1-iSIN32t1GuUrX3JISGNShZa7hME=",
+ "requires": {
+ "macaddress": "0.2.8"
+ }
+ },
+ "uniqs": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz",
+ "integrity": "sha1-/+3ks2slKQaW5uFl1KWe25mOawI="
+ },
+ "unique-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-1.0.0.tgz",
+ "integrity": "sha1-1ZpKdUJ0R9mqbJHnAmP40mpLEEs="
+ },
+ "unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw="
+ },
+ "urix": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz",
+ "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI="
+ },
+ "url": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
+ "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=",
+ "requires": {
+ "punycode": "1.3.2",
+ "querystring": "0.2.0"
+ },
+ "dependencies": {
+ "punycode": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
+ "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0="
+ }
+ }
+ },
+ "url-loader": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-0.6.2.tgz",
+ "integrity": "sha512-h3qf9TNn53BpuXTTcpC+UehiRrl0Cv45Yr/xWayApjw6G8Bg2dGke7rIwDQ39piciWCWrC+WiqLjOh3SUp9n0Q==",
+ "requires": {
+ "loader-utils": "1.1.0",
+ "mime": "1.4.1",
+ "schema-utils": "0.3.0"
+ }
+ },
+ "user-home": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz",
+ "integrity": "sha1-nHC/2Babwdy/SGBODwS4tJzenp8=",
+ "requires": {
+ "os-homedir": "1.0.2"
+ }
+ },
+ "util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
+ "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=",
+ "requires": {
+ "inherits": "2.0.1"
+ },
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz",
+ "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE="
+ }
+ }
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+ },
+ "v8flags": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-2.1.1.tgz",
+ "integrity": "sha1-qrGh+jDUX4jdMhFIh1rALAtV5bQ=",
+ "requires": {
+ "user-home": "1.1.1"
+ },
+ "dependencies": {
+ "user-home": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz",
+ "integrity": "sha1-K1viOjK2Onyd640PKNSFcko98ZA="
+ }
+ }
+ },
+ "validate-npm-package-license": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.1.tgz",
+ "integrity": "sha1-KAS6vnEq0zeUWaz74kdGqywwP7w=",
+ "requires": {
+ "spdx-correct": "1.0.2",
+ "spdx-expression-parse": "1.0.4"
+ }
+ },
+ "value-equal": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-0.4.0.tgz",
+ "integrity": "sha512-x+cYdNnaA3CxvMaTX0INdTCN8m8aF2uY9BvEqmxuYp8bL09cs/kWVQPVGcA35fMktdOsP69IgU7wFj/61dJHEw=="
+ },
+ "vendors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/vendors/-/vendors-1.0.1.tgz",
+ "integrity": "sha1-N61zyO5Bf7PVgOeFMSMH0nSEfyI="
+ },
+ "vinyl": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-0.5.3.tgz",
+ "integrity": "sha1-sEVbOPxeDPMNQyUTLkYZcMIJHN4=",
+ "requires": {
+ "clone": "1.0.2",
+ "clone-stats": "0.0.1",
+ "replace-ext": "0.0.1"
+ }
+ },
+ "vinyl-bufferstream": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/vinyl-bufferstream/-/vinyl-bufferstream-1.0.1.tgz",
+ "integrity": "sha1-BTeGn1gO/6TKRay0dXnkuf5jCBo=",
+ "requires": {
+ "bufferstreams": "1.0.1"
+ }
+ },
+ "vinyl-file": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/vinyl-file/-/vinyl-file-2.0.0.tgz",
+ "integrity": "sha1-p+v1/779obfRjRQPyweyI++2dRo=",
+ "requires": {
+ "graceful-fs": "4.1.11",
+ "pify": "2.3.0",
+ "pinkie-promise": "2.0.1",
+ "strip-bom": "2.0.0",
+ "strip-bom-stream": "2.0.0",
+ "vinyl": "1.2.0"
+ },
+ "dependencies": {
+ "strip-bom": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz",
+ "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=",
+ "requires": {
+ "is-utf8": "0.2.1"
+ }
+ },
+ "vinyl": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-1.2.0.tgz",
+ "integrity": "sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=",
+ "requires": {
+ "clone": "1.0.2",
+ "clone-stats": "0.0.1",
+ "replace-ext": "0.0.1"
+ }
+ }
+ }
+ },
+ "vinyl-fs": {
+ "version": "0.3.14",
+ "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-0.3.14.tgz",
+ "integrity": "sha1-mmhRzhysHBzqX+hsCTHWIMLPqeY=",
+ "requires": {
+ "defaults": "1.0.3",
+ "glob-stream": "3.1.18",
+ "glob-watcher": "0.0.6",
+ "graceful-fs": "3.0.11",
+ "mkdirp": "0.5.1",
+ "strip-bom": "1.0.0",
+ "through2": "0.6.5",
+ "vinyl": "0.4.6"
+ },
+ "dependencies": {
+ "clone": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-0.2.0.tgz",
+ "integrity": "sha1-xhJqkK1Pctv1rNskPMN3JP6T/B8="
+ },
+ "graceful-fs": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-3.0.11.tgz",
+ "integrity": "sha1-dhPHeKGv6mLyXGMKCG1/Osu92Bg=",
+ "requires": {
+ "natives": "1.1.0"
+ }
+ },
+ "isarray": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+ "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
+ },
+ "readable-stream": {
+ "version": "1.0.34",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
+ "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=",
+ "requires": {
+ "core-util-is": "1.0.2",
+ "inherits": "2.0.3",
+ "isarray": "0.0.1",
+ "string_decoder": "0.10.31"
+ }
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+ "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+ },
+ "strip-bom": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-1.0.0.tgz",
+ "integrity": "sha1-hbiGLzhEtabV7IRnqTWYFzo295Q=",
+ "requires": {
+ "first-chunk-stream": "1.0.0",
+ "is-utf8": "0.2.1"
+ }
+ },
+ "through2": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz",
+ "integrity": "sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=",
+ "requires": {
+ "readable-stream": "1.0.34",
+ "xtend": "4.0.1"
+ }
+ },
+ "vinyl": {
+ "version": "0.4.6",
+ "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-0.4.6.tgz",
+ "integrity": "sha1-LzVsh6VQolVGHza76ypbqL94SEc=",
+ "requires": {
+ "clone": "0.2.0",
+ "clone-stats": "0.0.1"
+ }
+ }
+ }
+ },
+ "vinyl-map": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/vinyl-map/-/vinyl-map-1.0.2.tgz",
+ "integrity": "sha1-qLKWAl+XP6fK1igXlnpI8dF2v3w=",
+ "requires": {
+ "bl": "1.2.1",
+ "new-from": "0.0.3",
+ "through2": "0.4.2"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+ "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
+ },
+ "object-keys": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz",
+ "integrity": "sha1-KKaq50KN0sOpLz2V8hM13SBOAzY="
+ },
+ "readable-stream": {
+ "version": "1.0.34",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
+ "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=",
+ "requires": {
+ "core-util-is": "1.0.2",
+ "inherits": "2.0.3",
+ "isarray": "0.0.1",
+ "string_decoder": "0.10.31"
+ }
+ },
+ "string_decoder": {
+ "version": "0.10.31",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+ "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+ },
+ "through2": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-0.4.2.tgz",
+ "integrity": "sha1-2/WGYDEVHsg1K7bE22SiKSqEC5s=",
+ "requires": {
+ "readable-stream": "1.0.34",
+ "xtend": "2.1.2"
+ }
+ },
+ "xtend": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz",
+ "integrity": "sha1-bv7MKk2tjmlixJAbM3znuoe10os=",
+ "requires": {
+ "object-keys": "0.4.0"
+ }
+ }
+ }
+ },
+ "vinyl-sourcemaps-apply": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz",
+ "integrity": "sha1-q2VJ1h0XLCsbh75cUI0jnI74dwU=",
+ "requires": {
+ "source-map": "0.5.7"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ }
+ }
+ },
+ "vm-browserify": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz",
+ "integrity": "sha1-XX6kW7755Kb/ZflUOOCofDV9WnM=",
+ "requires": {
+ "indexof": "0.0.1"
+ }
+ },
+ "walker": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz",
+ "integrity": "sha1-L3+bj9ENZ3JisYqITijRlhjgKPs=",
+ "requires": {
+ "makeerror": "1.0.11"
+ }
+ },
+ "warning": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
+ "integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
+ "requires": {
+ "loose-envify": "1.3.1"
+ }
+ },
+ "watch": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/watch/-/watch-0.10.0.tgz",
+ "integrity": "sha1-d3mLLaD5kQ1ZXxrOWwwiWFIfIdw="
+ },
+ "watchpack": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.4.0.tgz",
+ "integrity": "sha1-ShRyvLuVK9Cpu0A2gB+VTfs5+qw=",
+ "requires": {
+ "async": "2.5.0",
+ "chokidar": "1.7.0",
+ "graceful-fs": "4.1.11"
+ }
+ },
+ "webpack": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/webpack/-/webpack-3.6.0.tgz",
+ "integrity": "sha512-OsHT3D0W0KmPPh60tC7asNnOmST6bKTiR90UyEdT9QYoaJ4OYN4Gg7WK1k3VxHK07ZoiYWPsKvlS/gAjwL/vRA==",
+ "requires": {
+ "acorn": "5.1.2",
+ "acorn-dynamic-import": "2.0.2",
+ "ajv": "5.2.3",
+ "ajv-keywords": "2.1.0",
+ "async": "2.5.0",
+ "enhanced-resolve": "3.4.1",
+ "escope": "3.6.0",
+ "interpret": "1.0.4",
+ "json-loader": "0.5.7",
+ "json5": "0.5.1",
+ "loader-runner": "2.3.0",
+ "loader-utils": "1.1.0",
+ "memory-fs": "0.4.1",
+ "mkdirp": "0.5.1",
+ "node-libs-browser": "2.0.0",
+ "source-map": "0.5.7",
+ "supports-color": "4.5.0",
+ "tapable": "0.2.8",
+ "uglifyjs-webpack-plugin": "0.4.6",
+ "watchpack": "1.4.0",
+ "webpack-sources": "1.0.1",
+ "yargs": "8.0.2"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ }
+ }
+ },
+ "webpack-sources": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.0.1.tgz",
+ "integrity": "sha512-05tMxipUCwHqYaVS8xc7sYPTly8PzXayRCB4dTxLhWTqlKUiwH6ezmEe0OSreL1c30LAuA3Zqmc+uEBUGFJDjw==",
+ "requires": {
+ "source-list-map": "2.0.0",
+ "source-map": "0.5.7"
+ },
+ "dependencies": {
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+ }
+ }
+ },
+ "webpack-stream": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/webpack-stream/-/webpack-stream-4.0.0.tgz",
+ "integrity": "sha1-82c92QfW2bHqe/UfzR24W1/Z4PI=",
+ "requires": {
+ "gulp-util": "3.0.8",
+ "lodash.clone": "4.5.0",
+ "lodash.some": "4.6.0",
+ "memory-fs": "0.4.1",
+ "through": "2.3.8",
+ "vinyl": "2.1.0",
+ "webpack": "3.6.0"
+ },
+ "dependencies": {
+ "clone": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.1.tgz",
+ "integrity": "sha1-0hfR6WERjjrJpLi7oyhVU79kfNs="
+ },
+ "clone-stats": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-1.0.0.tgz",
+ "integrity": "sha1-s3gt/4u1R04Yuba/D9/ngvh3doA="
+ },
+ "replace-ext": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.0.tgz",
+ "integrity": "sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs="
+ },
+ "vinyl": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.1.0.tgz",
+ "integrity": "sha1-Ah+cLPlR1rk5lDyJ617lrdT9kkw=",
+ "requires": {
+ "clone": "2.1.1",
+ "clone-buffer": "1.0.0",
+ "clone-stats": "1.0.0",
+ "cloneable-readable": "1.0.0",
+ "remove-trailing-separator": "1.1.0",
+ "replace-ext": "1.0.0"
+ }
+ }
+ }
+ },
+ "websocket-driver": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.0.tgz",
+ "integrity": "sha1-DK+dLXVdk67gSdS90NP+LMoqJOs=",
+ "requires": {
+ "http-parser-js": "0.4.9",
+ "websocket-extensions": "0.1.2"
+ }
+ },
+ "websocket-extensions": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.2.tgz",
+ "integrity": "sha1-Dhh4HeYpoYMIzhSBZQ9n/6JpOl0="
+ },
+ "whatwg-fetch": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz",
+ "integrity": "sha1-nITsLc9oGH/wC8ZOEnS0QhduHIQ="
+ },
+ "whet.extend": {
+ "version": "0.9.9",
+ "resolved": "https://registry.npmjs.org/whet.extend/-/whet.extend-0.9.9.tgz",
+ "integrity": "sha1-+HfVv2SMl+WqVC+twW1qJZucEaE="
+ },
+ "which": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.0.tgz",
+ "integrity": "sha512-xcJpopdamTuY5duC/KnTTNBraPK54YwpenP4lzxU8H91GudWpFv38u0CKjclE1Wi2EH2EDz5LRcHcKbCIzqGyg==",
+ "requires": {
+ "isexe": "2.0.0"
+ }
+ },
+ "which-module": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
+ "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho="
+ },
+ "window-size": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz",
+ "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0="
+ },
+ "wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus="
+ },
+ "worker-farm": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/worker-farm/-/worker-farm-1.5.0.tgz",
+ "integrity": "sha512-DHRiUggxtbruaTwnLDm2/BRDKZIoOYvrgYUj5Bam4fU6Gtvc0FaEyoswFPBjMXAweGW2H4BDNIpy//1yXXuaqQ==",
+ "requires": {
+ "errno": "0.1.4",
+ "xtend": "4.0.1"
+ }
+ },
+ "wrap-ansi": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
+ "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=",
+ "requires": {
+ "string-width": "1.0.2",
+ "strip-ansi": "3.0.1"
+ },
+ "dependencies": {
+ "is-fullwidth-code-point": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+ "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
+ "requires": {
+ "number-is-nan": "1.0.1"
+ }
+ },
+ "string-width": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+ "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
+ "requires": {
+ "code-point-at": "1.1.0",
+ "is-fullwidth-code-point": "1.0.0",
+ "strip-ansi": "3.0.1"
+ }
+ }
+ }
+ },
+ "wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+ },
+ "write": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz",
+ "integrity": "sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c=",
+ "requires": {
+ "mkdirp": "0.5.1"
+ }
+ },
+ "xtend": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",
+ "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68="
+ },
+ "y18n": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz",
+ "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE="
+ },
+ "yallist": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
+ "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI="
+ },
+ "yargs": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-8.0.2.tgz",
+ "integrity": "sha1-YpmpBVsc78lp/355wdkY3Osiw2A=",
+ "requires": {
+ "camelcase": "4.1.0",
+ "cliui": "3.2.0",
+ "decamelize": "1.2.0",
+ "get-caller-file": "1.0.2",
+ "os-locale": "2.1.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.1.1",
+ "which-module": "2.0.0",
+ "y18n": "3.2.1",
+ "yargs-parser": "7.0.0"
+ }
+ },
+ "yargs-parser": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-7.0.0.tgz",
+ "integrity": "sha1-jQrELxbqVd69MyyvTEA4s+P139k=",
+ "requires": {
+ "camelcase": "4.1.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index 12bc565c3..e682076ef 100644
--- a/package.json
+++ b/package.json
@@ -1,46 +1,117 @@
{
- "name": "Lidarr",
+ "name": "lidarr",
"version": "1.0.0",
"description": "Lidarr",
- "main": "main.js",
"scripts": {
"build": "gulp build",
- "start": "gulp watch"
+ "start": "gulp watch",
+ "eslint": "esprint check",
+ "eslint-fix": "eslint start --fix",
+ "stylelint": "stylelint frontend/**/*.css --config frontend/.stylelintrc"
},
"repository": {
"type": "git",
- "url": "git://github.com/lidarr/Lidarr.git"
+ "url": "git://github.com/Lidarr/Lidarr.git"
},
- "author": "",
+ "author": "Team Lidarr",
"license": "GPL-3.0",
- "gitHead": "9ff7aa1bf7fe38c4c5bdb92f56c8ad556916ed67",
"readmeFilename": "readme.md",
"dependencies": {
- "autoprefixer-core": "5.2.1",
- "del": "1.2.0",
- "gulp": "3.9.0",
- "gulp-cached": "1.1.0",
- "gulp-concat": "2.6.0",
+ "autoprefixer": "7.1.5",
+ "babel-core": "6.26.0",
+ "babel-eslint": "8.0.1",
+ "babel-loader": "7.1.2",
+ "babel-plugin-transform-class-properties": "6.24.1",
+ "babel-preset-decorators-legacy": "1.0.0",
+ "babel-preset-es2015": "6.24.1",
+ "babel-preset-react": "6.24.1",
+ "babel-preset-stage-2": "6.24.1",
+ "classnames": "2.2.5",
+ "clipboard": "1.7.1",
+ "create-react-class": "^15.6.2",
+ "css-loader": "0.28.7",
+ "del": "3.0.0",
+ "element-class": "0.2.2",
+ "esformatter": "0.10.0",
+ "eslint": "4.8.0",
+ "eslint-loader": "1.9.0",
+ "eslint-plugin-filenames": "1.2.0",
+ "eslint-plugin-react": "7.4.0",
+ "esprint": "0.4.0",
+ "extract-text-webpack-plugin": "3.0.1",
+ "file-loader": "1.1.5",
+ "filesize": "3.5.10",
+ "gulp": "3.9.1",
+ "gulp-cached": "1.1.1",
+ "gulp-clean-css": "3.9.0",
+ "gulp-concat": "2.6.1",
"gulp-declare": "0.3.0",
- "gulp-handlebars": "3.0.1",
- "gulp-jshint": "1.11.2",
- "gulp-less": "3.0.3",
- "gulp-livereload": "3.8.0",
- "gulp-postcss": "6.0.0",
- "gulp-print": "1.1.0",
- "gulp-replace": "0.5.3",
- "gulp-run": "1.6.8",
- "gulp-sourcemaps": "1.5.2",
+ "gulp-livereload": "3.8.1",
+ "gulp-postcss": "7.0.0",
+ "gulp-print": "2.0.1",
+ "gulp-sourcemaps": "2.6.1",
"gulp-stripbom": "1.0.4",
- "gulp-webpack": "1.5.0",
- "gulp-wrap": "0.11.0",
- "handlebars": "3.0.3",
- "jshint-loader": "0.8.3",
- "jshint-stylish": "2.0.1",
- "run-sequence": "1.1.1",
- "streamqueue": "1.1.0",
- "tar.gz": "0.1.1",
- "webpack": "1.12.0",
- "webpack-stream": "2.1.0"
- }
+ "gulp-util": "3.0.8",
+ "gulp-watch": "4.3.11",
+ "gulp-wrap": "0.13.0",
+ "history": "4.7.2",
+ "jdu": "1.0.0",
+ "jquery": "3.2.1",
+ "loader-utils": "^1.1.0",
+ "lodash": "4.17.4",
+ "mobile-detect": "1.3.7",
+ "moment": "2.18.1",
+ "mousetrap": "1.6.1",
+ "normalize.css": "7.0.0",
+ "postcss-loader": "2.0.6",
+ "postcss-mixins": "6.1.1",
+ "postcss-nested": "2.1.2",
+ "postcss-simple-vars": "4.1.0",
+ "prop-types": "15.6.0",
+ "qs": "6.5.1",
+ "query-string": "5.0.0",
+ "raven-for-redux": "1.0.0",
+ "raven-js": "3.17.0",
+ "react": "15.6.0",
+ "react-addons-shallow-compare": "15.6.2",
+ "react-async-script": "0.9.1",
+ "react-autosuggest": "9.3.2",
+ "react-custom-scrollbars": "4.1.2",
+ "react-dnd": "2.5.4",
+ "react-dnd-html5-backend": "2.5.4",
+ "react-document-title": "2.0.3",
+ "react-dom": "15.6.0",
+ "react-google-recaptcha": "0.9.7",
+ "react-lazyload": "2.2.7",
+ "react-measure": "1.4.7",
+ "react-portal": "3.1.0",
+ "react-redux": "5.0.6",
+ "react-router-dom": "4.2.2",
+ "react-router-redux": "5.0.0-alpha.6",
+ "react-slider": "0.9.0",
+ "react-tabs": "2.1.0",
+ "react-tag-autocomplete": "5.4.1",
+ "react-tether": "0.5.7",
+ "react-text-truncate": "0.12.0",
+ "react-truncate": "2.2.2",
+ "react-virtualized": "9.10.1",
+ "redux": "3.7.2",
+ "redux-actions": "2.2.1",
+ "redux-batched-actions": "0.2.0",
+ "redux-localstorage": "0.4.1",
+ "redux-thunk": "2.2.0",
+ "require-nocache": "1.0.0",
+ "reselect": "3.0.1",
+ "run-sequence": "2.2.0",
+ "signalr": "2.2.2",
+ "streamqueue": "1.1.1",
+ "style-loader": "0.19.0",
+ "stylelint": "8.2.0",
+ "stylelint-order": "0.7.0",
+ "tar.gz": "1.0.5",
+ "url-loader": "0.6.2",
+ "webpack": "3.6.0",
+ "webpack-stream": "^4.0.0"
+ },
+ "main": "index.js"
}
diff --git a/setup/build.bat b/setup/build.bat
index 1821e5844..faef79cb4 100644
--- a/setup/build.bat
+++ b/setup/build.bat
@@ -1,3 +1,3 @@
-#SET BUILD_NUMBER=1
-#SET branch=develop
-inno\ISCC.exe nzbdrone.iss
\ No newline at end of file
+REM SET BUILD_NUMBER=1
+REM SET branch=develop
+inno\ISCC.exe lidarr.iss
\ No newline at end of file
diff --git a/setup/lidarr.iss b/setup/lidarr.iss
new file mode 100644
index 000000000..09c44e103
--- /dev/null
+++ b/setup/lidarr.iss
@@ -0,0 +1,65 @@
+; Script generated by the Inno Setup Script Wizard.
+; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
+
+#define AppName "Lidarr"
+#define AppPublisher "Team Lidarr"
+#define AppURL "https://lidarr.audio/"
+#define ForumsURL "https://forums.lidarr.audio/"
+#define AppExeName "Lidarr.exe"
+#define BuildNumber "0.3"
+#define BuildNumber GetEnv('BUILD_NUMBER')
+#define BranchName GetEnv('branch')
+
+[Setup]
+; NOTE: The value of AppId uniquely identifies this application.
+; Do not use the same AppId value in installers for other applications.
+; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
+AppId={{56C1065D-3523-4025-B76D-6F73F67F7F93}
+AppName={#AppName}
+AppVersion=2.0
+AppPublisher={#AppPublisher}
+AppPublisherURL={#AppURL}
+AppSupportURL={#ForumsURL}
+AppUpdatesURL={#AppURL}
+DefaultDirName={commonappdata}\Lidarr\bin
+DisableDirPage=yes
+DefaultGroupName={#AppName}
+DisableProgramGroupPage=yes
+OutputBaseFilename=Lidarr.{#BranchName}.{#BuildNumber}.windows
+SolidCompression=yes
+AppCopyright=Creative Commons 3.0 License
+AllowUNCPath=False
+UninstallDisplayIcon={app}\Lidarr.exe
+DisableReadyPage=True
+CompressionThreads=2
+Compression=lzma2/normal
+AppContact={#ForumsURL}
+VersionInfoVersion={#BuildNumber}
+
+[Languages]
+Name: "english"; MessagesFile: "compiler:Default.isl"
+
+[Tasks]
+Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"
+Name: "windowsService"; Description: "Install Windows Service (Starts when the computer starts)"; GroupDescription: "Start automatically"; Flags: exclusive
+Name: "startupShortcut"; Description: "Create shortcut in Startup folder (Starts when you log into Windows)"; GroupDescription: "Start automatically"; Flags: exclusive unchecked
+Name: "none"; Description: "Do not start automatically"; GroupDescription: "Start automatically"; Flags: exclusive unchecked
+
+[Files]
+Source: "..\_output\Lidarr.exe"; DestDir: "{app}"; Flags: ignoreversion
+Source: "..\_output\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
+; NOTE: Don't use "Flags: ignoreversion" on any shared system files
+
+[Icons]
+Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon"
+Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon"
+Name: "{userstartup}\{#AppName}"; Filename: "{app}\Lidarr.exe"; WorkingDir: "{app}"; Tasks: startupShortcut
+
+[Run]
+Filename: "{app}\Lidarr.Console.exe"; Parameters: "/u"; Flags: runhidden waituntilterminated;
+Filename: "{app}\Lidarr.Console.exe"; Parameters: "/i"; Flags: runhidden waituntilterminated; Tasks: windowsService
+Filename: "{app}\Lidarr.exe"; Description: "Open Lidarr"; Flags: postinstall skipifsilent nowait; Tasks: windowsService;
+Filename: "{app}\Lidarr.exe"; Description: "Start Lidarr"; Flags: postinstall skipifsilent nowait; Tasks: startupShortcut none;
+
+[UninstallRun]
+Filename: "{app}\lidarr.console.exe"; Parameters: "/u"; Flags: waituntilterminated skipifdoesntexist
diff --git a/setup/nzbdrone.iss b/setup/nzbdrone.iss
deleted file mode 100644
index 011781078..000000000
--- a/setup/nzbdrone.iss
+++ /dev/null
@@ -1,60 +0,0 @@
-; Script generated by the Inno Setup Script Wizard.
-; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
-
-#define AppName "Lidarr"
-#define AppPublisher "Team Lidarr"
-#define AppURL "https://lidarr.audio/"
-#define ForumsURL "https://forums.lidarr.audio/"
-#define AppExeName "Lidarr.exe"
-#define BuildNumber "2.0"
-#define BuildNumber GetEnv('BUILD_NUMBER')
-#define BranchName GetEnv('branch')
-
-[Setup]
-; NOTE: The value of AppId uniquely identifies this application.
-; Do not use the same AppId value in installers for other applications.
-; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
-AppId={{56C1065D-3523-4025-B76D-6F73F67F7F71}
-AppName={#AppName}
-AppVersion=2.0
-AppPublisher={#AppPublisher}
-AppPublisherURL={#AppURL}
-AppSupportURL={#ForumsURL}
-AppUpdatesURL={#AppURL}
-DefaultDirName={commonappdata}\Lidarr\bin
-DisableDirPage=yes
-DefaultGroupName={#AppName}
-DisableProgramGroupPage=yes
-OutputBaseFilename=Lidarr.{#BranchName}.{#BuildNumber}
-SolidCompression=yes
-AppCopyright=Creative Commons 3.0 License
-AllowUNCPath=False
-UninstallDisplayIcon={app}\Lidarr.exe
-DisableReadyPage=True
-CompressionThreads=2
-Compression=lzma2/normal
-AppContact={#ForumsURL}
-VersionInfoVersion={#BuildNumber}
-
-[Languages]
-Name: "english"; MessagesFile: "compiler:Default.isl"
-
-[Tasks]
-;Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
-Name: "windowsService"; Description: "Install as a Windows Service"
-
-[Files]
-Source: "..\_output\Lidarr.exe"; DestDir: "{app}"; Flags: ignoreversion
-Source: "..\_output\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
-; NOTE: Don't use "Flags: ignoreversion" on any shared system files
-
-[Icons]
-Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon"
-Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Parameters: "/icon"
-
-[Run]
-Filename: "{app}\lidarr.console.exe"; Parameters: "/u"; Flags: waituntilterminated;
-Filename: "{app}\lidarr.console.exe"; Parameters: "/i"; Flags: waituntilterminated; Tasks: windowsService
-
-[UninstallRun]
-Filename: "{app}\lidarr.console.exe"; Parameters: "/u"; Flags: waituntilterminated skipifdoesntexist
diff --git a/src/Lidarr.Api.V3/AlbumStudio/AlbumStudioArtistResource.cs b/src/Lidarr.Api.V3/AlbumStudio/AlbumStudioArtistResource.cs
new file mode 100644
index 000000000..e1c43ebc5
--- /dev/null
+++ b/src/Lidarr.Api.V3/AlbumStudio/AlbumStudioArtistResource.cs
@@ -0,0 +1,12 @@
+using System.Collections.Generic;
+using Lidarr.Api.V3.Albums;
+
+namespace Lidarr.Api.V3.AlbumStudio
+{
+ public class AlbumStudioArtistResource
+ {
+ public int Id { get; set; }
+ public bool? Monitored { get; set; }
+ public List Albums { get; set; }
+ }
+}
diff --git a/src/Lidarr.Api.V3/AlbumStudio/AlbumStudioModule.cs b/src/Lidarr.Api.V3/AlbumStudio/AlbumStudioModule.cs
new file mode 100644
index 000000000..71dc76920
--- /dev/null
+++ b/src/Lidarr.Api.V3/AlbumStudio/AlbumStudioModule.cs
@@ -0,0 +1,55 @@
+using System.Linq;
+using Nancy;
+using NzbDrone.Core.Music;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3.AlbumStudio
+{
+ public class AlbumStudioModule : SonarrV3Module
+ {
+ private readonly IArtistService _artistService;
+ private readonly IAlbumMonitoredService _episodeMonitoredService;
+
+ public AlbumStudioModule(IArtistService artistService, IAlbumMonitoredService episodeMonitoredService)
+ : base("/albumstudio")
+ {
+ _artistService = artistService;
+ _episodeMonitoredService = episodeMonitoredService;
+ Post["/"] = artist => UpdateAll();
+ }
+
+ private Response UpdateAll()
+ {
+ //Read from request
+ var request = Request.Body.FromJson();
+ var artistToUpdate = _artistService.GetArtists(request.Artist.Select(s => s.Id));
+
+ foreach (var s in request.Artist)
+ {
+ var artist = artistToUpdate.Single(c => c.Id == s.Id);
+
+ if (s.Monitored.HasValue)
+ {
+ artist.Monitored = s.Monitored.Value;
+ }
+
+ if (s.Albums != null && s.Albums.Any())
+ {
+ foreach (var artistAlbum in artist.Albums)
+ {
+ var album = s.Albums.FirstOrDefault(c => c.Id == artistAlbum.Id);
+
+ if (album != null)
+ {
+ artistAlbum.Monitored = album.Monitored;
+ }
+ }
+ }
+
+ _episodeMonitoredService.SetAlbumMonitoredStatus(artist, request.MonitoringOptions);
+ }
+
+ return "ok".AsResponse(HttpStatusCode.Accepted);
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/AlbumStudio/AlbumStudioResource.cs b/src/Lidarr.Api.V3/AlbumStudio/AlbumStudioResource.cs
new file mode 100644
index 000000000..b03f0629e
--- /dev/null
+++ b/src/Lidarr.Api.V3/AlbumStudio/AlbumStudioResource.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+using NzbDrone.Core.Music;
+
+namespace Lidarr.Api.V3.AlbumStudio
+{
+ public class AlbumStudioResource
+ {
+ public List Artist { get; set; }
+ public MonitoringOptions MonitoringOptions { get; set; }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Albums/AlbumModule.cs b/src/Lidarr.Api.V3/Albums/AlbumModule.cs
new file mode 100644
index 000000000..6b5e47d31
--- /dev/null
+++ b/src/Lidarr.Api.V3/Albums/AlbumModule.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Nancy;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Music;
+using NzbDrone.SignalR;
+using Lidarr.Http.Extensions;
+using Lidarr.Http.REST;
+using NzbDrone.Core.ArtistStats;
+
+namespace Lidarr.Api.V3.Albums
+{
+ public class AlbumModule : AlbumModuleWithSignalR
+ {
+ public AlbumModule(IArtistService artistService,
+ IAlbumService albumService,
+ IArtistStatisticsService artistStatisticsService,
+ IUpgradableSpecification upgradableSpecification,
+ IBroadcastSignalRMessage signalRBroadcaster)
+ : base(albumService, artistStatisticsService, artistService, upgradableSpecification, signalRBroadcaster)
+ {
+ GetResourceAll = GetAlbums;
+ Put[@"/(?[\d]{1,10})"] = x => SetAlbumMonitored(x.Id);
+ Put["/monitor"] = x => SetAlbumsMonitored();
+ }
+
+ private List GetAlbums()
+ {
+ var artistIdQuery = Request.Query.ArtistId;
+ var albumIdsQuery = Request.Query.AlbumIds;
+
+ if (!Request.Query.ArtistId.HasValue && !albumIdsQuery.HasValue)
+ {
+ return MapToResource(_albumService.GetAllAlbums(), false);
+ }
+
+ if (artistIdQuery.HasValue)
+ {
+ int artistId = Convert.ToInt32(artistIdQuery.Value);
+
+ return MapToResource(_albumService.GetAlbumsByArtist(artistId), false);
+ }
+
+ string albumIdsValue = albumIdsQuery.Value.ToString();
+
+ var albumIds = albumIdsValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(e => Convert.ToInt32(e))
+ .ToList();
+
+ return MapToResource(_albumService.GetAlbums(albumIds), false);
+ }
+
+ private Response SetAlbumMonitored(int id)
+ {
+ var resource = Request.Body.FromJson();
+ _albumService.SetAlbumMonitored(id, resource.Monitored);
+
+ return MapToResource(_albumService.GetAlbum(id), false).AsResponse(HttpStatusCode.Accepted);
+ }
+
+ private Response SetAlbumsMonitored()
+ {
+ var resource = Request.Body.FromJson();
+
+ _albumService.SetMonitored(resource.AlbumIds, resource.Monitored);
+
+ return MapToResource(_albumService.GetAlbums(resource.AlbumIds), false).AsResponse(HttpStatusCode.Accepted);
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/Albums/AlbumModuleWithSignalR.cs b/src/Lidarr.Api.V3/Albums/AlbumModuleWithSignalR.cs
similarity index 80%
rename from src/NzbDrone.Api/Albums/AlbumModuleWithSignalR.cs
rename to src/Lidarr.Api.V3/Albums/AlbumModuleWithSignalR.cs
index 59fe8fc2d..17c64b70c 100644
--- a/src/NzbDrone.Api/Albums/AlbumModuleWithSignalR.cs
+++ b/src/Lidarr.Api.V3/Albums/AlbumModuleWithSignalR.cs
@@ -1,32 +1,28 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using NzbDrone.Common.Extensions;
-using NzbDrone.Api.TrackFiles;
-using NzbDrone.Api.Music;
-using NzbDrone.Core.Datastore.Events;
+using Lidarr.Api.V3.Artist;
using NzbDrone.Core.DecisionEngine;
-using NzbDrone.Core.Download;
-using NzbDrone.Core.MediaFiles.Events;
-using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Music;
using NzbDrone.Core.ArtistStats;
using NzbDrone.SignalR;
+using Lidarr.Http;
-namespace NzbDrone.Api.Albums
+namespace Lidarr.Api.V3.Albums
{
- public abstract class AlbumModuleWithSignalR : NzbDroneRestModuleWithSignalR
+ public abstract class AlbumModuleWithSignalR : LidarrRestModuleWithSignalR
{
protected readonly IAlbumService _albumService;
protected readonly IArtistStatisticsService _artistStatisticsService;
protected readonly IArtistService _artistService;
- protected readonly IQualityUpgradableSpecification _qualityUpgradableSpecification;
+ protected readonly IUpgradableSpecification _qualityUpgradableSpecification;
protected AlbumModuleWithSignalR(IAlbumService albumService,
IArtistStatisticsService artistStatisticsService,
IArtistService artistService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification qualityUpgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster)
: base(signalRBroadcaster)
{
@@ -41,7 +37,7 @@ namespace NzbDrone.Api.Albums
protected AlbumModuleWithSignalR(IAlbumService albumService,
IArtistStatisticsService artistStatisticsService,
IArtistService artistService,
- IQualityUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification qualityUpgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster,
string resource)
: base(signalRBroadcaster, resource)
@@ -86,7 +82,7 @@ namespace NzbDrone.Api.Albums
if (includeArtist)
{
- var artistDict = new Dictionary();
+ var artistDict = new Dictionary();
for (var i = 0; i < albums.Count; i++)
{
var album = albums[i];
@@ -94,7 +90,7 @@ namespace NzbDrone.Api.Albums
var artist = album.Artist ?? artistDict.GetValueOrDefault(albums[i].ArtistId) ?? _artistService.GetArtist(albums[i].ArtistId);
artistDict[artist.Id] = artist;
-
+
if (includeArtist)
{
resource.Artist = artist.ToResource();
@@ -102,12 +98,9 @@ namespace NzbDrone.Api.Albums
}
}
- for (var i = 0; i < albums.Count; i++)
- {
- var resource = result[i];
- FetchAndLinkAlbumStatistics(resource);
- }
-
+ var artistList = albums.DistinctBy(a => a.ArtistId).ToList();
+ var artistStats = _artistStatisticsService.ArtistStatistics();
+ LinkArtistStatistics(result, artistStats);
return result;
}
@@ -117,6 +110,15 @@ namespace NzbDrone.Api.Albums
LinkArtistStatistics(resource, _artistStatisticsService.ArtistStatistics(resource.ArtistId));
}
+ private void LinkArtistStatistics(List resources, List artistStatistics)
+ {
+ foreach (var album in resources)
+ {
+ var stats = artistStatistics.SingleOrDefault(ss => ss.ArtistId == album.ArtistId);
+ LinkArtistStatistics(album, stats);
+ }
+ }
+
private void LinkArtistStatistics(AlbumResource resource, ArtistStatistics artistStatistics)
{
if (artistStatistics.AlbumStatistics != null)
@@ -124,7 +126,7 @@ namespace NzbDrone.Api.Albums
var dictAlbumStats = artistStatistics.AlbumStatistics.ToDictionary(v => v.AlbumId);
resource.Statistics = dictAlbumStats.GetValueOrDefault(resource.Id).ToResource();
-
+
}
}
diff --git a/src/Lidarr.Api.V3/Albums/AlbumResource.cs b/src/Lidarr.Api.V3/Albums/AlbumResource.cs
new file mode 100644
index 000000000..ddfc8c5b1
--- /dev/null
+++ b/src/Lidarr.Api.V3/Albums/AlbumResource.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json;
+using NzbDrone.Core.Music;
+using Lidarr.Api.V3.Artist;
+using Lidarr.Http.REST;
+using NzbDrone.Core.MediaCover;
+
+namespace Lidarr.Api.V3.Albums
+{
+ public class AlbumResource : RestResource
+ {
+ public string Title { get; set; }
+ public int ArtistId { get; set; }
+ public List AlbumLabel { get; set; }
+ public string ForeignAlbumId { get; set; }
+ public bool Monitored { get; set; }
+ public string Path { get; set; }
+ public int ProfileId { get; set; }
+ public int Duration { get; set; }
+ public string AlbumType { get; set; }
+ public Ratings Ratings { get; set; }
+ public DateTime? ReleaseDate { get; set; }
+ public List Genres { get; set; }
+ public ArtistResource Artist { get; set; }
+ public List Images { get; set; }
+ public AlbumStatisticsResource Statistics { get; set; }
+
+ //Hiding this so people don't think its usable (only used to set the initial state)
+ [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public bool Grabbed { get; set; }
+ }
+
+ public static class EpisodeResourceMapper
+ {
+ public static AlbumResource ToResource(this Album model)
+ {
+ if (model == null) return null;
+
+ return new AlbumResource
+ {
+ Id = model.Id,
+ ArtistId = model.ArtistId,
+ AlbumLabel = model.Label,
+ ForeignAlbumId = model.ForeignAlbumId,
+ Path = model.Path,
+ ProfileId = model.ProfileId,
+ Monitored = model.Monitored,
+ ReleaseDate = model.ReleaseDate,
+ Genres = model.Genres,
+ Title = model.Title,
+ Images = model.Images,
+ Ratings = model.Ratings,
+ Duration = model.Duration,
+ AlbumType = model.AlbumType
+ };
+ }
+
+ public static List ToResource(this IEnumerable models)
+ {
+ if (models == null) return null;
+
+ return models.Select(ToResource).ToList();
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/Albums/AlbumStatisticsResource.cs b/src/Lidarr.Api.V3/Albums/AlbumStatisticsResource.cs
similarity index 75%
rename from src/NzbDrone.Api/Albums/AlbumStatisticsResource.cs
rename to src/Lidarr.Api.V3/Albums/AlbumStatisticsResource.cs
index 13ffb2e4b..e53450ed5 100644
--- a/src/NzbDrone.Api/Albums/AlbumStatisticsResource.cs
+++ b/src/Lidarr.Api.V3/Albums/AlbumStatisticsResource.cs
@@ -1,7 +1,7 @@
-using System;
+using System;
using NzbDrone.Core.ArtistStats;
-namespace NzbDrone.Api.Albums
+namespace Lidarr.Api.V3.Albums
{
public class AlbumStatisticsResource
{
@@ -10,14 +10,11 @@ namespace NzbDrone.Api.Albums
public int TotalTrackCount { get; set; }
public long SizeOnDisk { get; set; }
- public decimal PercentOfTracks
+ public decimal PercentOfEpisodes
{
get
{
- if (TrackCount == 0)
- {
- return 0;
- }
+ if (TrackCount == 0) return 0;
return (decimal)TrackFileCount / (decimal)TrackCount * 100;
}
@@ -28,10 +25,7 @@ namespace NzbDrone.Api.Albums
{
public static AlbumStatisticsResource ToResource(this AlbumStatistics model)
{
- if (model == null)
- {
- return null;
- }
+ if (model == null) return null;
return new AlbumStatisticsResource
{
diff --git a/src/Lidarr.Api.V3/Albums/AlbumsMonitoredResource.cs b/src/Lidarr.Api.V3/Albums/AlbumsMonitoredResource.cs
new file mode 100644
index 000000000..9c0120eda
--- /dev/null
+++ b/src/Lidarr.Api.V3/Albums/AlbumsMonitoredResource.cs
@@ -0,0 +1,11 @@
+using System;
+using System.Collections.Generic;
+
+namespace Lidarr.Api.V3.Albums
+{
+ public class AlbumsMonitoredResource
+ {
+ public List AlbumIds { get; set; }
+ public bool Monitored { get; set; }
+ }
+}
diff --git a/src/NzbDrone.Api/Series/AlternateTitleResource.cs b/src/Lidarr.Api.V3/Artist/AlternateTitleResource.cs
similarity index 85%
rename from src/NzbDrone.Api/Series/AlternateTitleResource.cs
rename to src/Lidarr.Api.V3/Artist/AlternateTitleResource.cs
index b1d6cc22c..d3df621b9 100644
--- a/src/NzbDrone.Api/Series/AlternateTitleResource.cs
+++ b/src/Lidarr.Api.V3/Artist/AlternateTitleResource.cs
@@ -1,4 +1,4 @@
-namespace NzbDrone.Api.Series
+namespace Lidarr.Api.V3.Series
{
public class AlternateTitleResource
{
diff --git a/src/Lidarr.Api.V3/Artist/ArtistEditorDeleteResource.cs b/src/Lidarr.Api.V3/Artist/ArtistEditorDeleteResource.cs
new file mode 100644
index 000000000..09c5b892c
--- /dev/null
+++ b/src/Lidarr.Api.V3/Artist/ArtistEditorDeleteResource.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+
+namespace Lidarr.Api.V3.Artist
+{
+ public class ArtistEditorDeleteResource
+ {
+ public List ArtistIds { get; set; }
+ public bool DeleteFiles { get; set; }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Artist/ArtistEditorModule.cs b/src/Lidarr.Api.V3/Artist/ArtistEditorModule.cs
new file mode 100644
index 000000000..2cbdd8046
--- /dev/null
+++ b/src/Lidarr.Api.V3/Artist/ArtistEditorModule.cs
@@ -0,0 +1,85 @@
+using System.Collections.Generic;
+using Nancy;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Music;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3.Artist
+{
+ public class ArtistEditorModule : SonarrV3Module
+ {
+ private readonly IArtistService _artistService;
+
+ public ArtistEditorModule(IArtistService artistService)
+ : base("/artist/editor")
+ {
+ _artistService = artistService;
+ Put["/"] = artist => SaveAll();
+ Delete["/"] = artist => DeleteArtist();
+ }
+
+ private Response SaveAll()
+ {
+ var resource = Request.Body.FromJson();
+ var artistToUpdate = _artistService.GetArtists(resource.ArtistIds);
+
+ foreach (var artist in artistToUpdate)
+ {
+ if (resource.Monitored.HasValue)
+ {
+ artist.Monitored = resource.Monitored.Value;
+ }
+
+ if (resource.QualityProfileId.HasValue)
+ {
+ artist.ProfileId = resource.QualityProfileId.Value;
+ }
+
+ if (resource.AlbumFolder.HasValue)
+ {
+ artist.AlbumFolder = resource.AlbumFolder.Value;
+ }
+
+ if (resource.RootFolderPath.IsNotNullOrWhiteSpace())
+ {
+ artist.RootFolderPath = resource.RootFolderPath;
+ }
+
+ if (resource.Tags != null)
+ {
+ var newTags = resource.Tags;
+ var applyTags = resource.ApplyTags;
+
+ switch (applyTags)
+ {
+ case ApplyTags.Add:
+ newTags.ForEach(t => artist.Tags.Add(t));
+ break;
+ case ApplyTags.Remove:
+ newTags.ForEach(t => artist.Tags.Remove(t));
+ break;
+ case ApplyTags.Replace:
+ artist.Tags = new HashSet(newTags);
+ break;
+ }
+ }
+ }
+
+ return _artistService.UpdateArtists(artistToUpdate)
+ .ToResource()
+ .AsResponse(HttpStatusCode.Accepted);
+ }
+
+ private Response DeleteArtist()
+ {
+ var resource = Request.Body.FromJson();
+
+ foreach (var artistId in resource.ArtistIds)
+ {
+ _artistService.DeleteArtist(artistId, false);
+ }
+
+ return new object().AsResponse();
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Artist/ArtistEditorResource.cs b/src/Lidarr.Api.V3/Artist/ArtistEditorResource.cs
new file mode 100644
index 000000000..649b9910a
--- /dev/null
+++ b/src/Lidarr.Api.V3/Artist/ArtistEditorResource.cs
@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+using NzbDrone.Core.Music;
+
+namespace Lidarr.Api.V3.Artist
+{
+ public class ArtistEditorResource
+ {
+ public List ArtistIds { get; set; }
+ public bool? Monitored { get; set; }
+ public int? QualityProfileId { get; set; }
+ public int? LanguageProfileId { get; set; }
+ //public SeriesTypes? SeriesType { get; set; }
+ public bool? AlbumFolder { get; set; }
+ public string RootFolderPath { get; set; }
+ public List Tags { get; set; }
+ public ApplyTags ApplyTags { get; set; }
+ }
+
+ public enum ApplyTags
+ {
+ Add,
+ Remove,
+ Replace
+ }
+}
diff --git a/src/Lidarr.Api.V3/Artist/ArtistImportModule.cs b/src/Lidarr.Api.V3/Artist/ArtistImportModule.cs
new file mode 100644
index 000000000..2a3e1519e
--- /dev/null
+++ b/src/Lidarr.Api.V3/Artist/ArtistImportModule.cs
@@ -0,0 +1,29 @@
+using System.Collections.Generic;
+using Nancy;
+using NzbDrone.Core.Music;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3.Artist
+{
+ public class ArtistImportModule : LidarrRestModule
+ {
+ private readonly IAddArtistService _addArtistService;
+
+ public ArtistImportModule(IAddArtistService addArtistService)
+ : base("/artist/import")
+ {
+ _addArtistService = addArtistService;
+ Post["/"] = x => Import();
+ }
+
+
+ private Response Import()
+ {
+ var resource = Request.Body.FromJson>();
+ var newArtist = resource.ToModel();
+
+ return _addArtistService.AddArtists(newArtist).ToResource().AsResponse();
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Artist/ArtistLookupModule.cs b/src/Lidarr.Api.V3/Artist/ArtistLookupModule.cs
new file mode 100644
index 000000000..d0cbc6349
--- /dev/null
+++ b/src/Lidarr.Api.V3/Artist/ArtistLookupModule.cs
@@ -0,0 +1,45 @@
+using System.Collections.Generic;
+using System.Linq;
+using Nancy;
+using NzbDrone.Core.MediaCover;
+using NzbDrone.Core.MetadataSource;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3.Artist
+{
+ public class ArtistLookupModule : LidarrRestModule
+ {
+ private readonly ISearchForNewArtist _searchProxy;
+
+ public ArtistLookupModule(ISearchForNewArtist searchProxy)
+ : base("/artist/lookup")
+ {
+ _searchProxy = searchProxy;
+ Get["/"] = x => Search();
+ }
+
+
+ private Response Search()
+ {
+ var tvDbResults = _searchProxy.SearchForNewArtist((string)Request.Query.term);
+ return MapToResource(tvDbResults).AsResponse();
+ }
+
+
+ private static IEnumerable MapToResource(IEnumerable artist)
+ {
+ foreach (var currentArtist in artist)
+ {
+ var resource = currentArtist.ToResource();
+ var poster = currentArtist.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster);
+ if (poster != null)
+ {
+ resource.RemotePoster = poster.Url;
+ }
+
+ yield return resource;
+ }
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Artist/ArtistModule.cs b/src/Lidarr.Api.V3/Artist/ArtistModule.cs
new file mode 100644
index 000000000..0ead0137c
--- /dev/null
+++ b/src/Lidarr.Api.V3/Artist/ArtistModule.cs
@@ -0,0 +1,247 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using FluentValidation;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.MediaCover;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.MediaFiles.Events;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.Core.ArtistStats;
+using NzbDrone.Core.Music;
+using NzbDrone.Core.Music.Events;
+using NzbDrone.Core.Validation;
+using NzbDrone.Core.Validation.Paths;
+using Lidarr.Api.V3.Albums;
+using NzbDrone.SignalR;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+using Lidarr.Http.Mapping;
+
+namespace Lidarr.Api.V3.Artist
+{
+ public class ArtistModule : LidarrRestModuleWithSignalR,
+ IHandle,
+ IHandle,
+ IHandle,
+ IHandle,
+ IHandle,
+ IHandle,
+ IHandle
+
+ {
+ private readonly IArtistService _artistService;
+ private readonly IAddArtistService _addArtistService;
+ private readonly IArtistStatisticsService _artistStatisticsService;
+ private readonly IMapCoversToLocal _coverMapper;
+ private readonly IAlbumService _albumService;
+
+ public ArtistModule(IBroadcastSignalRMessage signalRBroadcaster,
+ IArtistService artistService,
+ IAddArtistService addArtistService,
+ IArtistStatisticsService artistStatisticsService,
+ IMapCoversToLocal coverMapper,
+ IAlbumService albumService,
+ RootFolderValidator rootFolderValidator,
+ ArtistPathValidator artistPathValidator,
+ ArtistExistsValidator artistExistsValidator,
+ ArtistAncestorValidator artistAncestorValidator,
+ ProfileExistsValidator profileExistsValidator,
+ LanguageProfileExistsValidator languageProfileExistsValidator
+ )
+ : base(signalRBroadcaster)
+ {
+ _artistService = artistService;
+ _addArtistService = addArtistService;
+ _artistStatisticsService = artistStatisticsService;
+
+ _coverMapper = coverMapper;
+ _albumService = albumService;
+
+ GetResourceAll = AllArtists;
+ GetResourceById = GetArtist;
+ CreateResource = AddArtist;
+ UpdateResource = UpdateArtist;
+ DeleteResource = DeleteArtist;
+
+ Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.QualityProfileId));
+
+ SharedValidator.RuleFor(s => s.Path)
+ .Cascade(CascadeMode.StopOnFirstFailure)
+ .IsValidPath()
+ .SetValidator(rootFolderValidator)
+ .SetValidator(artistPathValidator)
+ .SetValidator(artistAncestorValidator)
+ .When(s => !s.Path.IsNullOrWhiteSpace());
+
+ SharedValidator.RuleFor(s => s.QualityProfileId).SetValidator(profileExistsValidator);
+ SharedValidator.RuleFor(s => s.LanguageProfileId).SetValidator(languageProfileExistsValidator);
+
+ PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace());
+ PostValidator.RuleFor(s => s.RootFolderPath).IsValidPath().When(s => s.Path.IsNullOrWhiteSpace());
+ PostValidator.RuleFor(s => s.ArtistName).NotEmpty();
+ PostValidator.RuleFor(s => s.ForeignArtistId).NotEmpty().SetValidator(artistExistsValidator);
+
+ PutValidator.RuleFor(s => s.Path).IsValidPath();
+ }
+
+ private ArtistResource GetArtist(int id)
+ {
+ var artist = _artistService.GetArtist(id);
+ return GetArtistResource(artist);
+ }
+
+ private ArtistResource GetArtistResource(NzbDrone.Core.Music.Artist artist)
+ {
+ if (artist == null) return null;
+
+ var resource = artist.ToResource();
+ MapCoversToLocal(resource);
+ //MapAlbums(resource);
+ FetchAndLinkArtistStatistics(resource);
+ //PopulateAlternateTitles(resource);
+
+ return resource;
+ }
+
+ private List AllArtists()
+ {
+ var artistStats = _artistStatisticsService.ArtistStatistics();
+ var artistsResources = _artistService.GetAllArtists().ToResource();
+
+ MapCoversToLocal(artistsResources.ToArray());
+ //MapAlbums(artistsResources.ToArray());
+ LinkArtistStatistics(artistsResources, artistStats);
+ //PopulateAlternateTitles(seriesResources);
+
+ return artistsResources;
+ }
+
+ private int AddArtist(ArtistResource artistResource)
+ {
+ var artist = _addArtistService.AddArtist(artistResource.ToModel());
+
+ return artist.Id;
+ }
+
+ private void UpdateArtist(ArtistResource artistResource)
+ {
+ var model = artistResource.ToModel(_artistService.GetArtist(artistResource.Id));
+
+ _artistService.UpdateArtist(model);
+
+ BroadcastResourceChange(ModelAction.Updated, artistResource);
+ }
+
+ private void DeleteArtist(int id)
+ {
+ var deleteFiles = Request.GetBooleanQueryParameter("deleteFiles");
+
+ _artistService.DeleteArtist(id, deleteFiles);
+ }
+
+ private void MapCoversToLocal(params ArtistResource[] artists)
+ {
+ foreach (var artistResource in artists)
+ {
+ _coverMapper.ConvertToLocalUrls(artistResource.Id, artistResource.Images);
+ }
+ }
+
+ private void MapAlbums(params ArtistResource[] artists)
+ {
+ foreach (var artistResource in artists)
+ {
+ artistResource.Albums = _albumService.GetAlbumsByArtist(artistResource.Id).ToResource();
+ }
+ }
+
+ private void FetchAndLinkArtistStatistics(ArtistResource resource)
+ {
+ LinkArtistStatistics(resource, _artistStatisticsService.ArtistStatistics(resource.Id));
+ }
+
+ private void LinkArtistStatistics(List resources, List artistStatistics)
+ {
+ foreach (var artist in resources)
+ {
+ var stats = artistStatistics.SingleOrDefault(ss => ss.ArtistId == artist.Id);
+ if (stats == null) continue;
+
+ LinkArtistStatistics(artist, stats);
+ }
+ }
+
+ private void LinkArtistStatistics(ArtistResource resource, ArtistStatistics artistStatistics)
+ {
+ resource.TotalTrackCount = artistStatistics.TotalTrackCount;
+ resource.TrackCount = artistStatistics.TrackCount;
+ resource.TrackFileCount = artistStatistics.TrackFileCount;
+ resource.SizeOnDisk = artistStatistics.SizeOnDisk;
+ resource.AlbumCount = artistStatistics.AlbumCount;
+
+ if (artistStatistics.AlbumStatistics != null)
+ {
+ foreach (var album in resource.Albums)
+ {
+ album.Statistics = artistStatistics.AlbumStatistics.SingleOrDefault(s => s.AlbumId == album.Id).ToResource();
+ }
+ }
+ }
+
+ //private void PopulateAlternateTitles(List resources)
+ //{
+ // foreach (var resource in resources)
+ // {
+ // PopulateAlternateTitles(resource);
+ // }
+ //}
+
+ //private void PopulateAlternateTitles(ArtistResource resource)
+ //{
+ // var mappings = _sceneMappingService.FindByTvdbId(resource.TvdbId);
+
+ // if (mappings == null) return;
+
+ // resource.AlternateTitles = mappings.Select(v => new AlternateTitleResource { Title = v.Title, SeasonNumber = v.SeasonNumber, SceneSeasonNumber = v.SceneSeasonNumber }).ToList();
+ //}
+
+ public void Handle(TrackImportedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Updated, message.ImportedTrack.ArtistId);
+ }
+
+ public void Handle(TrackFileDeletedEvent message)
+ {
+ if (message.Reason == DeleteMediaFileReason.Upgrade) return;
+
+ BroadcastResourceChange(ModelAction.Updated, message.TrackFile.ArtistId);
+ }
+
+ public void Handle(ArtistUpdatedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Updated, message.Artist.Id);
+ }
+
+ public void Handle(ArtistEditedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Updated, message.Artist.Id);
+ }
+
+ public void Handle(ArtistDeletedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Deleted, message.Artist.ToResource());
+ }
+
+ public void Handle(ArtistRenamedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Updated, message.Artist.Id);
+ }
+
+ public void Handle(MediaCoversUpdatedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Updated, message.Artist.Id);
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Artist/ArtistResource.cs b/src/Lidarr.Api.V3/Artist/ArtistResource.cs
new file mode 100644
index 000000000..2ac1f42c4
--- /dev/null
+++ b/src/Lidarr.Api.V3/Artist/ArtistResource.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Core.MediaCover;
+using NzbDrone.Core.Music;
+using Lidarr.Api.V3.Albums;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Artist
+{
+ public class ArtistResource : RestResource
+ {
+ //Todo: Sorters should be done completely on the client
+ //Todo: Is there an easy way to keep IgnoreArticlesWhenSorting in sync between, Series, History, Missing?
+ //Todo: We should get the entire Profile instead of ID and Name separately
+
+ public ArtistStatusType Status { get; set; }
+
+ public bool Ended => Status == ArtistStatusType.Ended;
+
+ public DateTime? LastInfoSync { get; set; }
+
+ public string ArtistName { get; set; }
+ public string ForeignArtistId { get; set; }
+ public string MBId { get; set; }
+ public int TADBId { get; set; }
+ public int DiscogsId { get; set; }
+ public string AllMusicId { get; set; }
+ public string Overview { get; set; }
+ public string ArtistType { get; set; }
+ public string Disambiguation { get; set; }
+ public List PrimaryAlbumTypes { get; set; }
+ public List SecondaryAlbumTypes { get; set; }
+ public List Links { get; set; }
+
+ public int? AlbumCount { get; set; }
+ public int? TotalTrackCount { get; set; }
+ public int? TrackCount { get; set; }
+ public int? TrackFileCount { get; set; }
+ public long? SizeOnDisk { get; set; }
+ //public SeriesStatusType Status { get; set; }
+
+ public List Images { get; set; }
+ public List Members { get; set; }
+
+ public string RemotePoster { get; set; }
+ public List Albums { get; set; }
+
+
+ //View & Edit
+ public string Path { get; set; }
+ public int QualityProfileId { get; set; }
+ public int LanguageProfileId { get; set; }
+
+ //Editing Only
+ public bool AlbumFolder { get; set; }
+ public bool Monitored { get; set; }
+
+ public string RootFolderPath { get; set; }
+ //public string Certification { get; set; }
+ public List Genres { get; set; }
+ public string CleanName { get; set; }
+ public string SortName { get; set; }
+ public HashSet Tags { get; set; }
+ public DateTime Added { get; set; }
+ public AddArtistOptions AddOptions { get; set; }
+ public Ratings Ratings { get; set; }
+ public string NameSlug { get; set; }
+
+ //TODO: Add series statistics as a property of the series (instead of individual properties)
+ }
+
+ public static class SeriesResourceMapper
+ {
+ public static ArtistResource ToResource(this NzbDrone.Core.Music.Artist model)
+ {
+ if (model == null) return null;
+
+ return new ArtistResource
+ {
+ Id = model.Id,
+
+ ArtistName = model.Name,
+ //AlternateTitles
+ SortName = model.SortName,
+
+ Status = model.Status,
+ Overview = model.Overview,
+ ArtistType = model.ArtistType,
+ Disambiguation = model.Disambiguation,
+
+ PrimaryAlbumTypes = model.PrimaryAlbumTypes,
+ SecondaryAlbumTypes = model.SecondaryAlbumTypes,
+
+ Images = model.Images,
+
+ Albums = model.Albums.ToResource(),
+ //Year = model.Year,
+
+ Path = model.Path,
+ QualityProfileId = model.ProfileId,
+ LanguageProfileId = model.LanguageProfileId,
+ Links = model.Links,
+
+ AlbumFolder = model.AlbumFolder,
+ Monitored = model.Monitored,
+
+ LastInfoSync = model.LastInfoSync,
+ //SeriesType = model.SeriesType,
+ CleanName = model.CleanName,
+ ForeignArtistId = model.ForeignArtistId,
+ NameSlug = model.NameSlug,
+ RootFolderPath = model.RootFolderPath,
+ //Certification = model.Certification,
+ Genres = model.Genres,
+ Tags = model.Tags,
+ Added = model.Added,
+ AddOptions = model.AddOptions,
+ Ratings = model.Ratings
+ };
+ }
+
+ public static NzbDrone.Core.Music.Artist ToModel(this ArtistResource resource)
+ {
+ if (resource == null) return null;
+
+ return new NzbDrone.Core.Music.Artist
+ {
+ Id = resource.Id,
+
+ Name = resource.ArtistName,
+ //AlternateTitles
+ SortName = resource.SortName,
+
+ Status = resource.Status,
+ Overview = resource.Overview,
+ //NextAiring
+ //PreviousAiring
+ // Network = resource.Network,
+ //AirTime = resource.AirTime,
+ Images = resource.Images,
+
+ //Albums = resource.Albums.ToModel(),
+ //Year = resource.Year,
+
+ Path = resource.Path,
+ ProfileId = resource.QualityProfileId,
+ LanguageProfileId = resource.LanguageProfileId,
+ Links = resource.Links,
+ PrimaryAlbumTypes = resource.PrimaryAlbumTypes,
+ SecondaryAlbumTypes = resource.SecondaryAlbumTypes,
+
+ AlbumFolder = resource.AlbumFolder,
+ Monitored = resource.Monitored,
+
+ LastInfoSync = resource.LastInfoSync,
+ //SeriesType = resource.SeriesType,
+ CleanName = resource.CleanName,
+ ForeignArtistId = resource.ForeignArtistId,
+ NameSlug = resource.NameSlug,
+ RootFolderPath = resource.RootFolderPath,
+ //Certification = resource.Certification,
+ Genres = resource.Genres,
+ Tags = resource.Tags,
+ Added = resource.Added,
+ AddOptions = resource.AddOptions,
+ Ratings = resource.Ratings
+ };
+ }
+
+ public static NzbDrone.Core.Music.Artist ToModel(this ArtistResource resource, NzbDrone.Core.Music.Artist artist)
+ {
+ var updatedArtist = resource.ToModel();
+
+ artist.ApplyChanges(updatedArtist);
+
+ return artist;
+ }
+
+ public static List ToResource(this IEnumerable artist)
+ {
+ return artist.Select(ToResource).ToList();
+ }
+
+ public static List ToModel(this IEnumerable resources)
+ {
+ return resources.Select(ToModel).ToList();
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Blacklist/BlacklistModule.cs b/src/Lidarr.Api.V3/Blacklist/BlacklistModule.cs
new file mode 100644
index 000000000..7fecda1ca
--- /dev/null
+++ b/src/Lidarr.Api.V3/Blacklist/BlacklistModule.cs
@@ -0,0 +1,36 @@
+using NzbDrone.Core.Blacklisting;
+using NzbDrone.Core.Datastore;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.Blacklist
+{
+ public class BlacklistModule : LidarrRestModule
+ {
+ private readonly IBlacklistService _blacklistService;
+
+ public BlacklistModule(IBlacklistService blacklistService)
+ {
+ _blacklistService = blacklistService;
+ GetResourcePaged = GetBlacklist;
+ DeleteResource = DeleteBlacklist;
+ }
+
+ private PagingResource GetBlacklist(PagingResource pagingResource)
+ {
+ var pagingSpec = new PagingSpec
+ {
+ Page = pagingResource.Page,
+ PageSize = pagingResource.PageSize,
+ SortKey = pagingResource.SortKey,
+ SortDirection = pagingResource.SortDirection
+ };
+
+ return ApplyToPage(_blacklistService.Paged, pagingSpec, BlacklistResourceMapper.MapToResource);
+ }
+
+ private void DeleteBlacklist(int id)
+ {
+ _blacklistService.Delete(id);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Blacklist/BlacklistResource.cs b/src/Lidarr.Api.V3/Blacklist/BlacklistResource.cs
similarity index 78%
rename from src/NzbDrone.Api/Blacklist/BlacklistResource.cs
rename to src/Lidarr.Api.V3/Blacklist/BlacklistResource.cs
index d534e720f..d47926b97 100644
--- a/src/NzbDrone.Api/Blacklist/BlacklistResource.cs
+++ b/src/Lidarr.Api.V3/Blacklist/BlacklistResource.cs
@@ -1,17 +1,19 @@
-using System;
+using System;
using System.Collections.Generic;
-using NzbDrone.Api.REST;
-using NzbDrone.Core.Qualities;
-using NzbDrone.Api.Music;
using NzbDrone.Core.Indexers;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Qualities;
+using Lidarr.Api.V3.Artist;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Blacklist
+namespace Lidarr.Api.V3.Blacklist
{
public class BlacklistResource : RestResource
{
public int ArtistId { get; set; }
public List AlbumIds { get; set; }
public string SourceTitle { get; set; }
+ public Language Language { get; set; }
public QualityModel Quality { get; set; }
public DateTime Date { get; set; }
public DownloadProtocol Protocol { get; set; }
@@ -23,7 +25,7 @@ namespace NzbDrone.Api.Blacklist
public static class BlacklistResourceMapper
{
- public static BlacklistResource MapToResource(this Core.Blacklisting.Blacklist model)
+ public static BlacklistResource MapToResource(this NzbDrone.Core.Blacklisting.Blacklist model)
{
if (model == null) return null;
@@ -34,6 +36,7 @@ namespace NzbDrone.Api.Blacklist
ArtistId = model.ArtistId,
AlbumIds = model.AlbumIds,
SourceTitle = model.SourceTitle,
+ Language = model.Language,
Quality = model.Quality,
Date = model.Date,
Protocol = model.Protocol,
diff --git a/src/Lidarr.Api.V3/Calendar/CalendarFeedModule.cs b/src/Lidarr.Api.V3/Calendar/CalendarFeedModule.cs
new file mode 100644
index 000000000..86a467be7
--- /dev/null
+++ b/src/Lidarr.Api.V3/Calendar/CalendarFeedModule.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Ical.Net;
+using Ical.Net.DataTypes;
+using Ical.Net.General;
+using Ical.Net.Interfaces.Serialization;
+using Ical.Net.Serialization;
+using Ical.Net.Serialization.iCalendar.Factory;
+using Nancy;
+using Nancy.Responses;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Tags;
+using NzbDrone.Core.Music;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3.Calendar
+{
+ public class CalendarFeedModule : SonarrV3FeedModule
+ {
+ private readonly IAlbumService _albumService;
+ private readonly ITagService _tagService;
+
+ public CalendarFeedModule(IAlbumService albumService, ITagService tagService)
+ : base("calendar")
+ {
+ _albumService = albumService;
+ _tagService = tagService;
+
+ Get["/Lidarr.ics"] = options => GetCalendarFeed();
+ }
+
+ private Response GetCalendarFeed()
+ {
+ var pastDays = 7;
+ var futureDays = 28;
+ var start = DateTime.Today.AddDays(-pastDays);
+ var end = DateTime.Today.AddDays(futureDays);
+ var unmonitored = Request.GetBooleanQueryParameter("unmonitored");
+ var asAllDay = Request.GetBooleanQueryParameter("asAllDay");
+ var tags = new List();
+
+ var queryPastDays = Request.Query.PastDays;
+ var queryFutureDays = Request.Query.FutureDays;
+ var queryTags = Request.Query.Tags;
+
+ if (queryPastDays.HasValue)
+ {
+ pastDays = int.Parse(queryPastDays.Value);
+ start = DateTime.Today.AddDays(-pastDays);
+ }
+
+ if (queryFutureDays.HasValue)
+ {
+ futureDays = int.Parse(queryFutureDays.Value);
+ end = DateTime.Today.AddDays(futureDays);
+ }
+
+ if (queryTags.HasValue)
+ {
+ var tagInput = (string)queryTags.Value.ToString();
+ tags.AddRange(tagInput.Split(',').Select(_tagService.GetTag).Select(t => t.Id));
+ }
+
+ var albums = _albumService.AlbumsBetweenDates(start, end, unmonitored);
+ var calendar = new Ical.Net.Calendar
+ {
+ ProductId = "-//lidarr.audio//Lidarr//EN"
+ };
+
+ var calendarName = "Lidarr Music Schedule";
+ calendar.AddProperty(new CalendarProperty("NAME", calendarName));
+ calendar.AddProperty(new CalendarProperty("X-WR-CALNAME", calendarName));
+
+ foreach (var album in albums.OrderBy(v => v.ReleaseDate.Value))
+ {
+ if (tags.Any() && tags.None(album.Artist.Tags.Contains))
+ {
+ continue;
+ }
+
+ var occurrence = calendar.Create();
+ occurrence.Uid = "NzbDrone_album_" + album.Id;
+ //occurrence.Status = album.HasFile ? EventStatus.Confirmed : EventStatus.Tentative;
+ //occurrence.Description = album.Overview;
+ //occurrence.Categories = new List() { album.Series.Network };
+
+ occurrence.Start = new CalDateTime(album.ReleaseDate.Value.ToLocalTime()) { HasTime = false };
+
+ occurrence.Summary = $"{album.Artist.Name} - {album.Title}";
+ }
+
+ var serializer = (IStringSerializer)new SerializerFactory().Build(calendar.GetType(), new SerializationContext());
+ var icalendar = serializer.SerializeToString(calendar);
+
+ return new TextResponse(icalendar, "text/calendar");
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Calendar/CalendarModule.cs b/src/Lidarr.Api.V3/Calendar/CalendarModule.cs
new file mode 100644
index 000000000..db4e78722
--- /dev/null
+++ b/src/Lidarr.Api.V3/Calendar/CalendarModule.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Music;
+using NzbDrone.Core.ArtistStats;
+using NzbDrone.SignalR;
+using Lidarr.Api.V3.Albums;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3.Calendar
+{
+ public class CalendarModule : AlbumModuleWithSignalR
+ {
+ public CalendarModule(IAlbumService albumService,
+ IArtistStatisticsService artistStatisticsService,
+ IArtistService artistService,
+ IUpgradableSpecification ugradableSpecification,
+ IBroadcastSignalRMessage signalRBroadcaster)
+ : base(albumService, artistStatisticsService, artistService, ugradableSpecification, signalRBroadcaster, "calendar")
+ {
+ GetResourceAll = GetCalendar;
+ }
+
+ private List GetCalendar()
+ {
+ var start = DateTime.Today;
+ var end = DateTime.Today.AddDays(2);
+ var includeUnmonitored = Request.GetBooleanQueryParameter("unmonitored");
+ var includeArtist = Request.GetBooleanQueryParameter("includeArtist");
+ var includeEpisodeFile = Request.GetBooleanQueryParameter("includeEpisodeFile");
+
+ var queryStart = Request.Query.Start;
+ var queryEnd = Request.Query.End;
+
+ if (queryStart.HasValue) start = DateTime.Parse(queryStart.Value);
+ if (queryEnd.HasValue) end = DateTime.Parse(queryEnd.Value);
+
+ var resources = MapToResource(_albumService.AlbumsBetweenDates(start, end, includeUnmonitored), includeArtist);
+
+ return resources.OrderBy(e => e.ReleaseDate).ToList();
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Commands/CommandModule.cs b/src/Lidarr.Api.V3/Commands/CommandModule.cs
new file mode 100644
index 000000000..c4ceba3ed
--- /dev/null
+++ b/src/Lidarr.Api.V3/Commands/CommandModule.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Common;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.Messaging.Commands;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.Core.ProgressMessaging;
+using NzbDrone.SignalR;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+using Lidarr.Http.Validation;
+
+namespace Lidarr.Api.V3.Commands
+{
+ public class CommandModule : LidarrRestModuleWithSignalR, IHandle
+ {
+ private readonly IManageCommandQueue _commandQueueManager;
+ private readonly IServiceFactory _serviceFactory;
+
+ public CommandModule(IManageCommandQueue commandQueueManager,
+ IBroadcastSignalRMessage signalRBroadcaster,
+ IServiceFactory serviceFactory)
+ : base(signalRBroadcaster)
+ {
+ _commandQueueManager = commandQueueManager;
+ _serviceFactory = serviceFactory;
+
+ GetResourceById = GetCommand;
+ CreateResource = StartCommand;
+ GetResourceAll = GetStartedCommands;
+
+ PostValidator.RuleFor(c => c.Name).NotBlank();
+ }
+
+ private CommandResource GetCommand(int id)
+ {
+ return _commandQueueManager.Get(id).ToResource();
+ }
+
+ private int StartCommand(CommandResource commandResource)
+ {
+ var commandType =
+ _serviceFactory.GetImplementations(typeof (Command))
+ .Single(c => c.Name.Replace("Command", "")
+ .Equals(commandResource.Name, StringComparison.InvariantCultureIgnoreCase));
+
+ dynamic command = Request.Body.FromJson(commandType);
+ command.Trigger = CommandTrigger.Manual;
+ command.SuppressMessages = !command.SendUpdatesToClient;
+ command.SendUpdatesToClient = true;
+
+ var trackedCommand = _commandQueueManager.Push(command, CommandPriority.Normal, CommandTrigger.Manual);
+ return trackedCommand.Id;
+ }
+
+ private List GetStartedCommands()
+ {
+ return _commandQueueManager.GetStarted().ToResource();
+ }
+
+ public void Handle(CommandUpdatedEvent message)
+ {
+ if (message.Command.Body.SendUpdatesToClient)
+ {
+ BroadcastResourceChange(ModelAction.Updated, message.Command.ToResource());
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Commands/CommandResource.cs b/src/Lidarr.Api.V3/Commands/CommandResource.cs
similarity index 91%
rename from src/NzbDrone.Api/Commands/CommandResource.cs
rename to src/Lidarr.Api.V3/Commands/CommandResource.cs
index cf09f12ac..743db720c 100644
--- a/src/NzbDrone.Api/Commands/CommandResource.cs
+++ b/src/Lidarr.Api.V3/Commands/CommandResource.cs
@@ -2,16 +2,16 @@
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
-using NzbDrone.Api.REST;
using NzbDrone.Core.Messaging.Commands;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Commands
+namespace Lidarr.Api.V3.Commands
{
public class CommandResource : RestResource
{
public string Name { get; set; }
public string Message { get; set; }
- public object Body { get; set; }
+ public Command Body { get; set; }
public CommandPriority Priority { get; set; }
public CommandStatus Status { get; set; }
public DateTime Queued { get; set; }
@@ -72,7 +72,7 @@ namespace NzbDrone.Api.Commands
{
get
{
- if (Body != null) return (Body as Command).SendUpdatesToClient;
+ if (Body != null) return Body.SendUpdatesToClient;
return false;
}
@@ -84,7 +84,7 @@ namespace NzbDrone.Api.Commands
{
get
{
- if (Body != null) return (Body as Command).UpdateScheduledTask;
+ if (Body != null) return Body.UpdateScheduledTask;
return false;
}
diff --git a/src/Lidarr.Api.V3/Config/DownloadClientConfigModule.cs b/src/Lidarr.Api.V3/Config/DownloadClientConfigModule.cs
new file mode 100644
index 000000000..262b378c7
--- /dev/null
+++ b/src/Lidarr.Api.V3/Config/DownloadClientConfigModule.cs
@@ -0,0 +1,17 @@
+using NzbDrone.Core.Configuration;
+
+namespace Lidarr.Api.V3.Config
+{
+ public class DownloadClientConfigModule : SonarrConfigModule
+ {
+ public DownloadClientConfigModule(IConfigService configService)
+ : base(configService)
+ {
+ }
+
+ protected override DownloadClientConfigResource ToResource(IConfigService model)
+ {
+ return DownloadClientConfigResourceMapper.ToResource(model);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Config/DownloadClientConfigResource.cs b/src/Lidarr.Api.V3/Config/DownloadClientConfigResource.cs
new file mode 100644
index 000000000..49340c01b
--- /dev/null
+++ b/src/Lidarr.Api.V3/Config/DownloadClientConfigResource.cs
@@ -0,0 +1,33 @@
+using NzbDrone.Core.Configuration;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Config
+{
+ public class DownloadClientConfigResource : RestResource
+ {
+ public string DownloadClientWorkingFolders { get; set; }
+
+ public bool EnableCompletedDownloadHandling { get; set; }
+ public bool RemoveCompletedDownloads { get; set; }
+
+ public bool AutoRedownloadFailed { get; set; }
+ public bool RemoveFailedDownloads { get; set; }
+ }
+
+ public static class DownloadClientConfigResourceMapper
+ {
+ public static DownloadClientConfigResource ToResource(IConfigService model)
+ {
+ return new DownloadClientConfigResource
+ {
+ DownloadClientWorkingFolders = model.DownloadClientWorkingFolders,
+
+ EnableCompletedDownloadHandling = model.EnableCompletedDownloadHandling,
+ RemoveCompletedDownloads = model.RemoveCompletedDownloads,
+
+ AutoRedownloadFailed = model.AutoRedownloadFailed,
+ RemoveFailedDownloads = model.RemoveFailedDownloads
+ };
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/Config/HostConfigModule.cs b/src/Lidarr.Api.V3/Config/HostConfigModule.cs
similarity index 95%
rename from src/NzbDrone.Api/Config/HostConfigModule.cs
rename to src/Lidarr.Api.V3/Config/HostConfigModule.cs
index 367bf770d..081ef9f5c 100644
--- a/src/NzbDrone.Api/Config/HostConfigModule.cs
+++ b/src/Lidarr.Api.V3/Config/HostConfigModule.cs
@@ -8,10 +8,11 @@ using NzbDrone.Core.Configuration;
using NzbDrone.Core.Update;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
+using Lidarr.Http;
-namespace NzbDrone.Api.Config
+namespace Lidarr.Api.V3.Config
{
- public class HostConfigModule : NzbDroneRestModule
+ public class HostConfigModule : LidarrRestModule
{
private readonly IConfigFileProvider _configFileProvider;
private readonly IConfigService _configService;
@@ -74,7 +75,6 @@ namespace NzbDrone.Api.Config
.ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null));
_configFileProvider.SaveConfigDictionary(dictionary);
- _configService.SaveConfigDictionary(dictionary);
if (resource.Username.IsNotNullOrWhiteSpace() && resource.Password.IsNotNullOrWhiteSpace())
{
diff --git a/src/NzbDrone.Api/Config/HostConfigResource.cs b/src/Lidarr.Api.V3/Config/HostConfigResource.cs
similarity index 97%
rename from src/NzbDrone.Api/Config/HostConfigResource.cs
rename to src/Lidarr.Api.V3/Config/HostConfigResource.cs
index 930e0301c..8d45f2a23 100644
--- a/src/NzbDrone.Api/Config/HostConfigResource.cs
+++ b/src/Lidarr.Api.V3/Config/HostConfigResource.cs
@@ -1,10 +1,10 @@
-using NzbDrone.Api.REST;
+using NzbDrone.Common.Http.Proxy;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Update;
-using NzbDrone.Common.Http.Proxy;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Config
+namespace Lidarr.Api.V3.Config
{
public class HostConfigResource : RestResource
{
diff --git a/src/NzbDrone.Api/Config/IndexerConfigModule.cs b/src/Lidarr.Api.V3/Config/IndexerConfigModule.cs
similarity index 82%
rename from src/NzbDrone.Api/Config/IndexerConfigModule.cs
rename to src/Lidarr.Api.V3/Config/IndexerConfigModule.cs
index 73c2442b8..b7a066539 100644
--- a/src/NzbDrone.Api/Config/IndexerConfigModule.cs
+++ b/src/Lidarr.Api.V3/Config/IndexerConfigModule.cs
@@ -1,10 +1,10 @@
using FluentValidation;
-using NzbDrone.Api.Validation;
using NzbDrone.Core.Configuration;
+using Lidarr.Http.Validation;
-namespace NzbDrone.Api.Config
+namespace Lidarr.Api.V3.Config
{
- public class IndexerConfigModule : NzbDroneConfigModule
+ public class IndexerConfigModule : SonarrConfigModule
{
public IndexerConfigModule(IConfigService configService)
diff --git a/src/NzbDrone.Api/Config/IndexerConfigResource.cs b/src/Lidarr.Api.V3/Config/IndexerConfigResource.cs
similarity index 86%
rename from src/NzbDrone.Api/Config/IndexerConfigResource.cs
rename to src/Lidarr.Api.V3/Config/IndexerConfigResource.cs
index 179e28c3f..ffeb6d76d 100644
--- a/src/NzbDrone.Api/Config/IndexerConfigResource.cs
+++ b/src/Lidarr.Api.V3/Config/IndexerConfigResource.cs
@@ -1,7 +1,7 @@
-using NzbDrone.Api.REST;
-using NzbDrone.Core.Configuration;
+using NzbDrone.Core.Configuration;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Config
+namespace Lidarr.Api.V3.Config
{
public class IndexerConfigResource : RestResource
{
diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs b/src/Lidarr.Api.V3/Config/MediaManagementConfigModule.cs
similarity index 85%
rename from src/NzbDrone.Api/Config/MediaManagementConfigModule.cs
rename to src/Lidarr.Api.V3/Config/MediaManagementConfigModule.cs
index 8b35e53ed..dbf8ff9a4 100644
--- a/src/NzbDrone.Api/Config/MediaManagementConfigModule.cs
+++ b/src/Lidarr.Api.V3/Config/MediaManagementConfigModule.cs
@@ -2,9 +2,9 @@
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Validation.Paths;
-namespace NzbDrone.Api.Config
+namespace Lidarr.Api.V3.Config
{
- public class MediaManagementConfigModule : NzbDroneConfigModule
+ public class MediaManagementConfigModule : SonarrConfigModule
{
public MediaManagementConfigModule(IConfigService configService, PathExistsValidator pathExistsValidator)
: base(configService)
diff --git a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs b/src/Lidarr.Api.V3/Config/MediaManagementConfigResource.cs
similarity index 97%
rename from src/NzbDrone.Api/Config/MediaManagementConfigResource.cs
rename to src/Lidarr.Api.V3/Config/MediaManagementConfigResource.cs
index b2a7c9b65..857e8baa0 100644
--- a/src/NzbDrone.Api/Config/MediaManagementConfigResource.cs
+++ b/src/Lidarr.Api.V3/Config/MediaManagementConfigResource.cs
@@ -1,8 +1,8 @@
-using NzbDrone.Api.REST;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Config
+namespace Lidarr.Api.V3.Config
{
public class MediaManagementConfigResource : RestResource
{
diff --git a/src/Lidarr.Api.V3/Config/MetadataProviderConfigModule.cs b/src/Lidarr.Api.V3/Config/MetadataProviderConfigModule.cs
new file mode 100644
index 000000000..9292c4842
--- /dev/null
+++ b/src/Lidarr.Api.V3/Config/MetadataProviderConfigModule.cs
@@ -0,0 +1,22 @@
+using System.Linq;
+using System.Reflection;
+using NzbDrone.Core.Configuration;
+using Lidarr.Http;
+using NzbDrone.Core.Validation;
+
+namespace Lidarr.Api.V3.Config
+{
+ public class MetadataProviderConfigModule : SonarrConfigModule
+ {
+ public MetadataProviderConfigModule(IConfigService configService)
+ : base(configService)
+ {
+ SharedValidator.RuleFor(c => c.MetadataSource).IsValidUrl();
+ }
+
+ protected override MetadataProviderConfigResource ToResource(IConfigService model)
+ {
+ return MetadataProviderConfigResourceMapper.ToResource(model);
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Config/MetadataProviderConfigResource.cs b/src/Lidarr.Api.V3/Config/MetadataProviderConfigResource.cs
new file mode 100644
index 000000000..f14ff6ce8
--- /dev/null
+++ b/src/Lidarr.Api.V3/Config/MetadataProviderConfigResource.cs
@@ -0,0 +1,24 @@
+using NzbDrone.Core.Configuration;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Config
+{
+ public class MetadataProviderConfigResource : RestResource
+ {
+ //Calendar
+ public string MetadataSource { get; set; }
+
+ }
+
+ public static class MetadataProviderConfigResourceMapper
+ {
+ public static MetadataProviderConfigResource ToResource(IConfigService model)
+ {
+ return new MetadataProviderConfigResource
+ {
+ MetadataSource = model.MetadataSource,
+
+ };
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/Config/NamingConfigModule.cs b/src/Lidarr.Api.V3/Config/NamingConfigModule.cs
similarity index 85%
rename from src/NzbDrone.Api/Config/NamingConfigModule.cs
rename to src/Lidarr.Api.V3/Config/NamingConfigModule.cs
index abe7096c9..4484f0455 100644
--- a/src/NzbDrone.Api/Config/NamingConfigModule.cs
+++ b/src/Lidarr.Api.V3/Config/NamingConfigModule.cs
@@ -1,16 +1,18 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using FluentValidation.Results;
+using Nancy.ModelBinding;
using Nancy.Responses;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Organizer;
-using Nancy.ModelBinding;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+using Lidarr.Http.Mapping;
-namespace NzbDrone.Api.Config
+namespace Lidarr.Api.V3.Config
{
- public class NamingConfigModule : NzbDroneRestModule
+ public class NamingConfigModule : LidarrRestModule
{
private readonly INamingConfigService _namingConfigService;
private readonly IFilenameSampleService _filenameSampleService;
@@ -31,9 +33,9 @@ namespace NzbDrone.Api.Config
GetResourceById = GetNamingConfig;
UpdateResource = UpdateNamingConfig;
- Get["/samples"] = x => GetExamples(this.Bind());
+ Get["/examples"] = x => GetExamples(this.Bind());
+
- SharedValidator.RuleFor(c => c.MultiEpisodeStyle).InclusiveBetween(0, 5);
SharedValidator.RuleFor(c => c.StandardTrackFormat).ValidTrackFormat();
SharedValidator.RuleFor(c => c.ArtistFolderFormat).ValidArtistFolderFormat();
SharedValidator.RuleFor(c => c.AlbumFolderFormat).ValidAlbumFolderFormat();
@@ -66,24 +68,28 @@ namespace NzbDrone.Api.Config
return GetNamingConfig();
}
- private JsonResponse GetExamples(NamingConfigResource config)
+ private JsonResponse GetExamples(NamingConfigResource config)
{
+ if (config.Id == 0)
+ {
+ config = GetNamingConfig();
+ }
+
var nameSpec = config.ToModel();
- var sampleResource = new NamingSampleResource();
+ var sampleResource = new NamingExampleResource();
-
var singleTrackSampleResult = _filenameSampleService.GetStandardTrackSample(nameSpec);
sampleResource.SingleTrackExample = _filenameValidationService.ValidateTrackFilename(singleTrackSampleResult) != null
- ? "Invalid format"
+ ? null
: singleTrackSampleResult.FileName;
sampleResource.ArtistFolderExample = nameSpec.ArtistFolderFormat.IsNullOrWhiteSpace()
- ? "Invalid format"
+ ? null
: _filenameSampleService.GetArtistFolderSample(nameSpec);
sampleResource.AlbumFolderExample = nameSpec.AlbumFolderFormat.IsNullOrWhiteSpace()
- ? "Invalid format"
+ ? null
: _filenameSampleService.GetAlbumFolderSample(nameSpec);
return sampleResource.AsResponse();
@@ -91,16 +97,14 @@ namespace NzbDrone.Api.Config
private void ValidateFormatResult(NamingConfig nameSpec)
{
-
var singleTrackSampleResult = _filenameSampleService.GetStandardTrackSample(nameSpec);
-
+
var singleTrackValidationResult = _filenameValidationService.ValidateTrackFilename(singleTrackSampleResult);
var validationFailures = new List();
validationFailures.AddIfNotNull(singleTrackValidationResult);
-
if (validationFailures.Any())
{
throw new ValidationException(validationFailures.DistinctBy(v => v.PropertyName).ToArray());
diff --git a/src/Lidarr.Api.V3/Config/NamingConfigResource.cs b/src/Lidarr.Api.V3/Config/NamingConfigResource.cs
new file mode 100644
index 000000000..f081d65a6
--- /dev/null
+++ b/src/Lidarr.Api.V3/Config/NamingConfigResource.cs
@@ -0,0 +1,19 @@
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Config
+{
+ public class NamingConfigResource : RestResource
+ {
+ public bool RenameTracks { get; set; }
+ public bool ReplaceIllegalCharacters { get; set; }
+ public string StandardTrackFormat { get; set; }
+ public string ArtistFolderFormat { get; set; }
+ public string AlbumFolderFormat { get; set; }
+ public bool IncludeArtistName { get; set; }
+ public bool IncludeAlbumTitle { get; set; }
+ public bool IncludeQuality { get; set; }
+ public bool ReplaceSpaces { get; set; }
+ public string Separator { get; set; }
+ public string NumberStyle { get; set; }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Config/NamingExampleResource.cs b/src/Lidarr.Api.V3/Config/NamingExampleResource.cs
new file mode 100644
index 000000000..d516dc8e1
--- /dev/null
+++ b/src/Lidarr.Api.V3/Config/NamingExampleResource.cs
@@ -0,0 +1,59 @@
+using NzbDrone.Core.Organizer;
+
+namespace Lidarr.Api.V3.Config
+{
+ public class NamingExampleResource
+ {
+ public string SingleTrackExample { get; set; }
+ public string ArtistFolderExample { get; set; }
+ public string AlbumFolderExample { get; set; }
+ }
+
+ public static class NamingConfigResourceMapper
+ {
+ public static NamingConfigResource ToResource(this NamingConfig model)
+ {
+ return new NamingConfigResource
+ {
+ Id = model.Id,
+
+ RenameTracks = model.RenameTracks,
+ ReplaceIllegalCharacters = model.ReplaceIllegalCharacters,
+ StandardTrackFormat = model.StandardTrackFormat,
+ ArtistFolderFormat = model.ArtistFolderFormat,
+ AlbumFolderFormat = model.AlbumFolderFormat
+ //IncludeSeriesTitle
+ //IncludeEpisodeTitle
+ //IncludeQuality
+ //ReplaceSpaces
+ //Separator
+ //NumberStyle
+ };
+ }
+
+ public static void AddToResource(this BasicNamingConfig basicNamingConfig, NamingConfigResource resource)
+ {
+ resource.IncludeArtistName = basicNamingConfig.IncludeArtistName;
+ resource.IncludeAlbumTitle = basicNamingConfig.IncludeAlbumTitle;
+ resource.IncludeQuality = basicNamingConfig.IncludeQuality;
+ resource.ReplaceSpaces = basicNamingConfig.ReplaceSpaces;
+ resource.Separator = basicNamingConfig.Separator;
+ resource.NumberStyle = basicNamingConfig.NumberStyle;
+ }
+
+ public static NamingConfig ToModel(this NamingConfigResource resource)
+ {
+ return new NamingConfig
+ {
+ Id = resource.Id,
+
+ RenameTracks = resource.RenameTracks,
+ ReplaceIllegalCharacters = resource.ReplaceIllegalCharacters,
+ StandardTrackFormat = resource.StandardTrackFormat,
+
+ ArtistFolderFormat = resource.ArtistFolderFormat,
+ AlbumFolderFormat = resource.AlbumFolderFormat
+ };
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Config/SonarrConfigModule.cs b/src/Lidarr.Api.V3/Config/SonarrConfigModule.cs
new file mode 100644
index 000000000..b294ef7bb
--- /dev/null
+++ b/src/Lidarr.Api.V3/Config/SonarrConfigModule.cs
@@ -0,0 +1,52 @@
+using System.Linq;
+using System.Reflection;
+using NzbDrone.Core.Configuration;
+using Lidarr.Http;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Config
+{
+ public abstract class SonarrConfigModule : LidarrRestModule where TResource : RestResource, new()
+ {
+ private readonly IConfigService _configService;
+
+ protected SonarrConfigModule(IConfigService configService)
+ : this(new TResource().ResourceName.Replace("config", ""), configService)
+ {
+ }
+
+ protected SonarrConfigModule(string resource, IConfigService configService) :
+ base("config/" + resource.Trim('/'))
+ {
+ _configService = configService;
+
+ GetResourceSingle = GetConfig;
+ GetResourceById = GetConfig;
+ UpdateResource = SaveConfig;
+ }
+
+ private TResource GetConfig()
+ {
+ var resource = ToResource(_configService);
+ resource.Id = 1;
+
+ return resource;
+ }
+
+ protected abstract TResource ToResource(IConfigService model);
+
+ private TResource GetConfig(int id)
+ {
+ return GetConfig();
+ }
+
+ private void SaveConfig(TResource resource)
+ {
+ var dictionary = resource.GetType()
+ .GetProperties(BindingFlags.Instance | BindingFlags.Public)
+ .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null));
+
+ _configService.SaveConfigDictionary(dictionary);
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Config/UiConfigModule.cs b/src/Lidarr.Api.V3/Config/UiConfigModule.cs
new file mode 100644
index 000000000..2bc1fd4f9
--- /dev/null
+++ b/src/Lidarr.Api.V3/Config/UiConfigModule.cs
@@ -0,0 +1,21 @@
+using System.Linq;
+using System.Reflection;
+using NzbDrone.Core.Configuration;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.Config
+{
+ public class UiConfigModule : SonarrConfigModule
+ {
+ public UiConfigModule(IConfigService configService)
+ : base(configService)
+ {
+
+ }
+
+ protected override UiConfigResource ToResource(IConfigService model)
+ {
+ return UiConfigResourceMapper.ToResource(model);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Config/UiConfigResource.cs b/src/Lidarr.Api.V3/Config/UiConfigResource.cs
similarity index 91%
rename from src/NzbDrone.Api/Config/UiConfigResource.cs
rename to src/Lidarr.Api.V3/Config/UiConfigResource.cs
index 7c7d27b67..28ed7ce28 100644
--- a/src/NzbDrone.Api/Config/UiConfigResource.cs
+++ b/src/Lidarr.Api.V3/Config/UiConfigResource.cs
@@ -1,7 +1,7 @@
-using NzbDrone.Api.REST;
-using NzbDrone.Core.Configuration;
+using NzbDrone.Core.Configuration;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Config
+namespace Lidarr.Api.V3.Config
{
public class UiConfigResource : RestResource
{
@@ -26,7 +26,7 @@ namespace NzbDrone.Api.Config
{
FirstDayOfWeek = model.FirstDayOfWeek,
CalendarWeekColumnHeader = model.CalendarWeekColumnHeader,
-
+
ShortDateFormat = model.ShortDateFormat,
LongDateFormat = model.LongDateFormat,
TimeFormat = model.TimeFormat,
diff --git a/src/NzbDrone.Api/DiskSpace/DiskSpaceModule.cs b/src/Lidarr.Api.V3/DiskSpace/DiskSpaceModule.cs
similarity index 76%
rename from src/NzbDrone.Api/DiskSpace/DiskSpaceModule.cs
rename to src/Lidarr.Api.V3/DiskSpace/DiskSpaceModule.cs
index f6d8354b4..15806fb59 100644
--- a/src/NzbDrone.Api/DiskSpace/DiskSpaceModule.cs
+++ b/src/Lidarr.Api.V3/DiskSpace/DiskSpaceModule.cs
@@ -1,20 +1,20 @@
using System.Collections.Generic;
using NzbDrone.Core.DiskSpace;
+using Lidarr.Http;
-namespace NzbDrone.Api.DiskSpace
+namespace Lidarr.Api.V3.DiskSpace
{
- public class DiskSpaceModule :NzbDroneRestModule
+ public class DiskSpaceModule :LidarrRestModule
{
private readonly IDiskSpaceService _diskSpaceService;
public DiskSpaceModule(IDiskSpaceService diskSpaceService)
- : base("diskspace")
+ :base("diskspace")
{
_diskSpaceService = diskSpaceService;
GetResourceAll = GetFreeSpace;
}
-
public List GetFreeSpace()
{
return _diskSpaceService.GetFreeSpace().ConvertAll(DiskSpaceResourceMapper.MapToResource);
diff --git a/src/NzbDrone.Api/DiskSpace/DiskSpaceResource.cs b/src/Lidarr.Api.V3/DiskSpace/DiskSpaceResource.cs
similarity index 78%
rename from src/NzbDrone.Api/DiskSpace/DiskSpaceResource.cs
rename to src/Lidarr.Api.V3/DiskSpace/DiskSpaceResource.cs
index fc36f9d5c..fc236f1c7 100644
--- a/src/NzbDrone.Api/DiskSpace/DiskSpaceResource.cs
+++ b/src/Lidarr.Api.V3/DiskSpace/DiskSpaceResource.cs
@@ -1,6 +1,6 @@
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.DiskSpace
+namespace Lidarr.Api.V3.DiskSpace
{
public class DiskSpaceResource : RestResource
{
@@ -12,7 +12,7 @@ namespace NzbDrone.Api.DiskSpace
public static class DiskSpaceResourceMapper
{
- public static DiskSpaceResource MapToResource(this Core.DiskSpace.DiskSpace model)
+ public static DiskSpaceResource MapToResource(this NzbDrone.Core.DiskSpace.DiskSpace model)
{
if (model == null) return null;
diff --git a/src/Lidarr.Api.V3/DownloadClient/DownloadClientModule.cs b/src/Lidarr.Api.V3/DownloadClient/DownloadClientModule.cs
new file mode 100644
index 000000000..af12eb21d
--- /dev/null
+++ b/src/Lidarr.Api.V3/DownloadClient/DownloadClientModule.cs
@@ -0,0 +1,20 @@
+using NzbDrone.Core.Download;
+
+namespace Lidarr.Api.V3.DownloadClient
+{
+ public class DownloadClientModule : ProviderModuleBase
+ {
+ public static readonly DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper();
+
+ public DownloadClientModule(IDownloadClientFactory downloadClientFactory)
+ : base(downloadClientFactory, "downloadclient", ResourceMapper)
+ {
+ }
+
+ protected override void Validate(DownloadClientDefinition definition, bool includeWarnings)
+ {
+ if (!definition.Enable) return;
+ base.Validate(definition, includeWarnings);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/DownloadClient/DownloadClientResource.cs b/src/Lidarr.Api.V3/DownloadClient/DownloadClientResource.cs
new file mode 100644
index 000000000..6205f8813
--- /dev/null
+++ b/src/Lidarr.Api.V3/DownloadClient/DownloadClientResource.cs
@@ -0,0 +1,38 @@
+using NzbDrone.Core.Download;
+using NzbDrone.Core.Indexers;
+
+namespace Lidarr.Api.V3.DownloadClient
+{
+ public class DownloadClientResource : ProviderResource
+ {
+ public bool Enable { get; set; }
+ public DownloadProtocol Protocol { get; set; }
+ }
+
+ public class DownloadClientResourceMapper : ProviderResourceMapper
+ {
+ public override DownloadClientResource ToResource(DownloadClientDefinition definition)
+ {
+ if (definition == null) return null;
+
+ var resource = base.ToResource(definition);
+
+ resource.Enable = definition.Enable;
+ resource.Protocol = definition.Protocol;
+
+ return resource;
+ }
+
+ public override DownloadClientDefinition ToModel(DownloadClientResource resource)
+ {
+ if (resource == null) return null;
+
+ var definition = base.ToModel(resource);
+
+ definition.Enable = resource.Enable;
+ definition.Protocol = resource.Protocol;
+
+ return definition;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/FileSystem/FileSystemModule.cs b/src/Lidarr.Api.V3/FileSystem/FileSystemModule.cs
similarity index 84%
rename from src/NzbDrone.Api/FileSystem/FileSystemModule.cs
rename to src/Lidarr.Api.V3/FileSystem/FileSystemModule.cs
index 392c3c0c5..f6a9bb24a 100644
--- a/src/NzbDrone.Api/FileSystem/FileSystemModule.cs
+++ b/src/Lidarr.Api.V3/FileSystem/FileSystemModule.cs
@@ -1,15 +1,15 @@
-using System;
+using System;
using System.IO;
using System.Linq;
using Nancy;
-using NzbDrone.Api.Extensions;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.MediaFiles;
+using Lidarr.Http.Extensions;
-namespace NzbDrone.Api.FileSystem
+namespace Lidarr.Api.V3.FileSystem
{
- public class FileSystemModule : NzbDroneApiModule
+ public class FileSystemModule : SonarrV3Module
{
private readonly IFileSystemLookupService _fileSystemLookupService;
private readonly IDiskProvider _diskProvider;
@@ -31,13 +31,8 @@ namespace NzbDrone.Api.FileSystem
private Response GetContents()
{
var pathQuery = Request.Query.path;
- var includeFilesQuery = Request.Query.includeFiles;
- bool includeFiles = false;
+ var includeFiles = Request.GetBooleanQueryParameter("includeFiles");
- if (includeFilesQuery.HasValue)
- {
- includeFiles = Convert.ToBoolean(includeFilesQuery.Value);
- }
return _fileSystemLookupService.LookupContents((string)pathQuery.Value, includeFiles).AsResponse();
}
@@ -73,4 +68,4 @@ namespace NzbDrone.Api.FileSystem
}).AsResponse();
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Health/HealthModule.cs b/src/Lidarr.Api.V3/Health/HealthModule.cs
similarity index 85%
rename from src/NzbDrone.Api/Health/HealthModule.cs
rename to src/Lidarr.Api.V3/Health/HealthModule.cs
index 2699fa7d6..d299b40f7 100644
--- a/src/NzbDrone.Api/Health/HealthModule.cs
+++ b/src/Lidarr.Api.V3/Health/HealthModule.cs
@@ -3,10 +3,11 @@ using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.HealthCheck;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.SignalR;
+using Lidarr.Http;
-namespace NzbDrone.Api.Health
+namespace Lidarr.Api.V3.Health
{
- public class HealthModule : NzbDroneRestModuleWithSignalR,
+ public class HealthModule : LidarrRestModuleWithSignalR,
IHandle
{
private readonly IHealthCheckService _healthCheckService;
diff --git a/src/NzbDrone.Api/Health/HealthResource.cs b/src/Lidarr.Api.V3/Health/HealthResource.cs
similarity index 86%
rename from src/NzbDrone.Api/Health/HealthResource.cs
rename to src/Lidarr.Api.V3/Health/HealthResource.cs
index e860cb778..feebcb230 100644
--- a/src/NzbDrone.Api/Health/HealthResource.cs
+++ b/src/Lidarr.Api.V3/Health/HealthResource.cs
@@ -1,13 +1,14 @@
using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
using NzbDrone.Common.Http;
using NzbDrone.Core.HealthCheck;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Health
+namespace Lidarr.Api.V3.Health
{
public class HealthResource : RestResource
{
+ public string Source { get; set; }
public HealthCheckResult Type { get; set; }
public string Message { get; set; }
public HttpUri WikiUrl { get; set; }
@@ -22,7 +23,7 @@ namespace NzbDrone.Api.Health
return new HealthResource
{
Id = model.Id,
-
+ Source = model.Source.Name,
Type = model.Type,
Message = model.Message,
WikiUrl = model.WikiUrl
diff --git a/src/Lidarr.Api.V3/History/HistoryModule.cs b/src/Lidarr.Api.V3/History/HistoryModule.cs
new file mode 100644
index 000000000..9b1c73799
--- /dev/null
+++ b/src/Lidarr.Api.V3/History/HistoryModule.cs
@@ -0,0 +1,91 @@
+using System;
+using Nancy;
+using NzbDrone.Core.Datastore;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Download;
+using NzbDrone.Core.History;
+using Lidarr.Api.V3.Albums;
+using Lidarr.Api.V3.Artist;
+using Lidarr.Api.V3.Tracks;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3.History
+{
+ public class HistoryModule : LidarrRestModule
+ {
+ private readonly IHistoryService _historyService;
+ private readonly IUpgradableSpecification _upgradableSpecification;
+ private readonly IFailedDownloadService _failedDownloadService;
+
+ public HistoryModule(IHistoryService historyService,
+ IUpgradableSpecification upgradableSpecification,
+ IFailedDownloadService failedDownloadService)
+ {
+ _historyService = historyService;
+ _upgradableSpecification = upgradableSpecification;
+ _failedDownloadService = failedDownloadService;
+ GetResourcePaged = GetHistory;
+
+ Post["/failed"] = x => MarkAsFailed();
+ }
+
+ protected HistoryResource MapToResource(NzbDrone.Core.History.History model, bool includeArtist, bool includeAlbum, bool includeTrack)
+ {
+ var resource = model.ToResource();
+
+ if (includeArtist)
+ {
+ resource.Artist = model.Artist.ToResource();
+ }
+ if (includeAlbum)
+ {
+ resource.Album = model.Album.ToResource();
+ }
+ if (includeTrack)
+ {
+ resource.Track = model.Track.ToResource();
+ }
+
+
+ if (model.Artist != null)
+ {
+ resource.QualityCutoffNotMet = _upgradableSpecification.CutoffNotMet(model.Artist.Profile.Value,
+ model.Artist.LanguageProfile,
+ model.Quality,
+ model.Language);
+ }
+
+ return resource;
+ }
+
+ private PagingResource GetHistory(PagingResource pagingResource)
+ {
+ var pagingSpec = pagingResource.MapToPagingSpec("date", SortDirection.Descending);
+ var includeArtist = Request.GetBooleanQueryParameter("includeArtist");
+ var includeAlbum = Request.GetBooleanQueryParameter("includeAlbum");
+ var includeTrack = Request.GetBooleanQueryParameter("includeTrack");
+
+ if (pagingResource.FilterKey == "eventType")
+ {
+ var filterValue = (HistoryEventType)Convert.ToInt32(pagingResource.FilterValue);
+ pagingSpec.FilterExpression = v => v.EventType == filterValue;
+ }
+
+ if (pagingResource.FilterKey == "albumId")
+ {
+ int albumId = Convert.ToInt32(pagingResource.FilterValue);
+ pagingSpec.FilterExpression = h => h.AlbumId == albumId;
+ }
+
+ return ApplyToPage(_historyService.Paged, pagingSpec, h => MapToResource(h, includeArtist, includeAlbum, includeTrack));
+ }
+
+ private Response MarkAsFailed()
+ {
+ var id = (int)Request.Form.Id;
+ _failedDownloadService.MarkAsFailed(id);
+ return new object().AsResponse();
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/History/HistoryResource.cs b/src/Lidarr.Api.V3/History/HistoryResource.cs
new file mode 100644
index 000000000..86d8a9964
--- /dev/null
+++ b/src/Lidarr.Api.V3/History/HistoryResource.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+using NzbDrone.Core.History;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Qualities;
+using Lidarr.Api.V3.Albums;
+using Lidarr.Api.V3.Artist;
+using Lidarr.Api.V3.Tracks;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.History
+{
+ public class HistoryResource : RestResource
+ {
+ public int AlbumId { get; set; }
+ public int ArtistId { get; set; }
+ public int TrackId { get; set; }
+ public string SourceTitle { get; set; }
+ public Language Language { get; set; }
+ public QualityModel Quality { get; set; }
+ public bool QualityCutoffNotMet { get; set; }
+ public DateTime Date { get; set; }
+ public string DownloadId { get; set; }
+
+ public HistoryEventType EventType { get; set; }
+
+ public Dictionary Data { get; set; }
+
+ public AlbumResource Album { get; set; }
+ public ArtistResource Artist { get; set; }
+ public TrackResource Track { get; set; }
+ }
+
+ public static class HistoryResourceMapper
+ {
+ public static HistoryResource ToResource(this NzbDrone.Core.History.History model)
+ {
+ if (model == null) return null;
+
+ return new HistoryResource
+ {
+ Id = model.Id,
+
+ AlbumId = model.AlbumId,
+ ArtistId = model.ArtistId,
+ TrackId = model.TrackId,
+ SourceTitle = model.SourceTitle,
+ Language = model.Language,
+ Quality = model.Quality,
+ //QualityCutoffNotMet
+ Date = model.Date,
+ DownloadId = model.DownloadId,
+
+ EventType = model.EventType,
+
+ Data = model.Data
+ //Episode
+ //Series
+ };
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Indexers/IndexerModule.cs b/src/Lidarr.Api.V3/Indexers/IndexerModule.cs
new file mode 100644
index 000000000..c3451b61b
--- /dev/null
+++ b/src/Lidarr.Api.V3/Indexers/IndexerModule.cs
@@ -0,0 +1,20 @@
+using NzbDrone.Core.Indexers;
+
+namespace Lidarr.Api.V3.Indexers
+{
+ public class IndexerModule : ProviderModuleBase
+ {
+ public static readonly IndexerResourceMapper ResourceMapper = new IndexerResourceMapper();
+
+ public IndexerModule(IndexerFactory indexerFactory)
+ : base(indexerFactory, "indexer", ResourceMapper)
+ {
+ }
+
+ protected override void Validate(IndexerDefinition definition, bool includeWarnings)
+ {
+ if (!definition.Enable) return;
+ base.Validate(definition, includeWarnings);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Indexers/IndexerResource.cs b/src/Lidarr.Api.V3/Indexers/IndexerResource.cs
new file mode 100644
index 000000000..cfe23ea61
--- /dev/null
+++ b/src/Lidarr.Api.V3/Indexers/IndexerResource.cs
@@ -0,0 +1,43 @@
+using NzbDrone.Core.Indexers;
+
+namespace Lidarr.Api.V3.Indexers
+{
+ public class IndexerResource : ProviderResource
+ {
+ public bool EnableRss { get; set; }
+ public bool EnableSearch { get; set; }
+ public bool SupportsRss { get; set; }
+ public bool SupportsSearch { get; set; }
+ public DownloadProtocol Protocol { get; set; }
+ }
+
+ public class IndexerResourceMapper : ProviderResourceMapper
+ {
+ public override IndexerResource ToResource(IndexerDefinition definition)
+ {
+ if (definition == null) return null;
+
+ var resource = base.ToResource(definition);
+
+ resource.EnableRss = definition.EnableRss;
+ resource.EnableSearch = definition.EnableSearch;
+ resource.SupportsRss = definition.SupportsRss;
+ resource.SupportsSearch = definition.SupportsSearch;
+ resource.Protocol = definition.Protocol;
+
+ return resource;
+ }
+
+ public override IndexerDefinition ToModel(IndexerResource resource)
+ {
+ if (resource == null) return null;
+
+ var definition = base.ToModel(resource);
+
+ definition.EnableRss = resource.EnableRss;
+ definition.EnableSearch = resource.EnableSearch;
+
+ return definition;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Indexers/ReleaseModule.cs b/src/Lidarr.Api.V3/Indexers/ReleaseModule.cs
similarity index 82%
rename from src/NzbDrone.Api/Indexers/ReleaseModule.cs
rename to src/Lidarr.Api.V3/Indexers/ReleaseModule.cs
index 858263b4e..07d7a6de6 100644
--- a/src/NzbDrone.Api/Indexers/ReleaseModule.cs
+++ b/src/Lidarr.Api.V3/Indexers/ReleaseModule.cs
@@ -1,20 +1,20 @@
-using System;
+using System;
using System.Collections.Generic;
using FluentValidation;
using Nancy;
+using Nancy.ModelBinding;
using NLog;
+using NzbDrone.Common.Cache;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Download;
using NzbDrone.Core.Exceptions;
-using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.Indexers;
+using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.Parser.Model;
-using Nancy.ModelBinding;
-using NzbDrone.Api.Extensions;
-using NzbDrone.Common.Cache;
+using Lidarr.Http.Extensions;
using HttpStatusCode = System.Net.HttpStatusCode;
-namespace NzbDrone.Api.Indexers
+namespace Lidarr.Api.V3.Indexers
{
public class ReleaseModule : ReleaseModuleBase
{
@@ -53,13 +53,13 @@ namespace NzbDrone.Api.Indexers
private Response DownloadRelease(ReleaseResource release)
{
- var remoteAlbum = _remoteAlbumCache.Find(release.Guid);
+ var remoteAlbum = _remoteAlbumCache.Find(GetCacheKey(release));
if (remoteAlbum == null)
{
_logger.Debug("Couldn't find requested release in cache, cache timeout probably expired.");
- return new NotFoundResponse();
+ throw new NzbDroneClientException(HttpStatusCode.NotFound, "Couldn't find requested release in cache, try searching again");
}
try
@@ -68,7 +68,7 @@ namespace NzbDrone.Api.Indexers
}
catch (ReleaseDownloadException ex)
{
- _logger.Error(ex);
+ _logger.Error(ex, ex.Message);
throw new NzbDroneClientException(HttpStatusCode.Conflict, "Getting release from indexer failed");
}
@@ -77,7 +77,7 @@ namespace NzbDrone.Api.Indexers
private List GetReleases()
{
- if (Request.Query.albumId != null)
+ if (Request.Query.albumId.HasValue)
{
return GetAlbumReleases(Request.Query.albumId);
}
@@ -96,7 +96,7 @@ namespace NzbDrone.Api.Indexers
}
catch (Exception ex)
{
- _logger.Error(ex, "Album search failed");
+ _logger.Error(ex, "Album search failed: " + ex.Message);
}
return new List();
@@ -113,8 +113,14 @@ namespace NzbDrone.Api.Indexers
protected override ReleaseResource MapDecision(DownloadDecision decision, int initialWeight)
{
- _remoteAlbumCache.Set(decision.RemoteAlbum.Release.Guid, decision.RemoteAlbum, TimeSpan.FromMinutes(30));
- return base.MapDecision(decision, initialWeight);
+ var resource = base.MapDecision(decision, initialWeight);
+ _remoteAlbumCache.Set(GetCacheKey(resource), decision.RemoteAlbum, TimeSpan.FromMinutes(30));
+ return resource;
+ }
+
+ private string GetCacheKey(ReleaseResource resource)
+ {
+ return string.Concat(resource.IndexerId, "_", resource.Guid);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/NzbDrone.Api/Indexers/ReleaseModuleBase.cs b/src/Lidarr.Api.V3/Indexers/ReleaseModuleBase.cs
similarity index 87%
rename from src/NzbDrone.Api/Indexers/ReleaseModuleBase.cs
rename to src/Lidarr.Api.V3/Indexers/ReleaseModuleBase.cs
index 32344ef34..5d0e79ec5 100644
--- a/src/NzbDrone.Api/Indexers/ReleaseModuleBase.cs
+++ b/src/Lidarr.Api.V3/Indexers/ReleaseModuleBase.cs
@@ -1,9 +1,10 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using NzbDrone.Core.DecisionEngine;
+using Lidarr.Http;
-namespace NzbDrone.Api.Indexers
+namespace Lidarr.Api.V3.Indexers
{
- public abstract class ReleaseModuleBase : NzbDroneRestModule
+ public abstract class ReleaseModuleBase : LidarrRestModule
{
protected virtual List MapDecisions(IEnumerable decisions)
{
diff --git a/src/NzbDrone.Api/Indexers/ReleasePushModule.cs b/src/Lidarr.Api.V3/Indexers/ReleasePushModule.cs
similarity index 89%
rename from src/NzbDrone.Api/Indexers/ReleasePushModule.cs
rename to src/Lidarr.Api.V3/Indexers/ReleasePushModule.cs
index c25e45726..817602f12 100644
--- a/src/NzbDrone.Api/Indexers/ReleasePushModule.cs
+++ b/src/Lidarr.Api.V3/Indexers/ReleasePushModule.cs
@@ -1,15 +1,15 @@
-using Nancy;
-using Nancy.ModelBinding;
+using System.Collections.Generic;
+using System.Linq;
using FluentValidation;
+using Nancy;
+using Nancy.ModelBinding;
+using NLog;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Download;
-using System.Collections.Generic;
-using System.Linq;
using NzbDrone.Core.Parser.Model;
-using NzbDrone.Api.Extensions;
-using NLog;
+using Lidarr.Http.Extensions;
-namespace NzbDrone.Api.Indexers
+namespace Lidarr.Api.V3.Indexers
{
class ReleasePushModule : ReleaseModuleBase
{
@@ -29,7 +29,7 @@ namespace NzbDrone.Api.Indexers
PostValidator.RuleFor(s => s.Title).NotEmpty();
PostValidator.RuleFor(s => s.DownloadUrl).NotEmpty();
- PostValidator.RuleFor(s => s.Protocol).NotEmpty();
+ PostValidator.RuleFor(s => s.DownloadProtocol).NotEmpty();
PostValidator.RuleFor(s => s.PublishDate).NotEmpty();
}
diff --git a/src/NzbDrone.Api/Indexers/ReleaseResource.cs b/src/Lidarr.Api.V3/Indexers/ReleaseResource.cs
similarity index 83%
rename from src/NzbDrone.Api/Indexers/ReleaseResource.cs
rename to src/Lidarr.Api.V3/Indexers/ReleaseResource.cs
index a8f8cdfab..eb44fdaf6 100644
--- a/src/NzbDrone.Api/Indexers/ReleaseResource.cs
+++ b/src/Lidarr.Api.V3/Indexers/ReleaseResource.cs
@@ -1,15 +1,14 @@
-using System;
+using System;
using System.Collections.Generic;
-using Newtonsoft.Json;
-using NzbDrone.Api.REST;
-using NzbDrone.Core.Parser;
-using NzbDrone.Core.Qualities;
+using System.Linq;
+using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.Indexers;
+using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model;
-using NzbDrone.Core.DecisionEngine;
-using System.Linq;
+using NzbDrone.Core.Qualities;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Indexers
+namespace Lidarr.Api.V3.Indexers
{
public class ReleaseResource : RestResource
{
@@ -23,10 +22,13 @@ namespace NzbDrone.Api.Indexers
public int IndexerId { get; set; }
public string Indexer { get; set; }
public string ReleaseGroup { get; set; }
+ public string SubGroup { get; set; }
public string ReleaseHash { get; set; }
public string Title { get; set; }
+ public bool FullSeason { get; set; }
+ public bool SceneSource { get; set; }
public Language Language { get; set; }
- public string ReleaseDate { get; set; }
+ public string AirDate { get; set; }
public string ArtistName { get; set; }
public string AlbumTitle { get; set; }
public bool Approved { get; set; }
@@ -40,37 +42,19 @@ namespace NzbDrone.Api.Indexers
public bool DownloadAllowed { get; set; }
public int ReleaseWeight { get; set; }
-
public string MagnetUrl { get; set; }
public string InfoHash { get; set; }
public int? Seeders { get; set; }
public int? Leechers { get; set; }
public DownloadProtocol Protocol { get; set; }
+ //TODO: besides a test I don't think this is used...
+ public DownloadProtocol DownloadProtocol { get; set; }
- // TODO: Remove in v3
- // Used to support the original Release Push implementation
- // JsonIgnore so we don't serialize it, but can still parse it
- [JsonIgnore]
- public DownloadProtocol DownloadProtocol
- {
- get
- {
- return Protocol;
- }
- set
- {
- if (value > 0 && Protocol == 0)
- {
- Protocol = value;
- }
- }
- }
-
- public bool IsDaily { get; set; }
- public bool IsAbsoluteNumbering { get; set; }
- public bool IsPossibleSpecialEpisode { get; set; }
- public bool Special { get; set; }
+ //public bool IsDaily { get; set; }
+ //public bool IsAbsoluteNumbering { get; set; }
+ //public bool IsPossibleSpecialEpisode { get; set; }
+ //public bool Special { get; set; }
}
public static class ReleaseResourceMapper
@@ -98,9 +82,9 @@ namespace NzbDrone.Api.Indexers
ReleaseHash = parsedAlbumInfo.ReleaseHash,
Title = releaseInfo.Title,
Language = parsedAlbumInfo.Language,
- ReleaseDate = parsedAlbumInfo.ReleaseDate,
ArtistName = parsedAlbumInfo.ArtistName,
AlbumTitle = parsedAlbumInfo.AlbumTitle,
+
Approved = model.Approved,
TemporarilyRejected = model.TemporarilyRejected,
Rejected = model.Rejected,
@@ -112,11 +96,17 @@ namespace NzbDrone.Api.Indexers
DownloadAllowed = remoteAlbum.DownloadAllowed,
//ReleaseWeight
+
MagnetUrl = torrentInfo.MagnetUrl,
InfoHash = torrentInfo.InfoHash,
Seeders = torrentInfo.Seeders,
Leechers = (torrentInfo.Peers.HasValue && torrentInfo.Seeders.HasValue) ? (torrentInfo.Peers.Value - torrentInfo.Seeders.Value) : (int?)null,
Protocol = releaseInfo.DownloadProtocol,
+
+ //IsDaily = parsedEpisodeInfo.IsDaily,
+ //IsAbsoluteNumbering = parsedEpisodeInfo.IsAbsoluteNumbering,
+ //IsPossibleSpecialEpisode = parsedEpisodeInfo.IsPossibleSpecialEpisode,
+ //Special = parsedEpisodeInfo.Special,
};
}
@@ -149,9 +139,9 @@ namespace NzbDrone.Api.Indexers
model.IndexerId = resource.IndexerId;
model.Indexer = resource.Indexer;
model.DownloadProtocol = resource.DownloadProtocol;
- model.PublishDate = resource.PublishDate;
+ model.PublishDate = resource.PublishDate.ToUniversalTime();
return model;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/Lidarr.Api.V3/Lidarr.Api.V3.csproj b/src/Lidarr.Api.V3/Lidarr.Api.V3.csproj
new file mode 100644
index 000000000..6e3aed69d
--- /dev/null
+++ b/src/Lidarr.Api.V3/Lidarr.Api.V3.csproj
@@ -0,0 +1,245 @@
+
+
+
+
+ Debug
+ x86
+ {7140FF1F-79BE-492F-9188-B21A050BF708}
+ Library
+ Properties
+ Lidarr.Api.V3
+ Lidarr.Api.V3
+ v4.6.1
+ 512
+ ..\
+ true
+
+
+ 12.0.0
+ 2.0
+
+
+ true
+ ..\..\_output\
+ DEBUG;TRACE
+ full
+ x86
+ prompt
+ MinimumRecommendedRules.ruleset
+ 4
+ false
+ false
+
+
+ ..\..\_output\
+ TRACE
+ true
+ pdbonly
+ x86
+ prompt
+ MinimumRecommendedRules.ruleset
+ 4
+ false
+
+
+
+ ..\packages\Ical.Net.2.2.32\lib\net40\antlr.runtime.dll
+
+
+ ..\packages\FluentValidation.6.2.1.0\lib\portable-net40+sl50+wp80+win8+wpa81\FluentValidation.dll
+ True
+
+
+ ..\packages\Ical.Net.2.2.32\lib\net40\Ical.Net.dll
+
+
+ ..\packages\Ical.Net.2.2.32\lib\net40\Ical.Net.Collections.dll
+
+
+ ..\packages\Nancy.1.4.4\lib\net40\Nancy.dll
+
+
+ ..\packages\Nancy.Authentication.Basic.1.4.1\lib\net40\Nancy.Authentication.Basic.dll
+ True
+
+
+ ..\packages\Nancy.Authentication.Forms.1.4.1\lib\net40\Nancy.Authentication.Forms.dll
+ True
+
+
+ ..\packages\Newtonsoft.Json.9.0.1\lib\net40\Newtonsoft.Json.dll
+ True
+
+
+ ..\packages\NLog.4.4.12\lib\net45\NLog.dll
+
+
+ ..\packages\Ical.Net.2.2.32\lib\net40\NodaTime.dll
+
+
+
+
+
+
+
+ Properties\SharedAssemblyInfo.cs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Designer
+
+
+
+
+ {F6FC6BE7-0847-4817-A1ED-223DC647C3D7}
+ Marr.Data
+
+
+ {5370BFF7-1BD7-46BC-AF06-7D9EA5CDA1D6}
+ Lidarr.Http
+
+
+ {f2be0fdf-6e47-4827-a420-dd4ef82407f8}
+ NzbDrone.Common
+
+
+ {ff5ee3b6-913b-47ce-9ceb-11c51b4e1205}
+ NzbDrone.Core
+
+
+ {7c2cc69f-5ca0-4e5c-85cb-983f9f6c3b36}
+ NzbDrone.SignalR
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Logs/LogFileModule.cs b/src/Lidarr.Api.V3/Logs/LogFileModule.cs
similarity index 85%
rename from src/NzbDrone.Api/Logs/LogFileModule.cs
rename to src/Lidarr.Api.V3/Logs/LogFileModule.cs
index bed6f7de2..19676d523 100644
--- a/src/NzbDrone.Api/Logs/LogFileModule.cs
+++ b/src/Lidarr.Api.V3/Logs/LogFileModule.cs
@@ -5,7 +5,7 @@ using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
-namespace NzbDrone.Api.Logs
+namespace Lidarr.Api.V3.Logs
{
public class LogFileModule : LogFileModuleBase
{
@@ -31,6 +31,13 @@ namespace NzbDrone.Api.Logs
return Path.Combine(_appFolderInfo.GetLogFolder(), filename);
}
- protected override string DownloadUrlRoot => "logfile";
+ protected override string DownloadUrlRoot
+ {
+ get
+ {
+ return "logfile";
+ }
+ }
+
}
}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Logs/LogFileModuleBase.cs b/src/Lidarr.Api.V3/Logs/LogFileModuleBase.cs
similarity index 89%
rename from src/NzbDrone.Api/Logs/LogFileModuleBase.cs
rename to src/Lidarr.Api.V3/Logs/LogFileModuleBase.cs
index d8a12d1bf..47a8eab6d 100644
--- a/src/NzbDrone.Api/Logs/LogFileModuleBase.cs
+++ b/src/Lidarr.Api.V3/Logs/LogFileModuleBase.cs
@@ -1,14 +1,15 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
-using NzbDrone.Common.Disk;
using Nancy;
using Nancy.Responses;
+using NzbDrone.Common.Disk;
using NzbDrone.Core.Configuration;
+using Lidarr.Http;
-namespace NzbDrone.Api.Logs
+namespace Lidarr.Api.V3.Logs
{
- public abstract class LogFileModuleBase : NzbDroneRestModule
+ public abstract class LogFileModuleBase : LidarrRestModule
{
protected const string LOGFILE_ROUTE = @"/(?[-.a-zA-Z0-9]+?\.txt)";
@@ -43,7 +44,7 @@ namespace NzbDrone.Api.Logs
Id = i + 1,
Filename = filename,
LastWriteTime = _diskProvider.FileGetLastWrite(file),
- ContentsUrl = string.Format("{0}/api/{1}/{2}", _configFileProvider.UrlBase, Resource, filename),
+ ContentsUrl = string.Format("{0}/api/v3/{1}/{2}", _configFileProvider.UrlBase, Resource, filename),
DownloadUrl = string.Format("{0}/{1}/{2}", _configFileProvider.UrlBase, DownloadUrlRoot, filename)
});
}
diff --git a/src/NzbDrone.Api/Logs/LogFileResource.cs b/src/Lidarr.Api.V3/Logs/LogFileResource.cs
similarity index 83%
rename from src/NzbDrone.Api/Logs/LogFileResource.cs
rename to src/Lidarr.Api.V3/Logs/LogFileResource.cs
index 9f67c8af7..2bfb7da7c 100644
--- a/src/NzbDrone.Api/Logs/LogFileResource.cs
+++ b/src/Lidarr.Api.V3/Logs/LogFileResource.cs
@@ -1,7 +1,7 @@
using System;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Logs
+namespace Lidarr.Api.V3.Logs
{
public class LogFileResource : RestResource
{
diff --git a/src/NzbDrone.Api/Logs/LogModule.cs b/src/Lidarr.Api.V3/Logs/LogModule.cs
similarity index 84%
rename from src/NzbDrone.Api/Logs/LogModule.cs
rename to src/Lidarr.Api.V3/Logs/LogModule.cs
index 88ead3ec0..a1b9b79e3 100644
--- a/src/NzbDrone.Api/Logs/LogModule.cs
+++ b/src/Lidarr.Api.V3/Logs/LogModule.cs
@@ -1,8 +1,9 @@
using NzbDrone.Core.Instrumentation;
+using Lidarr.Http;
-namespace NzbDrone.Api.Logs
+namespace Lidarr.Api.V3.Logs
{
- public class LogModule : NzbDroneRestModule
+ public class LogModule : LidarrRestModule
{
private readonly ILogService _logService;
@@ -46,7 +47,14 @@ namespace NzbDrone.Api.Logs
}
}
- return ApplyToPage(_logService.Paged, pageSpec, LogResourceMapper.ToResource);
+ var response = ApplyToPage(_logService.Paged, pageSpec, LogResourceMapper.ToResource);
+
+ if (pageSpec.SortKey == "id")
+ {
+ response.SortKey = "time";
+ }
+
+ return response;
}
}
}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Logs/LogResource.cs b/src/Lidarr.Api.V3/Logs/LogResource.cs
similarity index 80%
rename from src/NzbDrone.Api/Logs/LogResource.cs
rename to src/Lidarr.Api.V3/Logs/LogResource.cs
index 504a45839..e3eef3836 100644
--- a/src/NzbDrone.Api/Logs/LogResource.cs
+++ b/src/Lidarr.Api.V3/Logs/LogResource.cs
@@ -1,7 +1,8 @@
using System;
-using NzbDrone.Api.REST;
+using NzbDrone.Core.Instrumentation;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Logs
+namespace Lidarr.Api.V3.Logs
{
public class LogResource : RestResource
{
@@ -11,11 +12,12 @@ namespace NzbDrone.Api.Logs
public string Level { get; set; }
public string Logger { get; set; }
public string Message { get; set; }
+ public string Method { get; set; }
}
public static class LogResourceMapper
{
- public static LogResource ToResource(this Core.Instrumentation.Log model)
+ public static LogResource ToResource(this Log model)
{
if (model == null) return null;
diff --git a/src/NzbDrone.Api/Logs/UpdateLogFileModule.cs b/src/Lidarr.Api.V3/Logs/UpdateLogFileModule.cs
similarity index 88%
rename from src/NzbDrone.Api/Logs/UpdateLogFileModule.cs
rename to src/Lidarr.Api.V3/Logs/UpdateLogFileModule.cs
index 5c4f81f02..f9ac8311c 100644
--- a/src/NzbDrone.Api/Logs/UpdateLogFileModule.cs
+++ b/src/Lidarr.Api.V3/Logs/UpdateLogFileModule.cs
@@ -7,7 +7,7 @@ using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
-namespace NzbDrone.Api.Logs
+namespace Lidarr.Api.V3.Logs
{
public class UpdateLogFileModule : LogFileModuleBase
{
@@ -37,6 +37,12 @@ namespace NzbDrone.Api.Logs
return Path.Combine(_appFolderInfo.GetUpdateLogFolder(), filename);
}
- protected override string DownloadUrlRoot => "updatelogfile";
+ protected override string DownloadUrlRoot
+ {
+ get
+ {
+ return "updatelogfile";
+ }
+ }
}
}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/ManualImport/ManualImportModule.cs b/src/Lidarr.Api.V3/ManualImport/ManualImportModule.cs
similarity index 76%
rename from src/NzbDrone.Api/ManualImport/ManualImportModule.cs
rename to src/Lidarr.Api.V3/ManualImport/ManualImportModule.cs
index bcd99de1b..11a3e4924 100644
--- a/src/NzbDrone.Api/ManualImport/ManualImportModule.cs
+++ b/src/Lidarr.Api.V3/ManualImport/ManualImportModule.cs
@@ -2,10 +2,11 @@ using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.MediaFiles.TrackImport.Manual;
using NzbDrone.Core.Qualities;
+using Lidarr.Http;
-namespace NzbDrone.Api.ManualImport
+namespace Lidarr.Api.V3.ManualImport
{
- public class ManualImportModule : NzbDroneRestModule
+ public class ManualImportModule : LidarrRestModule
{
private readonly IManualImportService _manualImportService;
@@ -19,11 +20,8 @@ namespace NzbDrone.Api.ManualImport
private List GetMediaFiles()
{
- var folderQuery = Request.Query.folder;
- var folder = (string)folderQuery.Value;
-
- var downloadIdQuery = Request.Query.downloadId;
- var downloadId = (string)downloadIdQuery.Value;
+ var folder = (string)Request.Query.folder;
+ var downloadId = (string)Request.Query.downloadId;
return _manualImportService.GetMediaFiles(folder, downloadId).ToResource().Select(AddQualityWeight).ToList();
}
diff --git a/src/Lidarr.Api.V3/ManualImport/ManualImportResource.cs b/src/Lidarr.Api.V3/ManualImport/ManualImportResource.cs
new file mode 100644
index 000000000..40d77cadb
--- /dev/null
+++ b/src/Lidarr.Api.V3/ManualImport/ManualImportResource.cs
@@ -0,0 +1,60 @@
+using NzbDrone.Common.Crypto;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.MediaFiles.TrackImport.Manual;
+using NzbDrone.Core.Qualities;
+using NzbDrone.Core.Languages;
+using Lidarr.Api.V3.Artist;
+using Lidarr.Api.V3.Albums;
+using Lidarr.Api.V3.Tracks;
+using Lidarr.Http.REST;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Lidarr.Api.V3.ManualImport
+{
+ public class ManualImportResource : RestResource
+ {
+ public string Path { get; set; }
+ public string RelativePath { get; set; }
+ public string Name { get; set; }
+ public long Size { get; set; }
+ public ArtistResource Artist { get; set; }
+ public AlbumResource Album { get; set; }
+ public List Tracks { get; set; }
+ public QualityModel Quality { get; set; }
+ public Language Language { get; set; }
+ public int QualityWeight { get; set; }
+ public string DownloadId { get; set; }
+ public IEnumerable Rejections { get; set; }
+ }
+
+ public static class ManualImportResourceMapper
+ {
+ public static ManualImportResource ToResource(this ManualImportItem model)
+ {
+ if (model == null) return null;
+
+ return new ManualImportResource
+ {
+ Id = HashConverter.GetHashInt31(model.Path),
+ Path = model.Path,
+ RelativePath = model.RelativePath,
+ Name = model.Name,
+ Size = model.Size,
+ Artist = model.Artist.ToResource(),
+ Album = model.Album.ToResource(),
+ Tracks = model.Tracks.ToResource(),
+ Quality = model.Quality,
+ Language = model.Language,
+ //QualityWeight
+ DownloadId = model.DownloadId,
+ Rejections = model.Rejections
+ };
+ }
+
+ public static List ToResource(this IEnumerable models)
+ {
+ return models.Select(ToResource).ToList();
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/MediaCovers/MediaCoverModule.cs b/src/Lidarr.Api.V3/MediaCovers/MediaCoverModule.cs
similarity index 95%
rename from src/NzbDrone.Api/MediaCovers/MediaCoverModule.cs
rename to src/Lidarr.Api.V3/MediaCovers/MediaCoverModule.cs
index a4ad78ef4..922cf54a4 100644
--- a/src/NzbDrone.Api/MediaCovers/MediaCoverModule.cs
+++ b/src/Lidarr.Api.V3/MediaCovers/MediaCoverModule.cs
@@ -6,9 +6,9 @@ using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
-namespace NzbDrone.Api.MediaCovers
+namespace Lidarr.Api.V3.MediaCovers
{
- public class MediaCoverModule : NzbDroneApiModule
+ public class MediaCoverModule : SonarrV3Module
{
private static readonly Regex RegexResizedImage = new Regex(@"-\d+\.jpg$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
diff --git a/src/Lidarr.Api.V3/Metadata/MetadataModule.cs b/src/Lidarr.Api.V3/Metadata/MetadataModule.cs
new file mode 100644
index 000000000..c8de3fc02
--- /dev/null
+++ b/src/Lidarr.Api.V3/Metadata/MetadataModule.cs
@@ -0,0 +1,20 @@
+using NzbDrone.Core.Extras.Metadata;
+
+namespace Lidarr.Api.V3.Metadata
+{
+ public class MetadataModule : ProviderModuleBase
+ {
+ public static readonly MetadataResourceMapper ResourceMapper = new MetadataResourceMapper();
+
+ public MetadataModule(IMetadataFactory metadataFactory)
+ : base(metadataFactory, "metadata", ResourceMapper)
+ {
+ }
+
+ protected override void Validate(MetadataDefinition definition, bool includeWarnings)
+ {
+ if (!definition.Enable) return;
+ base.Validate(definition, includeWarnings);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Metadata/MetadataResource.cs b/src/Lidarr.Api.V3/Metadata/MetadataResource.cs
new file mode 100644
index 000000000..3920b46f9
--- /dev/null
+++ b/src/Lidarr.Api.V3/Metadata/MetadataResource.cs
@@ -0,0 +1,34 @@
+using NzbDrone.Core.Extras.Metadata;
+
+namespace Lidarr.Api.V3.Metadata
+{
+ public class MetadataResource : ProviderResource
+ {
+ public bool Enable { get; set; }
+ }
+
+ public class MetadataResourceMapper : ProviderResourceMapper
+ {
+ public override MetadataResource ToResource(MetadataDefinition definition)
+ {
+ if (definition == null) return null;
+
+ var resource = base.ToResource(definition);
+
+ resource.Enable = definition.Enable;
+
+ return resource;
+ }
+
+ public override MetadataDefinition ToModel(MetadataResource resource)
+ {
+ if (resource == null) return null;
+
+ var definition = base.ToModel(resource);
+
+ definition.Enable = resource.Enable;
+
+ return definition;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Notifications/NotificationModule.cs b/src/Lidarr.Api.V3/Notifications/NotificationModule.cs
new file mode 100644
index 000000000..edf57eaa8
--- /dev/null
+++ b/src/Lidarr.Api.V3/Notifications/NotificationModule.cs
@@ -0,0 +1,20 @@
+using NzbDrone.Core.Notifications;
+
+namespace Lidarr.Api.V3.Notifications
+{
+ public class NotificationModule : ProviderModuleBase
+ {
+ public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper();
+
+ public NotificationModule(NotificationFactory notificationFactory)
+ : base(notificationFactory, "notification", ResourceMapper)
+ {
+ }
+
+ protected override void Validate(NotificationDefinition definition, bool includeWarnings)
+ {
+ if (!definition.OnGrab && !definition.OnDownload) return;
+ base.Validate(definition, includeWarnings);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Notifications/NotificationResource.cs b/src/Lidarr.Api.V3/Notifications/NotificationResource.cs
new file mode 100644
index 000000000..1cdb4e176
--- /dev/null
+++ b/src/Lidarr.Api.V3/Notifications/NotificationResource.cs
@@ -0,0 +1,57 @@
+using NzbDrone.Core.Notifications;
+
+namespace Lidarr.Api.V3.Notifications
+{
+ public class NotificationResource : ProviderResource
+ {
+ public string Link { get; set; }
+ public bool OnGrab { get; set; }
+ public bool OnDownload { get; set; }
+ public bool OnUpgrade { get; set; }
+ public bool OnRename { get; set; }
+ public bool SupportsOnGrab { get; set; }
+ public bool SupportsOnDownload { get; set; }
+ public bool SupportsOnUpgrade { get; set; }
+ public bool SupportsOnRename { get; set; }
+ public string TestCommand { get; set; }
+ }
+
+ public class NotificationResourceMapper : ProviderResourceMapper
+ {
+ public override NotificationResource ToResource(NotificationDefinition definition)
+ {
+ if (definition == null) return default(NotificationResource);
+
+ var resource = base.ToResource(definition);
+
+ resource.OnGrab = definition.OnGrab;
+ resource.OnDownload = definition.OnDownload;
+ resource.OnUpgrade = definition.OnUpgrade;
+ resource.OnRename = definition.OnRename;
+ resource.SupportsOnGrab = definition.SupportsOnGrab;
+ resource.SupportsOnDownload = definition.SupportsOnDownload;
+ resource.SupportsOnUpgrade = definition.SupportsOnUpgrade;
+ resource.SupportsOnRename = definition.SupportsOnRename;
+
+ return resource;
+ }
+
+ public override NotificationDefinition ToModel(NotificationResource resource)
+ {
+ if (resource == null) return default(NotificationDefinition);
+
+ var definition = base.ToModel(resource);
+
+ definition.OnGrab = resource.OnGrab;
+ definition.OnDownload = resource.OnDownload;
+ definition.OnUpgrade = resource.OnUpgrade;
+ definition.OnRename = resource.OnRename;
+ definition.SupportsOnGrab = resource.SupportsOnGrab;
+ definition.SupportsOnDownload = resource.SupportsOnDownload;
+ definition.SupportsOnUpgrade = resource.SupportsOnUpgrade;
+ definition.SupportsOnRename = resource.SupportsOnRename;
+
+ return definition;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Parse/ParseModule.cs b/src/Lidarr.Api.V3/Parse/ParseModule.cs
new file mode 100644
index 000000000..e6f3b15b6
--- /dev/null
+++ b/src/Lidarr.Api.V3/Parse/ParseModule.cs
@@ -0,0 +1,59 @@
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Parser;
+using Lidarr.Api.V3.Albums;
+using Lidarr.Api.V3.Artist;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.Parse
+{
+ public class ParseModule : LidarrRestModule
+ {
+ private readonly IParsingService _parsingService;
+
+ public ParseModule(IParsingService parsingService)
+ {
+ _parsingService = parsingService;
+
+ GetResourceSingle = Parse;
+ }
+
+ private ParseResource Parse()
+ {
+ var title = Request.Query.Title.Value as string;
+ var path = Request.Query.Path.Value as string;
+ var parsedEpisodeInfo = path.IsNotNullOrWhiteSpace() ? Parser.ParseMusicPath(path) : Parser.ParseMusicTitle(title);
+
+ if (parsedEpisodeInfo == null)
+ {
+ return null;
+ }
+
+ return new ParseResource
+ {
+ Title = title,
+ ParsedAlbumInfo = parsedEpisodeInfo
+ };
+
+ //var remoteEpisode = null //_parsingService.Map(parsedEpisodeInfo, 0, 0);
+
+ //if (remoteEpisode != null)
+ //{
+ // return new ParseResource
+ // {
+ // Title = title,
+ // ParsedAlbumInfo = remoteEpisode.ParsedEpisodeInfo,
+ // Artist = remoteEpisode.Series.ToResource(),
+ // Albums = remoteEpisode.Episodes.ToResource()
+ // };
+ //}
+ //else
+ //{
+ // return new ParseResource
+ // {
+ // Title = title,
+ // ParsedAlbumInfo = parsedEpisodeInfo
+ // };
+ //}
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Parse/ParseResource.cs b/src/Lidarr.Api.V3/Parse/ParseResource.cs
new file mode 100644
index 000000000..85a335186
--- /dev/null
+++ b/src/Lidarr.Api.V3/Parse/ParseResource.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+using NzbDrone.Core.Parser.Model;
+using Lidarr.Api.V3.Albums;
+using Lidarr.Api.V3.Artist;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Parse
+{
+ public class ParseResource : RestResource
+ {
+ public string Title { get; set; }
+ public ParsedTrackInfo ParsedAlbumInfo { get; set; }
+ public ArtistResource Artist { get; set; }
+ public List Albums { get; set; }
+ }
+}
diff --git a/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs b/src/Lidarr.Api.V3/Profiles/Delay/DelayProfileModule.cs
similarity index 76%
rename from src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs
rename to src/Lidarr.Api.V3/Profiles/Delay/DelayProfileModule.cs
index e7975b661..217a50bc6 100644
--- a/src/NzbDrone.Api/Profiles/Delay/DelayProfileModule.cs
+++ b/src/Lidarr.Api.V3/Profiles/Delay/DelayProfileModule.cs
@@ -1,13 +1,17 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using FluentValidation;
using FluentValidation.Results;
-using NzbDrone.Api.REST;
-using NzbDrone.Api.Validation;
+using Nancy;
using NzbDrone.Core.Profiles.Delay;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+using Lidarr.Http.REST;
+using Lidarr.Http.Validation;
-namespace NzbDrone.Api.Profiles.Delay
+namespace Lidarr.Api.V3.Profiles.Delay
{
- public class DelayProfileModule : NzbDroneRestModule
+ public class DelayProfileModule : LidarrRestModule
{
private readonly IDelayProfileService _delayProfileService;
@@ -20,6 +24,7 @@ namespace NzbDrone.Api.Profiles.Delay
UpdateResource = Update;
CreateResource = Create;
DeleteResource = DeleteProfile;
+ Put[@"/reorder/(?[\d]{1,10})"] = options => Reorder(options.Id);
SharedValidator.RuleFor(d => d.Tags).NotEmpty().When(d => d.Id != 1);
SharedValidator.RuleFor(d => d.Tags).EmptyCollection().When(d => d.Id == 1);
@@ -71,5 +76,15 @@ namespace NzbDrone.Api.Profiles.Delay
{
return _delayProfileService.All().ToResource();
}
+
+ private Response Reorder(int id)
+ {
+ ValidateId(id);
+
+ var afterIdQuery = Request.Query.After;
+ int? afterId = afterIdQuery.HasValue ? Convert.ToInt32(afterIdQuery.Value) : null;
+
+ return _delayProfileService.Reorder(id, afterId).ToResource().AsResponse();
+ }
}
}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs b/src/Lidarr.Api.V3/Profiles/Delay/DelayProfileResource.cs
similarity index 96%
rename from src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs
rename to src/Lidarr.Api.V3/Profiles/Delay/DelayProfileResource.cs
index e35df9043..6ec92a6cd 100644
--- a/src/NzbDrone.Api/Profiles/Delay/DelayProfileResource.cs
+++ b/src/Lidarr.Api.V3/Profiles/Delay/DelayProfileResource.cs
@@ -1,10 +1,10 @@
using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Profiles.Delay;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Profiles.Delay
+namespace Lidarr.Api.V3.Profiles.Delay
{
public class DelayProfileResource : RestResource
{
diff --git a/src/Lidarr.Api.V3/Profiles/Language/LanguageProfileModule.cs b/src/Lidarr.Api.V3/Profiles/Language/LanguageProfileModule.cs
new file mode 100644
index 000000000..458eede53
--- /dev/null
+++ b/src/Lidarr.Api.V3/Profiles/Language/LanguageProfileModule.cs
@@ -0,0 +1,57 @@
+using System.Collections.Generic;
+using FluentValidation;
+using NzbDrone.Core.Profiles.Languages;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.Profiles.Language
+{
+ public class LanguageProfileModule : LidarrRestModule
+ {
+ private readonly ILanguageProfileService _profileService;
+
+ public LanguageProfileModule(ILanguageProfileService profileService)
+ {
+ _profileService = profileService;
+ SharedValidator.RuleFor(c => c.Name).NotEmpty();
+ SharedValidator.RuleFor(c => c.Cutoff).NotNull();
+ SharedValidator.RuleFor(c => c.Languages).MustHaveAllowedLanguage();
+
+ GetResourceAll = GetAll;
+ GetResourceById = GetById;
+ UpdateResource = Update;
+ CreateResource = Create;
+ DeleteResource = DeleteProfile;
+ }
+
+ private int Create(LanguageProfileResource resource)
+ {
+ var model = resource.ToModel();
+ model = _profileService.Add(model);
+ return model.Id;
+ }
+
+ private void DeleteProfile(int id)
+ {
+ _profileService.Delete(id);
+ }
+
+ private void Update(LanguageProfileResource resource)
+ {
+ var model = resource.ToModel();
+
+ _profileService.Update(model);
+ }
+
+ private LanguageProfileResource GetById(int id)
+ {
+ return _profileService.Get(id).ToResource();
+ }
+
+ private List GetAll()
+ {
+ var profiles = _profileService.All().ToResource();
+
+ return profiles;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Profiles/Language/LanguageProfileResource.cs b/src/Lidarr.Api.V3/Profiles/Language/LanguageProfileResource.cs
new file mode 100644
index 000000000..fabfc40f7
--- /dev/null
+++ b/src/Lidarr.Api.V3/Profiles/Language/LanguageProfileResource.cs
@@ -0,0 +1,76 @@
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Core.Profiles.Languages;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Profiles.Language
+{
+ public class LanguageProfileResource : RestResource
+ {
+ public string Name { get; set; }
+ public NzbDrone.Core.Languages.Language Cutoff { get; set; }
+ public List Languages { get; set; }
+ }
+
+ public class ProfileLanguageItemResource : RestResource
+ {
+ public NzbDrone.Core.Languages.Language Language { get; set; }
+ public bool Allowed { get; set; }
+ }
+
+ public static class LanguageProfileResourceMapper
+ {
+ public static LanguageProfileResource ToResource(this LanguageProfile model)
+ {
+ if (model == null) return null;
+
+ return new LanguageProfileResource
+ {
+ Id = model.Id,
+ Name = model.Name,
+ Cutoff = model.Cutoff,
+ Languages = model.Languages.ConvertAll(ToResource)
+ };
+ }
+
+ public static ProfileLanguageItemResource ToResource(this ProfileLanguageItem model)
+ {
+ if (model == null) return null;
+
+ return new ProfileLanguageItemResource
+ {
+ Language = model.Language,
+ Allowed = model.Allowed
+ };
+ }
+
+ public static LanguageProfile ToModel(this LanguageProfileResource resource)
+ {
+ if (resource == null) return null;
+
+ return new LanguageProfile
+ {
+ Id = resource.Id,
+ Name = resource.Name,
+ Cutoff = (NzbDrone.Core.Languages.Language)resource.Cutoff.Id,
+ Languages = resource.Languages.ConvertAll(ToModel)
+ };
+ }
+
+ public static ProfileLanguageItem ToModel(this ProfileLanguageItemResource resource)
+ {
+ if (resource == null) return null;
+
+ return new ProfileLanguageItem
+ {
+ Language = (NzbDrone.Core.Languages.Language)resource.Language.Id,
+ Allowed = resource.Allowed
+ };
+ }
+
+ public static List ToResource(this IEnumerable models)
+ {
+ return models.Select(ToResource).ToList();
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Profiles/Language/LanguageProfileSchemaModule.cs b/src/Lidarr.Api.V3/Profiles/Language/LanguageProfileSchemaModule.cs
new file mode 100644
index 000000000..899658441
--- /dev/null
+++ b/src/Lidarr.Api.V3/Profiles/Language/LanguageProfileSchemaModule.cs
@@ -0,0 +1,37 @@
+using System.Linq;
+using NzbDrone.Core.Profiles.Languages;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.Profiles.Language
+{
+ public class LanguageProfileSchemaModule : LidarrRestModule
+ {
+
+ public LanguageProfileSchemaModule()
+ : base("/languageprofile/schema")
+ {
+ GetResourceSingle = GetAll;
+ }
+
+ private LanguageProfileResource GetAll()
+ {
+ var orderedLanguages = NzbDrone.Core.Languages.Language.All
+ .Where(l => l != NzbDrone.Core.Languages.Language.Unknown)
+ .OrderByDescending(l => l.Name)
+ .ToList();
+
+ orderedLanguages.Insert(0, NzbDrone.Core.Languages.Language.Unknown);
+
+ var languages = orderedLanguages.Select(v => new ProfileLanguageItem {Language = v, Allowed = false})
+ .ToList();
+
+ var profile = new LanguageProfile
+ {
+ Cutoff = NzbDrone.Core.Languages.Language.Unknown,
+ Languages = languages
+ };
+
+ return profile.ToResource();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Profiles/Language/LanguageValidator.cs b/src/Lidarr.Api.V3/Profiles/Language/LanguageValidator.cs
new file mode 100644
index 000000000..6aa5de220
--- /dev/null
+++ b/src/Lidarr.Api.V3/Profiles/Language/LanguageValidator.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using System.Linq;
+using FluentValidation;
+using FluentValidation.Validators;
+
+namespace Lidarr.Api.V3.Profiles.Language
+{
+ public static class LanguageValidation
+ {
+ public static IRuleBuilderOptions> MustHaveAllowedLanguage(this IRuleBuilder> ruleBuilder)
+ {
+ ruleBuilder.SetValidator(new NotEmptyValidator(null));
+
+ return ruleBuilder.SetValidator(new LanguageValidator());
+ }
+ }
+
+
+ public class LanguageValidator : PropertyValidator
+ {
+ public LanguageValidator()
+ : base("Must have at least one allowed language")
+ {
+ }
+
+ protected override bool IsValid(PropertyValidatorContext context)
+ {
+ var list = context.PropertyValue as IList;
+
+ if (list == null)
+ {
+ return false;
+ }
+
+ if (!list.Any(c => c.Allowed))
+ {
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Profiles/Quality/QualityProfileModule.cs b/src/Lidarr.Api.V3/Profiles/Quality/QualityProfileModule.cs
new file mode 100644
index 000000000..dbc7d8a1d
--- /dev/null
+++ b/src/Lidarr.Api.V3/Profiles/Quality/QualityProfileModule.cs
@@ -0,0 +1,55 @@
+using System.Collections.Generic;
+using FluentValidation;
+using NzbDrone.Core.Profiles.Qualities;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.Profiles.Quality
+{
+ public class ProfileModule : LidarrRestModule
+ {
+ private readonly IProfileService _profileService;
+
+ public ProfileModule(IProfileService profileService)
+ {
+ _profileService = profileService;
+ SharedValidator.RuleFor(c => c.Name).NotEmpty();
+ SharedValidator.RuleFor(c => c.Cutoff).NotNull();
+ SharedValidator.RuleFor(c => c.Items).MustHaveAllowedQuality();
+
+ GetResourceAll = GetAll;
+ GetResourceById = GetById;
+ UpdateResource = Update;
+ CreateResource = Create;
+ DeleteResource = DeleteProfile;
+ }
+
+ private int Create(QualityProfileResource resource)
+ {
+ var model = resource.ToModel();
+ model = _profileService.Add(model);
+ return model.Id;
+ }
+
+ private void DeleteProfile(int id)
+ {
+ _profileService.Delete(id);
+ }
+
+ private void Update(QualityProfileResource resource)
+ {
+ var model = resource.ToModel();
+
+ _profileService.Update(model);
+ }
+
+ private QualityProfileResource GetById(int id)
+ {
+ return _profileService.Get(id).ToResource();
+ }
+
+ private List GetAll()
+ {
+ return _profileService.All().ToResource();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Profiles/Quality/QualityProfileResource.cs b/src/Lidarr.Api.V3/Profiles/Quality/QualityProfileResource.cs
new file mode 100644
index 000000000..111fbec79
--- /dev/null
+++ b/src/Lidarr.Api.V3/Profiles/Quality/QualityProfileResource.cs
@@ -0,0 +1,78 @@
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Core.Profiles.Qualities;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Profiles.Quality
+{
+ public class QualityProfileResource : RestResource
+ {
+ public string Name { get; set; }
+ public NzbDrone.Core.Qualities.Quality Cutoff { get; set; }
+ public List Items { get; set; }
+ }
+
+ public class QualityProfileQualityItemResource : RestResource
+ {
+ public NzbDrone.Core.Qualities.Quality Quality { get; set; }
+ public bool Allowed { get; set; }
+ }
+
+ public static class ProfileResourceMapper
+ {
+ public static QualityProfileResource ToResource(this Profile model)
+ {
+ if (model == null) return null;
+
+ return new QualityProfileResource
+ {
+ Id = model.Id,
+
+ Name = model.Name,
+ Cutoff = model.Cutoff,
+ Items = model.Items.ConvertAll(ToResource),
+ };
+ }
+
+ public static QualityProfileQualityItemResource ToResource(this ProfileQualityItem model)
+ {
+ if (model == null) return null;
+
+ return new QualityProfileQualityItemResource
+ {
+ Quality = model.Quality,
+ Allowed = model.Allowed
+ };
+ }
+
+ public static Profile ToModel(this QualityProfileResource resource)
+ {
+ if (resource == null) return null;
+
+ return new Profile
+ {
+ Id = resource.Id,
+
+ Name = resource.Name,
+ Cutoff = (NzbDrone.Core.Qualities.Quality)resource.Cutoff.Id,
+ Items = resource.Items.ConvertAll(ToModel)
+ };
+ }
+
+ public static ProfileQualityItem ToModel(this QualityProfileQualityItemResource resource)
+ {
+ if (resource == null) return null;
+
+ return new ProfileQualityItem
+ {
+ Quality = (NzbDrone.Core.Qualities.Quality)resource.Quality.Id,
+ Allowed = resource.Allowed
+ };
+ }
+
+ public static List ToResource(this IEnumerable models)
+ {
+ return models.Select(ToResource).ToList();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Profiles/Quality/QualityProfileSchemaModule.cs b/src/Lidarr.Api.V3/Profiles/Quality/QualityProfileSchemaModule.cs
new file mode 100644
index 000000000..4aebb9fb2
--- /dev/null
+++ b/src/Lidarr.Api.V3/Profiles/Quality/QualityProfileSchemaModule.cs
@@ -0,0 +1,34 @@
+using System.Linq;
+using NzbDrone.Core.Profiles.Qualities;
+using NzbDrone.Core.Qualities;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.Profiles.Quality
+{
+ public class QualityProfileSchemaModule : LidarrRestModule
+ {
+ private readonly IQualityDefinitionService _qualityDefinitionService;
+
+ public QualityProfileSchemaModule(IQualityDefinitionService qualityDefinitionService)
+ : base("/qualityprofile/schema")
+ {
+ _qualityDefinitionService = qualityDefinitionService;
+
+ GetResourceSingle = GetSchema;
+ }
+
+ private QualityProfileResource GetSchema()
+ {
+ var items = _qualityDefinitionService.All()
+ .OrderBy(v => v.Weight)
+ .Select(v => new ProfileQualityItem { Quality = v.Quality, Allowed = false })
+ .ToList();
+
+ var qualityProfile = new Profile();
+ qualityProfile.Cutoff = NzbDrone.Core.Qualities.Quality.Unknown;
+ qualityProfile.Items = items;
+
+ return qualityProfile.ToResource();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Profiles/Quality/QualityProfileValidation.cs b/src/Lidarr.Api.V3/Profiles/Quality/QualityProfileValidation.cs
new file mode 100644
index 000000000..7382dbefb
--- /dev/null
+++ b/src/Lidarr.Api.V3/Profiles/Quality/QualityProfileValidation.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using System.Linq;
+using FluentValidation;
+using FluentValidation.Validators;
+
+namespace Lidarr.Api.V3.Profiles.Quality
+{
+ public static class QualityProfileValidation
+ {
+ public static IRuleBuilderOptions> MustHaveAllowedQuality(this IRuleBuilder> ruleBuilder)
+ {
+ ruleBuilder.SetValidator(new NotEmptyValidator(null));
+
+ return ruleBuilder.SetValidator(new AllowedValidator());
+ }
+ }
+
+ public class AllowedValidator : PropertyValidator
+ {
+ public AllowedValidator()
+ : base("Must contain at least one allowed quality")
+ {
+
+ }
+
+ protected override bool IsValid(PropertyValidatorContext context)
+ {
+ var list = context.PropertyValue as IList;
+
+ if (list == null)
+ {
+ return false;
+ }
+
+ if (!list.Any(c => c.Allowed))
+ {
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Properties/AssemblyInfo.cs b/src/Lidarr.Api.V3/Properties/AssemblyInfo.cs
new file mode 100644
index 000000000..8cfbdf950
--- /dev/null
+++ b/src/Lidarr.Api.V3/Properties/AssemblyInfo.cs
@@ -0,0 +1,9 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+[assembly: AssemblyTitle("Lidarr.Api")]
+
+[assembly: Guid("4c0922d7-979e-4ff7-b44b-b8ac2100eeb5")]
+
+[assembly: InternalsVisibleTo("Lidarr.Core")]
diff --git a/src/Lidarr.Api.V3/ProviderModuleBase.cs b/src/Lidarr.Api.V3/ProviderModuleBase.cs
new file mode 100644
index 000000000..43d7228a0
--- /dev/null
+++ b/src/Lidarr.Api.V3/ProviderModuleBase.cs
@@ -0,0 +1,187 @@
+using System.Collections.Generic;
+using System.Linq;
+using FluentValidation;
+using FluentValidation.Results;
+using Nancy;
+using Newtonsoft.Json;
+using NzbDrone.Core.ThingiProvider;
+using NzbDrone.Core.Validation;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3
+{
+ public abstract class ProviderModuleBase : LidarrRestModule
+ where TProviderDefinition : ProviderDefinition, new()
+ where TProvider : IProvider
+ where TProviderResource : ProviderResource, new()
+ {
+ private readonly IProviderFactory _providerFactory;
+ private readonly ProviderResourceMapper _resourceMapper;
+
+ protected ProviderModuleBase(IProviderFactory providerFactory, string resource, ProviderResourceMapper resourceMapper)
+ : base(resource)
+ {
+ _providerFactory = providerFactory;
+ _resourceMapper = resourceMapper;
+
+ Get["schema"] = x => GetTemplates();
+ Post["test"] = x => Test(ReadResourceFromRequest(true));
+ Post["action/{action}"] = x => RequestAction(x.action, ReadResourceFromRequest(true));
+
+ GetResourceAll = GetAll;
+ GetResourceById = GetProviderById;
+ CreateResource = CreateProvider;
+ UpdateResource = UpdateProvider;
+ DeleteResource = DeleteProvider;
+
+ SharedValidator.RuleFor(c => c.Name).NotEmpty();
+ SharedValidator.RuleFor(c => c.Name).Must((v,c) => !_providerFactory.All().Any(p => p.Name == c && p.Id != v.Id)).WithMessage("Should be unique");
+ SharedValidator.RuleFor(c => c.Implementation).NotEmpty();
+ SharedValidator.RuleFor(c => c.ConfigContract).NotEmpty();
+
+ PostValidator.RuleFor(c => c.Fields).NotNull();
+ }
+
+ private TProviderResource GetProviderById(int id)
+ {
+ var definition = _providerFactory.Get(id);
+ _providerFactory.SetProviderCharacteristics(definition);
+
+ return _resourceMapper.ToResource(definition);
+ }
+
+ private List GetAll()
+ {
+ var providerDefinitions = _providerFactory.All().OrderBy(p => p.ImplementationName);
+
+ var result = new List(providerDefinitions.Count());
+
+ foreach (var definition in providerDefinitions)
+ {
+ _providerFactory.SetProviderCharacteristics(definition);
+
+ result.Add(_resourceMapper.ToResource(definition));
+ }
+
+ return result.OrderBy(p => p.Name).ToList();
+ }
+
+ private int CreateProvider(TProviderResource providerResource)
+ {
+ var providerDefinition = GetDefinition(providerResource, false);
+
+ if (providerDefinition.Enable)
+ {
+ Test(providerDefinition, false);
+ }
+
+ providerDefinition = _providerFactory.Create(providerDefinition);
+
+ return providerDefinition.Id;
+ }
+
+ private void UpdateProvider(TProviderResource providerResource)
+ {
+ var providerDefinition = GetDefinition(providerResource, false);
+
+ if (providerDefinition.Enable)
+ {
+ Test(providerDefinition, false);
+ }
+
+ _providerFactory.Update(providerDefinition);
+ }
+
+ private TProviderDefinition GetDefinition(TProviderResource providerResource, bool includeWarnings = false, bool validate = true)
+ {
+ var definition = _resourceMapper.ToModel(providerResource);
+
+ if (validate)
+ {
+ Validate(definition, includeWarnings);
+ }
+
+ return definition;
+ }
+
+ private void DeleteProvider(int id)
+ {
+ _providerFactory.Delete(id);
+ }
+
+ private Response GetTemplates()
+ {
+ var defaultDefinitions = _providerFactory.GetDefaultDefinitions().OrderBy(p => p.ImplementationName).ToList();
+
+ var result = new List(defaultDefinitions.Count());
+
+ foreach (var providerDefinition in defaultDefinitions)
+ {
+ var providerResource = _resourceMapper.ToResource(providerDefinition);
+ var presetDefinitions = _providerFactory.GetPresetDefinitions(providerDefinition);
+
+ providerResource.Presets = presetDefinitions.Select(v =>
+ {
+ var presetResource = _resourceMapper.ToResource(v);
+
+ return presetResource as ProviderResource;
+ }).ToList();
+
+ result.Add(providerResource);
+ }
+
+ return result.AsResponse();
+ }
+
+ private Response Test(TProviderResource providerResource)
+ {
+ var providerDefinition = GetDefinition(providerResource, true);
+
+ Test(providerDefinition, true);
+
+ return "{}";
+ }
+
+ private Response RequestAction(string action, TProviderResource providerResource)
+ {
+ var providerDefinition = GetDefinition(providerResource, true, false);
+
+ var query = ((IDictionary)Request.Query.ToDictionary()).ToDictionary(k => k.Key, k => k.Value.ToString());
+
+ var data = _providerFactory.RequestAction(providerDefinition, action, query);
+ Response resp = JsonConvert.SerializeObject(data);
+ resp.ContentType = "application/json";
+ return resp;
+ }
+
+ protected virtual void Validate(TProviderDefinition definition, bool includeWarnings)
+ {
+ var validationResult = definition.Settings.Validate();
+
+ VerifyValidationResult(validationResult, includeWarnings);
+ }
+
+ protected virtual void Test(TProviderDefinition definition, bool includeWarnings)
+ {
+ var validationResult = _providerFactory.Test(definition);
+
+ VerifyValidationResult(validationResult, includeWarnings);
+ }
+
+ protected void VerifyValidationResult(ValidationResult validationResult, bool includeWarnings)
+ {
+ var result = new NzbDroneValidationResult(validationResult.Errors);
+
+ if (includeWarnings && (!result.IsValid || result.HasWarnings))
+ {
+ throw new ValidationException(result.Failures);
+ }
+
+ if (!result.IsValid)
+ {
+ throw new ValidationException(result.Errors);
+ }
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/ProviderResource.cs b/src/Lidarr.Api.V3/ProviderResource.cs
new file mode 100644
index 000000000..e9aa78c80
--- /dev/null
+++ b/src/Lidarr.Api.V3/ProviderResource.cs
@@ -0,0 +1,70 @@
+using System.Collections.Generic;
+using NzbDrone.Common.Reflection;
+using NzbDrone.Core.ThingiProvider;
+using Lidarr.Http.ClientSchema;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3
+{
+ public class ProviderResource : RestResource
+ {
+ public string Name { get; set; }
+ public List Fields { get; set; }
+ public string ImplementationName { get; set; }
+ public string Implementation { get; set; }
+ public string ConfigContract { get; set; }
+ public string InfoLink { get; set; }
+ public ProviderMessage Message { get; set; }
+ public HashSet Tags { get; set; }
+
+ public List Presets { get; set; }
+ }
+
+ public class ProviderResourceMapper
+ where TProviderResource : ProviderResource, new()
+ where TProviderDefinition : ProviderDefinition, new()
+ {
+ public virtual TProviderResource ToResource(TProviderDefinition definition)
+
+ {
+ return new TProviderResource
+ {
+ Id = definition.Id,
+
+ Name = definition.Name,
+ ImplementationName = definition.ImplementationName,
+ Implementation = definition.Implementation,
+ ConfigContract = definition.ConfigContract,
+ Message = definition.Message,
+ Tags = definition.Tags,
+ Fields = SchemaBuilder.ToSchema(definition.Settings),
+
+ InfoLink = string.Format("https://github.com/Sonarr/Sonarr/wiki/Supported-{0}#{1}",
+ typeof(TProviderResource).Name.Replace("Resource", "s"),
+ definition.Implementation.ToLower())
+ };
+ }
+
+ public virtual TProviderDefinition ToModel(TProviderResource resource)
+ {
+ if (resource == null) return default(TProviderDefinition);
+
+ var definition = new TProviderDefinition
+ {
+ Id = resource.Id,
+
+ Name = resource.Name,
+ ImplementationName = resource.ImplementationName,
+ Implementation = resource.Implementation,
+ ConfigContract = resource.ConfigContract,
+ Message = resource.Message,
+ Tags = resource.Tags
+ };
+
+ var configContract = ReflectionExtensions.CoreAssembly.FindTypeByName(definition.ConfigContract);
+ definition.Settings = (IProviderConfig)SchemaBuilder.ReadFromSchema(resource.Fields, configContract);
+
+ return definition;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Qualities/QualityDefinitionModule.cs b/src/Lidarr.Api.V3/Qualities/QualityDefinitionModule.cs
new file mode 100644
index 000000000..1da32a774
--- /dev/null
+++ b/src/Lidarr.Api.V3/Qualities/QualityDefinitionModule.cs
@@ -0,0 +1,54 @@
+using System.Collections.Generic;
+using System.Linq;
+using Nancy;
+using NzbDrone.Core.Qualities;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3.Qualities
+{
+ public class QualityDefinitionModule : LidarrRestModule
+ {
+ private readonly IQualityDefinitionService _qualityDefinitionService;
+
+ public QualityDefinitionModule(IQualityDefinitionService qualityDefinitionService)
+ {
+ _qualityDefinitionService = qualityDefinitionService;
+
+ GetResourceAll = GetAll;
+ GetResourceById = GetById;
+ UpdateResource = Update;
+ Put["/update"] = d => UpdateMany();
+ }
+
+ private void Update(QualityDefinitionResource resource)
+ {
+ var model = resource.ToModel();
+ _qualityDefinitionService.Update(model);
+ }
+
+ private QualityDefinitionResource GetById(int id)
+ {
+ return _qualityDefinitionService.GetById(id).ToResource();
+ }
+
+ private List GetAll()
+ {
+ return _qualityDefinitionService.All().ToResource();
+ }
+
+ private Response UpdateMany()
+ {
+ //Read from request
+ var qualityDefinitions = Request.Body.FromJson>()
+ .ToModel()
+ .ToList();
+
+ _qualityDefinitionService.UpdateMany(qualityDefinitions);
+
+ return _qualityDefinitionService.All()
+ .ToResource()
+ .AsResponse(HttpStatusCode.Accepted);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Qualities/QualityDefinitionResource.cs b/src/Lidarr.Api.V3/Qualities/QualityDefinitionResource.cs
similarity index 86%
rename from src/NzbDrone.Api/Qualities/QualityDefinitionResource.cs
rename to src/Lidarr.Api.V3/Qualities/QualityDefinitionResource.cs
index ea0edc0ab..7d2000c79 100644
--- a/src/NzbDrone.Api/Qualities/QualityDefinitionResource.cs
+++ b/src/Lidarr.Api.V3/Qualities/QualityDefinitionResource.cs
@@ -1,9 +1,9 @@
using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
using NzbDrone.Core.Qualities;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Qualities
+namespace Lidarr.Api.V3.Qualities
{
public class QualityDefinitionResource : RestResource
{
@@ -26,13 +26,9 @@ namespace NzbDrone.Api.Qualities
return new QualityDefinitionResource
{
Id = model.Id,
-
Quality = model.Quality,
-
Title = model.Title,
-
Weight = model.Weight,
-
MinSize = model.MinSize,
MaxSize = model.MaxSize
};
@@ -45,13 +41,9 @@ namespace NzbDrone.Api.Qualities
return new QualityDefinition
{
Id = resource.Id,
-
Quality = resource.Quality,
-
Title = resource.Title,
-
Weight = resource.Weight,
-
MinSize = resource.MinSize,
MaxSize = resource.MaxSize
};
@@ -61,5 +53,10 @@ namespace NzbDrone.Api.Qualities
{
return models.Select(ToResource).ToList();
}
+
+ public static List ToModel(this IEnumerable resources)
+ {
+ return resources.Select(ToModel).ToList();
+ }
}
}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Queue/QueueActionModule.cs b/src/Lidarr.Api.V3/Queue/QueueActionModule.cs
new file mode 100644
index 000000000..d0ff7e818
--- /dev/null
+++ b/src/Lidarr.Api.V3/Queue/QueueActionModule.cs
@@ -0,0 +1,167 @@
+using System;
+using System.Collections.Generic;
+using Nancy;
+using NzbDrone.Core.Download;
+using NzbDrone.Core.Download.Pending;
+using NzbDrone.Core.Download.TrackedDownloads;
+using NzbDrone.Core.Queue;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Queue
+{
+ public class QueueActionModule : LidarrRestModule
+ {
+ private readonly IQueueService _queueService;
+ private readonly ITrackedDownloadService _trackedDownloadService;
+ private readonly IFailedDownloadService _failedDownloadService;
+ private readonly IProvideDownloadClient _downloadClientProvider;
+ private readonly IPendingReleaseService _pendingReleaseService;
+ private readonly IDownloadService _downloadService;
+
+ public QueueActionModule(IQueueService queueService,
+ ITrackedDownloadService trackedDownloadService,
+ IFailedDownloadService failedDownloadService,
+ IProvideDownloadClient downloadClientProvider,
+ IPendingReleaseService pendingReleaseService,
+ IDownloadService downloadService)
+ {
+ _queueService = queueService;
+ _trackedDownloadService = trackedDownloadService;
+ _failedDownloadService = failedDownloadService;
+ _downloadClientProvider = downloadClientProvider;
+ _pendingReleaseService = pendingReleaseService;
+ _downloadService = downloadService;
+
+ Post[@"/grab/(?[\d]{1,10})"] = x => Grab((int)x.Id);
+ Post["/grab/bulk"] = x => Grab();
+
+ Delete[@"/(?[\d]{1,10})"] = x => Remove((int)x.Id);
+ Delete["/bulk"] = x => Remove();
+ }
+
+ private Response Grab(int id)
+ {
+ var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id);
+
+ if (pendingRelease == null)
+ {
+ throw new NotFoundException();
+ }
+
+ _downloadService.DownloadReport(pendingRelease.RemoteAlbum);
+
+ return new object().AsResponse();
+ }
+
+ private Response Grab()
+ {
+ var resource = Request.Body.FromJson();
+
+ foreach (var id in resource.Ids)
+ {
+ var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id);
+
+ if (pendingRelease == null)
+ {
+ throw new NotFoundException();
+ }
+
+ _downloadService.DownloadReport(pendingRelease.RemoteAlbum);
+ }
+
+ return new object().AsResponse();
+ }
+
+ private Response Remove(int id)
+ {
+ var blacklist = Request.GetBooleanQueryParameter("blacklist");
+
+ var trackedDownload = Remove(id, blacklist);
+
+ if (trackedDownload != null)
+ {
+ _trackedDownloadService.StopTracking(trackedDownload.DownloadItem.DownloadId);
+ }
+
+ return new object().AsResponse();
+ }
+
+ private Response Remove()
+ {
+ var blacklist = Request.GetBooleanQueryParameter("blacklist");
+
+ var resource = Request.Body.FromJson();
+ var trackedDownloadIds = new List();
+
+ foreach (var id in resource.Ids)
+ {
+ var trackedDownload = Remove(id, blacklist);
+
+ if (trackedDownload != null)
+ {
+ trackedDownloadIds.Add(trackedDownload.DownloadItem.DownloadId);
+ }
+ }
+
+ _trackedDownloadService.StopTracking(trackedDownloadIds);
+
+ return new object().AsResponse();
+ }
+
+ private TrackedDownload Remove(int id, bool blacklist)
+ {
+ var pendingRelease = _pendingReleaseService.FindPendingQueueItem(id);
+
+ if (pendingRelease != null)
+ {
+ _pendingReleaseService.RemovePendingQueueItems(pendingRelease.Id);
+
+ return null;
+ }
+
+ var trackedDownload = GetTrackedDownload(id);
+
+ if (trackedDownload == null)
+ {
+ throw new NotFoundException();
+ }
+
+ var downloadClient = _downloadClientProvider.Get(trackedDownload.DownloadClient);
+
+ if (downloadClient == null)
+ {
+ throw new BadRequestException();
+ }
+
+ downloadClient.RemoveItem(trackedDownload.DownloadItem.DownloadId, true);
+
+ if (blacklist)
+ {
+ _failedDownloadService.MarkAsFailed(trackedDownload.DownloadItem.DownloadId);
+ }
+
+ return trackedDownload;
+ }
+
+ private TrackedDownload GetTrackedDownload(int queueId)
+ {
+ var queueItem = _queueService.Find(queueId);
+
+ if (queueItem == null)
+ {
+ throw new NotFoundException();
+ }
+
+ var trackedDownload = _trackedDownloadService.Find(queueItem.DownloadId);
+
+ if (trackedDownload == null)
+ {
+ throw new NotFoundException();
+ }
+
+ return trackedDownload;
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Queue/QueueBulkResource.cs b/src/Lidarr.Api.V3/Queue/QueueBulkResource.cs
new file mode 100644
index 000000000..b1024846e
--- /dev/null
+++ b/src/Lidarr.Api.V3/Queue/QueueBulkResource.cs
@@ -0,0 +1,9 @@
+using System.Collections.Generic;
+
+namespace Lidarr.Api.V3.Queue
+{
+ public class QueueBulkResource
+ {
+ public List Ids { get; set; }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Queue/QueueDetailsModule.cs b/src/Lidarr.Api.V3/Queue/QueueDetailsModule.cs
new file mode 100644
index 000000000..4adda5669
--- /dev/null
+++ b/src/Lidarr.Api.V3/Queue/QueueDetailsModule.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.Download.Pending;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.Core.Queue;
+using NzbDrone.SignalR;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3.Queue
+{
+ public class QueueDetailsModule : LidarrRestModuleWithSignalR,
+ IHandle, IHandle
+ {
+ private readonly IQueueService _queueService;
+ private readonly IPendingReleaseService _pendingReleaseService;
+
+ public QueueDetailsModule(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService)
+ : base(broadcastSignalRMessage, "queue/details")
+ {
+ _queueService = queueService;
+ _pendingReleaseService = pendingReleaseService;
+ GetResourceAll = GetQueue;
+ }
+
+ private List GetQueue()
+ {
+ var includeSeries = Request.GetBooleanQueryParameter("includeSeries");
+ var includeEpisode = Request.GetBooleanQueryParameter("includeEpisode", true);
+ var queue = _queueService.GetQueue();
+ var pending = _pendingReleaseService.GetPendingQueue();
+ var fullQueue = queue.Concat(pending);
+
+ var artistIdQuery = Request.Query.ArtistId;
+ var albumIdsQuery = Request.Query.AlbumIds;
+
+ if (artistIdQuery.HasValue)
+ {
+ return fullQueue.Where(q => q.Artist.Id == (int)artistIdQuery).ToResource(includeSeries, includeEpisode);
+ }
+
+ if (albumIdsQuery.HasValue)
+ {
+ string albumIdsValue = albumIdsQuery.Value.ToString();
+
+ var albumIds = albumIdsValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(e => Convert.ToInt32(e))
+ .ToList();
+
+ return fullQueue.Where(q => albumIds.Contains(q.Album.Id)).ToResource(includeSeries, includeEpisode);
+ }
+
+ return fullQueue.ToResource(includeSeries, includeEpisode);
+ }
+
+ public void Handle(QueueUpdatedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Sync);
+ }
+
+ public void Handle(PendingReleasesUpdatedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Sync);
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Queue/QueueModule.cs b/src/Lidarr.Api.V3/Queue/QueueModule.cs
new file mode 100644
index 000000000..8ecfdccfc
--- /dev/null
+++ b/src/Lidarr.Api.V3/Queue/QueueModule.cs
@@ -0,0 +1,113 @@
+using System;
+using System.Linq;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Datastore;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.Download.Pending;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.Core.Queue;
+using NzbDrone.SignalR;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3.Queue
+{
+ public class QueueModule : LidarrRestModuleWithSignalR,
+ IHandle, IHandle
+ {
+ private readonly IQueueService _queueService;
+ private readonly IPendingReleaseService _pendingReleaseService;
+
+ public QueueModule(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService)
+ : base(broadcastSignalRMessage)
+ {
+ _queueService = queueService;
+ _pendingReleaseService = pendingReleaseService;
+ GetResourcePaged = GetQueue;
+ }
+
+ private PagingResource GetQueue(PagingResource pagingResource)
+ {
+ var pagingSpec = pagingResource.MapToPagingSpec("timeleft", SortDirection.Ascending);
+ var includeArtist = Request.GetBooleanQueryParameter("includeArtist");
+ var includeAlbum = Request.GetBooleanQueryParameter("includeAlbum");
+
+ return ApplyToPage(GetQueue, pagingSpec, (q) => MapToResource(q, includeArtist, includeAlbum));
+ }
+
+ private PagingSpec GetQueue(PagingSpec pagingSpec)
+ {
+ var ascending = pagingSpec.SortDirection == SortDirection.Ascending;
+ var orderByFunc = GetOrderByFunc(pagingSpec);
+
+ var queue = _queueService.GetQueue();
+ var pending = _pendingReleaseService.GetPendingQueue();
+ var fullQueue = queue.Concat(pending).ToList();
+ IOrderedEnumerable ordered;
+
+ if (pagingSpec.SortKey == "timeleft")
+ {
+ ordered = ascending ? fullQueue.OrderBy(q => q.Timeleft, new TimeleftComparer()) :
+ fullQueue.OrderByDescending(q => q.Timeleft, new TimeleftComparer());
+ }
+
+ else if (pagingSpec.SortKey == "estimatedCompletionTime")
+ {
+ ordered = ascending ? fullQueue.OrderBy(q => q.EstimatedCompletionTime, new EstimatedCompletionTimeComparer()) :
+ fullQueue.OrderByDescending(q => q.EstimatedCompletionTime, new EstimatedCompletionTimeComparer());
+ }
+
+ else
+ {
+ ordered = ascending ? fullQueue.OrderBy(orderByFunc) : fullQueue.OrderByDescending(orderByFunc);
+ }
+
+ ordered = ordered.ThenByDescending(q => 100 - q.Sizeleft / q.Size * 100);
+
+ pagingSpec.Records = ordered.Skip((pagingSpec.Page - 1) * pagingSpec.PageSize).Take(pagingSpec.PageSize).ToList();
+ pagingSpec.TotalRecords = fullQueue.Count;
+
+ if (pagingSpec.Records.Empty() && pagingSpec.Page > 1)
+ {
+ pagingSpec.Page = (int)Math.Max(Math.Ceiling((decimal)(pagingSpec.TotalRecords / pagingSpec.PageSize)), 1);
+ pagingSpec.Records = ordered.Skip((pagingSpec.Page - 1) * pagingSpec.PageSize).Take(pagingSpec.PageSize).ToList();
+ }
+
+ return pagingSpec;
+ }
+
+ private Func GetOrderByFunc(PagingSpec pagingSpec)
+ {
+ switch (pagingSpec.SortKey)
+ {
+ case "series.sortTitle":
+ return q => q.Artist.SortName;
+ case "episode":
+ return q => q.Album;
+ case "episode.title":
+ return q => q.Album.Title;
+ case "quality":
+ return q => q.Quality;
+ case "progress":
+ return q => 100 - q.Sizeleft / q.Size * 100;
+ default:
+ return q => q.Timeleft;
+ }
+ }
+
+ private QueueResource MapToResource(NzbDrone.Core.Queue.Queue queueItem, bool includeSeries, bool includeEpisode)
+ {
+ return queueItem.ToResource(includeSeries, includeEpisode);
+ }
+
+ public void Handle(QueueUpdatedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Sync);
+ }
+
+ public void Handle(PendingReleasesUpdatedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Sync);
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Queue/QueueResource.cs b/src/Lidarr.Api.V3/Queue/QueueResource.cs
new file mode 100644
index 000000000..e37a0155b
--- /dev/null
+++ b/src/Lidarr.Api.V3/Queue/QueueResource.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Core.Download.TrackedDownloads;
+using NzbDrone.Core.Indexers;
+using NzbDrone.Core.Qualities;
+using Lidarr.Api.V3.Albums;
+using Lidarr.Api.V3.Artist;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Queue
+{
+ public class QueueResource : RestResource
+ {
+ public int ArtistId { get; set; }
+ public int AlbumId { get; set; }
+ public ArtistResource Artist { get; set; }
+ public AlbumResource Album { get; set; }
+ public QualityModel Quality { get; set; }
+ public decimal Size { get; set; }
+ public string Title { get; set; }
+ public decimal Sizeleft { get; set; }
+ public TimeSpan? Timeleft { get; set; }
+ public DateTime? EstimatedCompletionTime { get; set; }
+ public string Status { get; set; }
+ public string TrackedDownloadStatus { get; set; }
+ public List StatusMessages { get; set; }
+ public string ErrorMessage { get; set; }
+ public string DownloadId { get; set; }
+ public DownloadProtocol Protocol { get; set; }
+ public string DownloadClient { get; set; }
+ public string Indexer { get; set; }
+ }
+
+ public static class QueueResourceMapper
+ {
+ public static QueueResource ToResource(this NzbDrone.Core.Queue.Queue model, bool includeSeries, bool includeEpisode)
+ {
+ if (model == null) return null;
+
+ return new QueueResource
+ {
+ Id = model.Id,
+ ArtistId = model.Artist.Id,
+ AlbumId = model.Album.Id,
+ Artist = includeSeries ? model.Artist.ToResource() : null,
+ Album = includeEpisode ? model.Album.ToResource() : null,
+ Quality = model.Quality,
+ Size = model.Size,
+ Title = model.Title,
+ Sizeleft = model.Sizeleft,
+ Timeleft = model.Timeleft,
+ EstimatedCompletionTime = model.EstimatedCompletionTime,
+ Status = model.Status,
+ TrackedDownloadStatus = model.TrackedDownloadStatus,
+ StatusMessages = model.StatusMessages,
+ ErrorMessage = model.ErrorMessage,
+ DownloadId = model.DownloadId,
+ Protocol = model.Protocol,
+ DownloadClient = model.DownloadClient,
+ Indexer = model.Indexer
+ };
+ }
+
+ public static List ToResource(this IEnumerable models, bool includeSeries, bool includeEpisode)
+ {
+ return models.Select((m) => ToResource(m, includeSeries, includeEpisode)).ToList();
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Queue/QueueStatusModule.cs b/src/Lidarr.Api.V3/Queue/QueueStatusModule.cs
new file mode 100644
index 000000000..09b59fc33
--- /dev/null
+++ b/src/Lidarr.Api.V3/Queue/QueueStatusModule.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Linq;
+using Nancy.Responses;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.Download.Pending;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.Core.Queue;
+using NzbDrone.SignalR;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3.Queue
+{
+ public class QueueStatusModule : LidarrRestModuleWithSignalR,
+ IHandle, IHandle
+ {
+ private readonly IQueueService _queueService;
+ private readonly IPendingReleaseService _pendingReleaseService;
+
+ public QueueStatusModule(IBroadcastSignalRMessage broadcastSignalRMessage, IQueueService queueService, IPendingReleaseService pendingReleaseService)
+ : base(broadcastSignalRMessage, "queue/status")
+ {
+ _queueService = queueService;
+ _pendingReleaseService = pendingReleaseService;
+ Get["/"] = x => GetQueueStatusResponse();
+ }
+
+ private JsonResponse GetQueueStatusResponse()
+ {
+ return GetQueueStatus().AsResponse();
+ }
+
+ private QueueStatusResource GetQueueStatus()
+ {
+ var queue = _queueService.GetQueue();
+ var pending = _pendingReleaseService.GetPendingQueue();
+
+ return new QueueStatusResource
+ {
+ Count = queue.Count + pending.Count,
+ Errors = queue.Any(q => q.TrackedDownloadStatus.Equals("Error", StringComparison.InvariantCultureIgnoreCase)),
+ Warnings = queue.Any(q => q.TrackedDownloadStatus.Equals("Warning", StringComparison.InvariantCultureIgnoreCase))
+ };
+ }
+
+ public void Handle(QueueUpdatedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Updated, GetQueueStatus());
+ }
+
+ public void Handle(PendingReleasesUpdatedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Updated, GetQueueStatus());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/Queue/QueueStatusResource.cs b/src/Lidarr.Api.V3/Queue/QueueStatusResource.cs
new file mode 100644
index 000000000..82261abc3
--- /dev/null
+++ b/src/Lidarr.Api.V3/Queue/QueueStatusResource.cs
@@ -0,0 +1,11 @@
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Queue
+{
+ public class QueueStatusResource : RestResource
+ {
+ public int Count { get; set; }
+ public bool Errors { get; set; }
+ public bool Warnings { get; set; }
+ }
+}
diff --git a/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingModule.cs b/src/Lidarr.Api.V3/RemotePathMappings/RemotePathMappingModule.cs
similarity index 93%
rename from src/NzbDrone.Api/RemotePathMappings/RemotePathMappingModule.cs
rename to src/Lidarr.Api.V3/RemotePathMappings/RemotePathMappingModule.cs
index a61b5f7b3..cbdffa155 100644
--- a/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingModule.cs
+++ b/src/Lidarr.Api.V3/RemotePathMappings/RemotePathMappingModule.cs
@@ -2,10 +2,11 @@
using FluentValidation;
using NzbDrone.Core.RemotePathMappings;
using NzbDrone.Core.Validation.Paths;
+using Lidarr.Http;
-namespace NzbDrone.Api.RemotePathMappings
+namespace Lidarr.Api.V3.RemotePathMappings
{
- public class RemotePathMappingModule : NzbDroneRestModule
+ public class RemotePathMappingModule : LidarrRestModule
{
private readonly IRemotePathMappingService _remotePathMappingService;
diff --git a/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingResource.cs b/src/Lidarr.Api.V3/RemotePathMappings/RemotePathMappingResource.cs
similarity index 95%
rename from src/NzbDrone.Api/RemotePathMappings/RemotePathMappingResource.cs
rename to src/Lidarr.Api.V3/RemotePathMappings/RemotePathMappingResource.cs
index 60c01b682..e1b716ba6 100644
--- a/src/NzbDrone.Api/RemotePathMappings/RemotePathMappingResource.cs
+++ b/src/Lidarr.Api.V3/RemotePathMappings/RemotePathMappingResource.cs
@@ -1,9 +1,9 @@
using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
using NzbDrone.Core.RemotePathMappings;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.RemotePathMappings
+namespace Lidarr.Api.V3.RemotePathMappings
{
public class RemotePathMappingResource : RestResource
{
diff --git a/src/Lidarr.Api.V3/Restrictions/RestrictionModule.cs b/src/Lidarr.Api.V3/Restrictions/RestrictionModule.cs
new file mode 100644
index 000000000..810eb821a
--- /dev/null
+++ b/src/Lidarr.Api.V3/Restrictions/RestrictionModule.cs
@@ -0,0 +1,60 @@
+using System.Collections.Generic;
+using FluentValidation.Results;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Restrictions;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.Restrictions
+{
+ public class RestrictionModule : LidarrRestModule
+ {
+ private readonly IRestrictionService _restrictionService;
+
+
+ public RestrictionModule(IRestrictionService restrictionService)
+ {
+ _restrictionService = restrictionService;
+
+ GetResourceById = Get;
+ GetResourceAll = GetAll;
+ CreateResource = Create;
+ UpdateResource = Update;
+ DeleteResource = Delete;
+
+ SharedValidator.Custom(restriction =>
+ {
+ if (restriction.Ignored.IsNullOrWhiteSpace() && restriction.Required.IsNullOrWhiteSpace())
+ {
+ return new ValidationFailure("", "Either 'Must contain' or 'Must not contain' is required");
+ }
+
+ return null;
+ });
+ }
+
+ private RestrictionResource Get(int id)
+ {
+ return _restrictionService.Get(id).ToResource();
+ }
+
+ private List GetAll()
+ {
+ return _restrictionService.All().ToResource();
+ }
+
+ private int Create(RestrictionResource resource)
+ {
+ return _restrictionService.Add(resource.ToModel()).Id;
+ }
+
+ private void Update(RestrictionResource resource)
+ {
+ _restrictionService.Update(resource.ToModel());
+ }
+
+ private void Delete(int id)
+ {
+ _restrictionService.Delete(id);
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/Restrictions/RestrictionResource.cs b/src/Lidarr.Api.V3/Restrictions/RestrictionResource.cs
similarity index 96%
rename from src/NzbDrone.Api/Restrictions/RestrictionResource.cs
rename to src/Lidarr.Api.V3/Restrictions/RestrictionResource.cs
index 14085e820..8c8689cd6 100644
--- a/src/NzbDrone.Api/Restrictions/RestrictionResource.cs
+++ b/src/Lidarr.Api.V3/Restrictions/RestrictionResource.cs
@@ -1,9 +1,9 @@
using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
using NzbDrone.Core.Restrictions;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Restrictions
+namespace Lidarr.Api.V3.Restrictions
{
public class RestrictionResource : RestResource
{
diff --git a/src/Lidarr.Api.V3/RootFolders/RootFolderModule.cs b/src/Lidarr.Api.V3/RootFolders/RootFolderModule.cs
new file mode 100644
index 000000000..9258db3f6
--- /dev/null
+++ b/src/Lidarr.Api.V3/RootFolders/RootFolderModule.cs
@@ -0,0 +1,58 @@
+using System.Collections.Generic;
+using FluentValidation;
+using NzbDrone.Core.RootFolders;
+using NzbDrone.Core.Validation.Paths;
+using NzbDrone.SignalR;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.RootFolders
+{
+ public class RootFolderModule : LidarrRestModuleWithSignalR
+ {
+ private readonly IRootFolderService _rootFolderService;
+
+ public RootFolderModule(IRootFolderService rootFolderService,
+ IBroadcastSignalRMessage signalRBroadcaster,
+ RootFolderValidator rootFolderValidator,
+ PathExistsValidator pathExistsValidator,
+ MappedNetworkDriveValidator mappedNetworkDriveValidator)
+ : base(signalRBroadcaster)
+ {
+ _rootFolderService = rootFolderService;
+
+ GetResourceAll = GetRootFolders;
+ GetResourceById = GetRootFolder;
+ CreateResource = CreateRootFolder;
+ DeleteResource = DeleteFolder;
+
+ SharedValidator.RuleFor(c => c.Path)
+ .Cascade(CascadeMode.StopOnFirstFailure)
+ .IsValidPath()
+ .SetValidator(rootFolderValidator)
+ .SetValidator(mappedNetworkDriveValidator)
+ .SetValidator(pathExistsValidator);
+ }
+
+ private RootFolderResource GetRootFolder(int id)
+ {
+ return _rootFolderService.Get(id).ToResource();
+ }
+
+ private int CreateRootFolder(RootFolderResource rootFolderResource)
+ {
+ var model = rootFolderResource.ToModel();
+
+ return _rootFolderService.Add(model).Id;
+ }
+
+ private List GetRootFolders()
+ {
+ return _rootFolderService.AllWithUnmappedFolders().ToResource();
+ }
+
+ private void DeleteFolder(int id)
+ {
+ _rootFolderService.Remove(id);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/RootFolders/RootFolderResource.cs b/src/Lidarr.Api.V3/RootFolders/RootFolderResource.cs
similarity index 95%
rename from src/NzbDrone.Api/RootFolders/RootFolderResource.cs
rename to src/Lidarr.Api.V3/RootFolders/RootFolderResource.cs
index 86efef529..992f112be 100644
--- a/src/NzbDrone.Api/RootFolders/RootFolderResource.cs
+++ b/src/Lidarr.Api.V3/RootFolders/RootFolderResource.cs
@@ -1,9 +1,9 @@
using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
using NzbDrone.Core.RootFolders;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.RootFolders
+namespace Lidarr.Api.V3.RootFolders
{
public class RootFolderResource : RestResource
{
diff --git a/src/Lidarr.Api.V3/SonarrV3FeedModule.cs b/src/Lidarr.Api.V3/SonarrV3FeedModule.cs
new file mode 100644
index 000000000..0c6e838b2
--- /dev/null
+++ b/src/Lidarr.Api.V3/SonarrV3FeedModule.cs
@@ -0,0 +1,12 @@
+using Nancy;
+
+namespace Lidarr.Api.V3
+{
+ public abstract class SonarrV3FeedModule : NancyModule
+ {
+ protected SonarrV3FeedModule(string resource)
+ : base("/feed/v3/" + resource.Trim('/'))
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/SonarrV3Module.cs b/src/Lidarr.Api.V3/SonarrV3Module.cs
new file mode 100644
index 000000000..112368f9f
--- /dev/null
+++ b/src/Lidarr.Api.V3/SonarrV3Module.cs
@@ -0,0 +1,12 @@
+using Nancy;
+
+namespace Lidarr.Api.V3
+{
+ public abstract class SonarrV3Module : NancyModule
+ {
+ protected SonarrV3Module(string resource)
+ : base("/api/v3/" + resource.Trim('/'))
+ {
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Lidarr.Api.V3/System/Backup/BackupModule.cs b/src/Lidarr.Api.V3/System/Backup/BackupModule.cs
new file mode 100644
index 000000000..9bf93146d
--- /dev/null
+++ b/src/Lidarr.Api.V3/System/Backup/BackupModule.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using NzbDrone.Core.Backup;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.System.Backup
+{
+ public class BackupModule : LidarrRestModule
+ {
+ private readonly IBackupService _backupService;
+
+ public BackupModule(IBackupService backupService) : base("system/backup")
+ {
+ _backupService = backupService;
+ GetResourceAll = GetBackupFiles;
+ }
+
+ public List GetBackupFiles()
+ {
+ var backups = _backupService.GetBackups();
+
+ return backups.Select(b => new BackupResource
+ {
+ Id = b.Name.GetHashCode(),
+ Name = b.Name,
+ Path = $"/backup/{b.Type.ToString().ToLower()}/{b.Name}",
+ Type = b.Type,
+ Time = b.Time
+ })
+ .OrderByDescending(b => b.Time)
+ .ToList();
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/System/Backup/BackupResource.cs b/src/Lidarr.Api.V3/System/Backup/BackupResource.cs
similarity index 81%
rename from src/NzbDrone.Api/System/Backup/BackupResource.cs
rename to src/Lidarr.Api.V3/System/Backup/BackupResource.cs
index 7eac82838..243ccd5bf 100644
--- a/src/NzbDrone.Api/System/Backup/BackupResource.cs
+++ b/src/Lidarr.Api.V3/System/Backup/BackupResource.cs
@@ -1,8 +1,8 @@
using System;
-using NzbDrone.Api.REST;
using NzbDrone.Core.Backup;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.System.Backup
+namespace Lidarr.Api.V3.System.Backup
{
public class BackupResource : RestResource
{
diff --git a/src/NzbDrone.Api/System/SystemModule.cs b/src/Lidarr.Api.V3/System/SystemModule.cs
similarity index 92%
rename from src/NzbDrone.Api/System/SystemModule.cs
rename to src/Lidarr.Api.V3/System/SystemModule.cs
index c62ed3b9e..5017c5ed4 100644
--- a/src/NzbDrone.Api/System/SystemModule.cs
+++ b/src/Lidarr.Api.V3/System/SystemModule.cs
@@ -1,15 +1,15 @@
using Nancy;
using Nancy.Routing;
-using NzbDrone.Api.Extensions;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Lifecycle;
+using Lidarr.Http.Extensions;
-namespace NzbDrone.Api.System
+namespace Lidarr.Api.V3.System
{
- public class SystemModule : NzbDroneApiModule
+ public class SystemModule : SonarrV3Module
{
private readonly IAppFolderInfo _appFolderInfo;
private readonly IRuntimeInfo _runtimeInfo;
@@ -27,7 +27,8 @@ namespace NzbDrone.Api.System
IRouteCacheProvider routeCacheProvider,
IConfigFileProvider configFileProvider,
IMainDatabase database,
- ILifecycleService lifecycleService) : base("system")
+ ILifecycleService lifecycleService)
+ : base("system")
{
_appFolderInfo = appFolderInfo;
_runtimeInfo = runtimeInfo;
@@ -46,7 +47,7 @@ namespace NzbDrone.Api.System
private Response GetStatus()
{
return new
- {
+ {
Version = BuildInfo.Version.ToString(),
BuildTime = BuildInfo.BuildDateTime,
IsDebug = BuildInfo.IsDebug,
@@ -62,6 +63,7 @@ namespace NzbDrone.Api.System
IsLinux = OsInfo.IsLinux,
IsOsx = OsInfo.IsOsx,
IsWindows = OsInfo.IsWindows,
+ Mode = _runtimeInfo.Mode,
Branch = _configFileProvider.Branch,
Authentication = _configFileProvider.AuthenticationMethod,
SqliteVersion = _database.Version,
diff --git a/src/Lidarr.Api.V3/System/Tasks/TaskModule.cs b/src/Lidarr.Api.V3/System/Tasks/TaskModule.cs
new file mode 100644
index 000000000..e23a8567e
--- /dev/null
+++ b/src/Lidarr.Api.V3/System/Tasks/TaskModule.cs
@@ -0,0 +1,67 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.Jobs;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.SignalR;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.System.Tasks
+{
+ public class TaskModule : LidarrRestModuleWithSignalR, IHandle
+ {
+ private readonly ITaskManager _taskManager;
+
+ private static readonly Regex NameRegex = new Regex("(? GetAll()
+ {
+ return _taskManager.GetAll()
+ .Select(ConvertToResource)
+ .OrderBy(t => t.Name)
+ .ToList();
+ }
+
+ private TaskResource GetTask(int id)
+ {
+ var task = _taskManager.GetAll()
+ .SingleOrDefault(t => t.Id == id);
+
+ if (task == null)
+ {
+ return null;
+ }
+
+ return ConvertToResource(task);
+ }
+
+ private static TaskResource ConvertToResource(ScheduledTask scheduledTask)
+ {
+ var taskName = scheduledTask.TypeName.Split('.').Last().Replace("Command", "");
+
+ return new TaskResource
+ {
+ Id = scheduledTask.Id,
+ Name = NameRegex.Replace(taskName, match => " " + match.Value),
+ TaskName = taskName,
+ Interval = scheduledTask.Interval,
+ LastExecution = scheduledTask.LastExecution,
+ NextExecution = scheduledTask.LastExecution.AddMinutes(scheduledTask.Interval)
+ };
+ }
+
+ public void Handle(CommandExecutedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Sync);
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/System/Tasks/TaskResource.cs b/src/Lidarr.Api.V3/System/Tasks/TaskResource.cs
similarity index 83%
rename from src/NzbDrone.Api/System/Tasks/TaskResource.cs
rename to src/Lidarr.Api.V3/System/Tasks/TaskResource.cs
index fda392cae..9c6d6385b 100644
--- a/src/NzbDrone.Api/System/Tasks/TaskResource.cs
+++ b/src/Lidarr.Api.V3/System/Tasks/TaskResource.cs
@@ -1,7 +1,7 @@
using System;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.System.Tasks
+namespace Lidarr.Api.V3.System.Tasks
{
public class TaskResource : RestResource
{
diff --git a/src/Lidarr.Api.V3/Tags/TagDetailsModule.cs b/src/Lidarr.Api.V3/Tags/TagDetailsModule.cs
new file mode 100644
index 000000000..a8a4f173c
--- /dev/null
+++ b/src/Lidarr.Api.V3/Tags/TagDetailsModule.cs
@@ -0,0 +1,23 @@
+using NzbDrone.Core.Tags;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.Tags
+{
+ public class TagDetailsModule : LidarrRestModule
+ {
+ private readonly ITagService _tagService;
+
+ public TagDetailsModule(ITagService tagService)
+ : base("/tag/details")
+ {
+ _tagService = tagService;
+
+ GetResourceById = Get;
+ }
+
+ private TagDetailsResource Get(int id)
+ {
+ return _tagService.Details(id).ToResource();
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Tags/TagDetailsResource.cs b/src/Lidarr.Api.V3/Tags/TagDetailsResource.cs
new file mode 100644
index 000000000..10675b3c1
--- /dev/null
+++ b/src/Lidarr.Api.V3/Tags/TagDetailsResource.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+using System.Linq;
+using NzbDrone.Core.Tags;
+using Lidarr.Api.V3.Notifications;
+using Lidarr.Api.V3.Profiles.Delay;
+using Lidarr.Api.V3.Restrictions;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Tags
+{
+ public class TagDetailsResource : RestResource
+ {
+ public string Label { get; set; }
+ public List DelayProfiles { get; set; }
+ public List Notifications { get; set; }
+ public List Restrictions { get; set; }
+ public List ArtistIds { get; set; }
+ }
+
+ public static class TagDetailsResourceMapper
+ {
+ private static readonly NotificationResourceMapper NotificationResourceMapper = new NotificationResourceMapper();
+
+ public static TagDetailsResource ToResource(this TagDetails model)
+ {
+ if (model == null) return null;
+
+ return new TagDetailsResource
+ {
+ Id = model.Id,
+ Label = model.Label,
+ DelayProfiles = model.DelayProfiles.ToResource(),
+ Notifications = model.Notifications.Select(NotificationResourceMapper.ToResource).ToList(),
+ Restrictions = model.Restrictions.ToResource(),
+ ArtistIds = model.Artist.Select(s => s.Id).ToList()
+ };
+ }
+
+ public static List ToResource(this IEnumerable models)
+ {
+ return models.Select(ToResource).ToList();
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Tags/TagModule.cs b/src/Lidarr.Api.V3/Tags/TagModule.cs
new file mode 100644
index 000000000..045fe723d
--- /dev/null
+++ b/src/Lidarr.Api.V3/Tags/TagModule.cs
@@ -0,0 +1,57 @@
+using System.Collections.Generic;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.Core.Tags;
+using NzbDrone.SignalR;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.Tags
+{
+ public class TagModule : LidarrRestModuleWithSignalR, IHandle
+ {
+ private readonly ITagService _tagService;
+
+ public TagModule(IBroadcastSignalRMessage signalRBroadcaster,
+ ITagService tagService)
+ : base(signalRBroadcaster)
+ {
+ _tagService = tagService;
+
+ GetResourceById = Get;
+ GetResourceAll = GetAll;
+ CreateResource = Create;
+ UpdateResource = Update;
+ DeleteResource = Delete;
+ }
+
+ private TagResource Get(int id)
+ {
+ return _tagService.GetTag(id).ToResource();
+ }
+
+ private List GetAll()
+ {
+ return _tagService.All().ToResource();
+ }
+
+ private int Create(TagResource resource)
+ {
+ return _tagService.Add(resource.ToModel()).Id;
+ }
+
+ private void Update(TagResource resource)
+ {
+ _tagService.Update(resource.ToModel());
+ }
+
+ private void Delete(int id)
+ {
+ _tagService.Delete(id);
+ }
+
+ public void Handle(TagsUpdatedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Sync);
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/Tags/TagResource.cs b/src/Lidarr.Api.V3/Tags/TagResource.cs
similarity index 94%
rename from src/NzbDrone.Api/Tags/TagResource.cs
rename to src/Lidarr.Api.V3/Tags/TagResource.cs
index 678107bf5..1c95024f2 100644
--- a/src/NzbDrone.Api/Tags/TagResource.cs
+++ b/src/Lidarr.Api.V3/Tags/TagResource.cs
@@ -1,9 +1,9 @@
using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
using NzbDrone.Core.Tags;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Tags
+namespace Lidarr.Api.V3.Tags
{
public class TagResource : RestResource
{
@@ -19,7 +19,6 @@ namespace NzbDrone.Api.Tags
return new TagResource
{
Id = model.Id,
-
Label = model.Label
};
}
@@ -31,7 +30,6 @@ namespace NzbDrone.Api.Tags
return new Tag
{
Id = resource.Id,
-
Label = resource.Label
};
}
diff --git a/src/Lidarr.Api.V3/TrackFiles/MediaInfoResource.cs b/src/Lidarr.Api.V3/TrackFiles/MediaInfoResource.cs
new file mode 100644
index 000000000..7ebdd974b
--- /dev/null
+++ b/src/Lidarr.Api.V3/TrackFiles/MediaInfoResource.cs
@@ -0,0 +1,28 @@
+using NzbDrone.Core.MediaFiles.MediaInfo;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.TrackFiles
+{
+ public class MediaInfoResource : RestResource
+ {
+ public decimal AudioChannels { get; set; }
+ public string AudioCodec { get; set; }
+ }
+
+ public static class MediaInfoResourceMapper
+ {
+ public static MediaInfoResource ToResource(this MediaInfoModel model, string sceneName)
+ {
+ if (model == null)
+ {
+ return null;
+ }
+
+ return new MediaInfoResource
+ {
+ AudioChannels = MediaInfoFormatter.FormatAudioChannels(model),
+ AudioCodec = MediaInfoFormatter.FormatAudioCodec(model)
+ };
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/TrackFiles/TrackFileListResource.cs b/src/Lidarr.Api.V3/TrackFiles/TrackFileListResource.cs
new file mode 100644
index 000000000..d8bf09018
--- /dev/null
+++ b/src/Lidarr.Api.V3/TrackFiles/TrackFileListResource.cs
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.Qualities;
+
+namespace Lidarr.Api.V3.TrackFiles
+{
+ public class TrackFileListResource
+ {
+ public List TrackFileIds { get; set; }
+ public Language Language { get; set; }
+ public QualityModel Quality { get; set; }
+ }
+}
diff --git a/src/Lidarr.Api.V3/TrackFiles/TrackFileModule.cs b/src/Lidarr.Api.V3/TrackFiles/TrackFileModule.cs
new file mode 100644
index 000000000..753820b29
--- /dev/null
+++ b/src/Lidarr.Api.V3/TrackFiles/TrackFileModule.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Nancy;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.MediaFiles.Events;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.Core.Music;
+using NzbDrone.SignalR;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+using NzbDrone.Core.Exceptions;
+using HttpStatusCode = System.Net.HttpStatusCode;
+
+namespace Lidarr.Api.V3.TrackFiles
+{
+ public class TrackModule : LidarrRestModuleWithSignalR,
+ IHandle
+ {
+ private readonly IMediaFileService _mediaFileService;
+ private readonly IDeleteMediaFiles _mediaFileDeletionService;
+ private readonly IArtistService _artistService;
+ private readonly IAlbumService _albumService;
+ private readonly IUpgradableSpecification _upgradableSpecification;
+
+ public TrackModule(IBroadcastSignalRMessage signalRBroadcaster,
+ IMediaFileService mediaFileService,
+ IDeleteMediaFiles mediaFileDeletionService,
+ IArtistService artistService,
+ IAlbumService albumService,
+ IUpgradableSpecification upgradableSpecification)
+ : base(signalRBroadcaster)
+ {
+ _mediaFileService = mediaFileService;
+ _mediaFileDeletionService = mediaFileDeletionService;
+ _artistService = artistService;
+ _albumService = albumService;
+ _upgradableSpecification = upgradableSpecification;
+
+ GetResourceById = GetTrackFile;
+ GetResourceAll = GetTrackFiles;
+ UpdateResource = SetQuality;
+ DeleteResource = DeleteTrackFile;
+
+ Put["/editor"] = trackFiles => SetQuality();
+ Delete["/bulk"] = trackFiles => DeleteTrackFiles();
+ }
+
+ private TrackFileResource GetTrackFile(int id)
+ {
+ var trackFile = _mediaFileService.Get(id);
+ var artist = _artistService.GetArtist(trackFile.ArtistId);
+
+ return trackFile.ToResource(artist, _upgradableSpecification);
+ }
+
+ private List GetTrackFiles()
+ {
+ var artistIdQuery = Request.Query.ArtistId;
+ var trackFileIdsQuery = Request.Query.TrackFileIds;
+ var albumIdQuery = Request.Query.AlbumId;
+
+ if (!artistIdQuery.HasValue && !trackFileIdsQuery.HasValue && !albumIdQuery.HasValue)
+ {
+ throw new Lidarr.Http.REST.BadRequestException("artistId, albumId, or trackFileIds must be provided");
+ }
+
+ if (artistIdQuery.HasValue && !albumIdQuery.HasValue)
+ {
+ int artistId = Convert.ToInt32(artistIdQuery.Value);
+ var artist = _artistService.GetArtist(artistId);
+
+ return _mediaFileService.GetFilesByArtist(artistId).ConvertAll(f => f.ToResource(artist, _upgradableSpecification));
+ }
+
+ if (albumIdQuery.HasValue)
+ {
+ int albumId = Convert.ToInt32(albumIdQuery.Value);
+ var album = _albumService.GetAlbum(albumId);
+
+ return _mediaFileService.GetFilesByAlbum(album.ArtistId, album.Id).ConvertAll(f => f.ToResource(album.Artist, _upgradableSpecification));
+ }
+
+ else
+ {
+ string trackFileIdsValue = trackFileIdsQuery.Value.ToString();
+
+ var trackFileIds = trackFileIdsValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(e => Convert.ToInt32(e))
+ .ToList();
+
+ var trackFiles = _mediaFileService.Get(trackFileIds);
+
+ return trackFiles.GroupBy(e => e.ArtistId)
+ .SelectMany(f => f.ToList()
+ .ConvertAll(e => e.ToResource(_artistService.GetArtist(f.Key), _upgradableSpecification)))
+ .ToList();
+ }
+ }
+
+ private void SetQuality(TrackFileResource trackFileResource)
+ {
+ var trackFile = _mediaFileService.Get(trackFileResource.Id);
+ trackFile.Quality = trackFileResource.Quality;
+ _mediaFileService.Update(trackFile);
+ }
+
+ private Response SetQuality()
+ {
+ var resource = Request.Body.FromJson();
+ var trackFiles = _mediaFileService.GetFiles(resource.TrackFileIds);
+
+ foreach (var trackFile in trackFiles)
+ {
+ if (resource.Language != null)
+ {
+ trackFile.Language = resource.Language;
+ }
+
+ if (resource.Quality != null)
+ {
+ trackFile.Quality = resource.Quality;
+ }
+ }
+
+ _mediaFileService.Update(trackFiles);
+
+ var artist = _artistService.GetArtist(trackFiles.First().ArtistId);
+
+ return trackFiles.ConvertAll(f => f.ToResource(artist, _upgradableSpecification))
+ .AsResponse(Nancy.HttpStatusCode.Accepted);
+ }
+
+ private void DeleteTrackFile(int id)
+ {
+ var trackFile = _mediaFileService.Get(id);
+
+ if (trackFile == null)
+ {
+ throw new NzbDroneClientException(HttpStatusCode.NotFound, "Track file not found");
+ }
+
+ var artist = _artistService.GetArtist(trackFile.ArtistId);
+ var fullPath = Path.Combine(artist.Path, trackFile.RelativePath);
+
+ _mediaFileDeletionService.DeleteTrackFile(artist, trackFile);
+ }
+
+ private Response DeleteTrackFiles()
+ {
+ var resource = Request.Body.FromJson();
+ var trackFiles = _mediaFileService.GetFiles(resource.TrackFileIds);
+ var artist = _artistService.GetArtist(trackFiles.First().ArtistId);
+
+ foreach (var trackFile in trackFiles)
+ {
+ var fullPath = Path.Combine(artist.Path, trackFile.RelativePath);
+
+ _mediaFileDeletionService.DeleteTrackFile(artist, trackFile);
+ }
+
+ return new object().AsResponse();
+ }
+
+ public void Handle(TrackFileAddedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Updated, message.TrackFile.Id);
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/TrackFiles/TrackFileResource.cs b/src/Lidarr.Api.V3/TrackFiles/TrackFileResource.cs
new file mode 100644
index 000000000..be6deaabe
--- /dev/null
+++ b/src/Lidarr.Api.V3/TrackFiles/TrackFileResource.cs
@@ -0,0 +1,78 @@
+using System;
+using System.IO;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.Qualities;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.TrackFiles
+{
+ public class TrackFileResource : RestResource
+ {
+ public int ArtistId { get; set; }
+ public int AlbumId { get; set; }
+ public string RelativePath { get; set; }
+ public string Path { get; set; }
+ public long Size { get; set; }
+ public DateTime DateAdded { get; set; }
+ //public string SceneName { get; set; }
+ public Language Language { get; set; }
+ public QualityModel Quality { get; set; }
+ public MediaInfoResource MediaInfo { get; set; }
+
+ public bool QualityCutoffNotMet { get; set; }
+ }
+
+ public static class TrackFileResourceMapper
+ {
+ private static TrackFileResource ToResource(this TrackFile model)
+ {
+ if (model == null) return null;
+
+ return new TrackFileResource
+ {
+ Id = model.Id,
+
+ ArtistId = model.ArtistId,
+ AlbumId = model.AlbumId,
+ RelativePath = model.RelativePath,
+ //Path
+ Size = model.Size,
+ DateAdded = model.DateAdded,
+ // SceneName = model.SceneName,
+ Language = model.Language,
+ Quality = model.Quality,
+ MediaInfo = model.MediaInfo.ToResource(model.SceneName)
+ //QualityCutoffNotMet
+ };
+
+ }
+
+ public static TrackFileResource ToResource(this TrackFile model, NzbDrone.Core.Music.Artist artist, IUpgradableSpecification upgradableSpecification)
+ {
+ if (model == null) return null;
+
+ return new TrackFileResource
+ {
+ Id = model.Id,
+
+ ArtistId = model.ArtistId,
+ AlbumId = model.AlbumId,
+ RelativePath = model.RelativePath,
+ Path = Path.Combine(artist.Path, model.RelativePath),
+ Size = model.Size,
+ DateAdded = model.DateAdded,
+ //SceneName = model.SceneName,
+ Language = model.Language,
+ Quality = model.Quality,
+ MediaInfo = model.MediaInfo.ToResource(model.SceneName),
+
+ QualityCutoffNotMet = upgradableSpecification.CutoffNotMet(artist.Profile.Value,
+ artist.LanguageProfile.Value,
+ model.Quality,
+ model.Language)
+ };
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Tracks/RenameTrackModule.cs b/src/Lidarr.Api.V3/Tracks/RenameTrackModule.cs
new file mode 100644
index 000000000..073acdff4
--- /dev/null
+++ b/src/Lidarr.Api.V3/Tracks/RenameTrackModule.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using NzbDrone.Core.MediaFiles;
+using Lidarr.Http;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Tracks
+{
+ public class RenameTrackModule : LidarrRestModule
+ {
+ private readonly IRenameTrackFileService _renameTrackFileService;
+
+ public RenameTrackModule(IRenameTrackFileService renameTrackFileService)
+ : base("rename")
+ {
+ _renameTrackFileService = renameTrackFileService;
+
+ GetResourceAll = GetTracks;
+ }
+
+ private List GetTracks()
+ {
+ int artistId;
+
+ if (Request.Query.ArtistId.HasValue)
+ {
+ artistId = (int)Request.Query.ArtistId;
+ }
+
+ else
+ {
+ throw new BadRequestException("artistId is missing");
+ }
+
+ if (Request.Query.albumId.HasValue)
+ {
+ var albumId = (int)Request.Query.albumId;
+ return _renameTrackFileService.GetRenamePreviews(artistId, albumId).ToResource();
+ }
+
+ return _renameTrackFileService.GetRenamePreviews(artistId).ToResource();
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/Tracks/RenameTrackResource.cs b/src/Lidarr.Api.V3/Tracks/RenameTrackResource.cs
similarity index 78%
rename from src/NzbDrone.Api/Tracks/RenameTrackResource.cs
rename to src/Lidarr.Api.V3/Tracks/RenameTrackResource.cs
index 12f67bb60..9bfe676cb 100644
--- a/src/NzbDrone.Api/Tracks/RenameTrackResource.cs
+++ b/src/Lidarr.Api.V3/Tracks/RenameTrackResource.cs
@@ -1,8 +1,8 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
-using NzbDrone.Api.REST;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Tracks
+namespace Lidarr.Api.V3.Tracks
{
public class RenameTrackResource : RestResource
{
@@ -16,7 +16,7 @@ namespace NzbDrone.Api.Tracks
public static class RenameTrackResourceMapper
{
- public static RenameTrackResource ToResource(this Core.MediaFiles.RenameTrackFilePreview model)
+ public static RenameTrackResource ToResource(this NzbDrone.Core.MediaFiles.RenameTrackFilePreview model)
{
if (model == null) return null;
@@ -31,7 +31,7 @@ namespace NzbDrone.Api.Tracks
};
}
- public static List ToResource(this IEnumerable models)
+ public static List ToResource(this IEnumerable models)
{
return models.Select(ToResource).ToList();
}
diff --git a/src/Lidarr.Api.V3/Tracks/TrackModule.cs b/src/Lidarr.Api.V3/Tracks/TrackModule.cs
new file mode 100644
index 000000000..32d761d9d
--- /dev/null
+++ b/src/Lidarr.Api.V3/Tracks/TrackModule.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Nancy;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Music;
+using NzbDrone.SignalR;
+using Lidarr.Http.Extensions;
+using Lidarr.Http.REST;
+
+namespace Lidarr.Api.V3.Tracks
+{
+ public class TrackModule : TrackModuleWithSignalR
+ {
+ public TrackModule(IArtistService artistService,
+ ITrackService trackService,
+ IUpgradableSpecification upgradableSpecification,
+ IBroadcastSignalRMessage signalRBroadcaster)
+ : base(trackService, artistService, upgradableSpecification, signalRBroadcaster)
+ {
+ GetResourceAll = GetTracks;
+ }
+
+ private List GetTracks()
+ {
+ var artistIdQuery = Request.Query.ArtistId;
+ var albumIdQuery = Request.Query.AlbumId;
+ var trackIdsQuery = Request.Query.TrackIds;
+
+ if (!artistIdQuery.HasValue && !trackIdsQuery.HasValue && !albumIdQuery.HasValue)
+ {
+ throw new BadRequestException("artistId or trackIds must be provided");
+ }
+
+ if (artistIdQuery.HasValue && !albumIdQuery.HasValue)
+ {
+ int artistId = Convert.ToInt32(artistIdQuery.Value);
+
+ return MapToResource(_trackService.GetTracksByArtist(artistId), false, false);
+ }
+
+ if (albumIdQuery.HasValue)
+ {
+ int albumId = Convert.ToInt32(albumIdQuery.Value);
+
+ return MapToResource(_trackService.GetTracksByAlbum(albumId), false, false);
+ }
+
+ string trackIdsValue = trackIdsQuery.Value.ToString();
+
+ var trackIds = trackIdsValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(e => Convert.ToInt32(e))
+ .ToList();
+
+ return MapToResource(_trackService.GetTracks(trackIds), false, false);
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Tracks/TrackModuleWithSignalR.cs b/src/Lidarr.Api.V3/Tracks/TrackModuleWithSignalR.cs
new file mode 100644
index 000000000..bd4f3f654
--- /dev/null
+++ b/src/Lidarr.Api.V3/Tracks/TrackModuleWithSignalR.cs
@@ -0,0 +1,132 @@
+using System.Collections.Generic;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Download;
+using NzbDrone.Core.MediaFiles.Events;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.Core.Music;
+using NzbDrone.SignalR;
+using Lidarr.Api.V3.TrackFiles;
+using Lidarr.Api.V3.Artist;
+using Lidarr.Http;
+
+namespace Lidarr.Api.V3.Tracks
+{
+ public abstract class TrackModuleWithSignalR : LidarrRestModuleWithSignalR
+ //IHandle,
+ //IHandle
+ {
+ protected readonly ITrackService _trackService;
+ protected readonly IArtistService _artistService;
+ protected readonly IUpgradableSpecification _upgradableSpecification;
+
+ protected TrackModuleWithSignalR(ITrackService trackService,
+ IArtistService artistService,
+ IUpgradableSpecification upgradableSpecification,
+ IBroadcastSignalRMessage signalRBroadcaster)
+ : base(signalRBroadcaster)
+ {
+ _trackService = trackService;
+ _artistService = artistService;
+ _upgradableSpecification = upgradableSpecification;
+
+ GetResourceById = GetEpisode;
+ }
+
+ protected TrackModuleWithSignalR(ITrackService episodeService,
+ IArtistService seriesService,
+ IUpgradableSpecification upgradableSpecification,
+ IBroadcastSignalRMessage signalRBroadcaster,
+ string resource)
+ : base(signalRBroadcaster, resource)
+ {
+ _trackService = episodeService;
+ _artistService = seriesService;
+ _upgradableSpecification = upgradableSpecification;
+
+ GetResourceById = GetEpisode;
+ }
+
+ protected TrackResource GetEpisode(int id)
+ {
+ var episode = _trackService.GetTrack(id);
+ var resource = MapToResource(episode, true, true);
+ return resource;
+ }
+
+ protected TrackResource MapToResource(Track track, bool includeArtist, bool includeTrackFile)
+ {
+ var resource = track.ToResource();
+
+ if (includeArtist || includeTrackFile)
+ {
+ var artist = track.Artist ?? _artistService.GetArtist(track.ArtistId);
+
+ if (includeArtist)
+ {
+ resource.Artist = artist.ToResource();
+ }
+ if (includeTrackFile && track.TrackFileId != 0)
+ {
+ resource.TrackFile = track.TrackFile.Value.ToResource(artist, _upgradableSpecification);
+ }
+ }
+
+ return resource;
+ }
+
+ protected List MapToResource(List episodes, bool includeSeries, bool includeEpisodeFile)
+ {
+ var result = episodes.ToResource();
+
+ if (includeSeries || includeEpisodeFile)
+ {
+ var seriesDict = new Dictionary();
+ for (var i = 0; i < episodes.Count; i++)
+ {
+ var episode = episodes[i];
+ var resource = result[i];
+
+ var series = episode.Artist ?? seriesDict.GetValueOrDefault(episodes[i].ArtistId) ?? _artistService.GetArtist(episodes[i].ArtistId);
+ seriesDict[series.Id] = series;
+
+ if (includeSeries)
+ {
+ resource.Artist = series.ToResource();
+ }
+ if (includeEpisodeFile && episodes[i].TrackFileId != 0)
+ {
+ resource.TrackFile = episodes[i].TrackFile.Value.ToResource(series, _upgradableSpecification);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ //public void Handle(EpisodeGrabbedEvent message)
+ //{
+ // foreach (var episode in message.Episode.Episodes)
+ // {
+ // var resource = episode.ToResource();
+ // resource.Grabbed = true;
+
+ // BroadcastResourceChange(ModelAction.Updated, resource);
+ // }
+ //}
+
+ //public void Handle(EpisodeImportedEvent message)
+ //{
+ // if (!message.NewDownload)
+ // {
+ // return;
+ // }
+
+ // foreach (var episode in message.EpisodeInfo.Episodes)
+ // {
+ // BroadcastResourceChange(ModelAction.Updated, episode.Id);
+ // }
+ //}
+ }
+}
diff --git a/src/NzbDrone.Api/Tracks/TrackResource.cs b/src/Lidarr.Api.V3/Tracks/TrackResource.cs
similarity index 94%
rename from src/NzbDrone.Api/Tracks/TrackResource.cs
rename to src/Lidarr.Api.V3/Tracks/TrackResource.cs
index b8a007671..222394c52 100644
--- a/src/NzbDrone.Api/Tracks/TrackResource.cs
+++ b/src/Lidarr.Api.V3/Tracks/TrackResource.cs
@@ -1,13 +1,13 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
-using NzbDrone.Api.TrackFiles;
-using NzbDrone.Api.REST;
-using NzbDrone.Api.Music;
using NzbDrone.Core.Music;
+using Lidarr.Api.V3.TrackFiles;
+using Lidarr.Api.V3.Artist;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Tracks
+namespace Lidarr.Api.V3.Tracks
{
public class TrackResource : RestResource
{
diff --git a/src/NzbDrone.Api/Update/UpdateModule.cs b/src/Lidarr.Api.V3/Update/UpdateModule.cs
similarity index 91%
rename from src/NzbDrone.Api/Update/UpdateModule.cs
rename to src/Lidarr.Api.V3/Update/UpdateModule.cs
index 2104f23ea..9aa4c2c94 100644
--- a/src/NzbDrone.Api/Update/UpdateModule.cs
+++ b/src/Lidarr.Api.V3/Update/UpdateModule.cs
@@ -2,10 +2,11 @@
using System.Linq;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Update;
+using Lidarr.Http;
-namespace NzbDrone.Api.Update
+namespace Lidarr.Api.V3.Update
{
- public class UpdateModule : NzbDroneRestModule
+ public class UpdateModule : LidarrRestModule
{
private readonly IRecentUpdateProvider _recentUpdateProvider;
diff --git a/src/NzbDrone.Api/Update/UpdateResource.cs b/src/Lidarr.Api.V3/Update/UpdateResource.cs
similarity index 96%
rename from src/NzbDrone.Api/Update/UpdateResource.cs
rename to src/Lidarr.Api.V3/Update/UpdateResource.cs
index dca6f6725..2ed916cdc 100644
--- a/src/NzbDrone.Api/Update/UpdateResource.cs
+++ b/src/Lidarr.Api.V3/Update/UpdateResource.cs
@@ -2,10 +2,10 @@
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
-using NzbDrone.Api.REST;
using NzbDrone.Core.Update;
+using Lidarr.Http.REST;
-namespace NzbDrone.Api.Update
+namespace Lidarr.Api.V3.Update
{
public class UpdateResource : RestResource
{
diff --git a/src/Lidarr.Api.V3/Wanted/CutoffModule.cs b/src/Lidarr.Api.V3/Wanted/CutoffModule.cs
new file mode 100644
index 000000000..e104269fe
--- /dev/null
+++ b/src/Lidarr.Api.V3/Wanted/CutoffModule.cs
@@ -0,0 +1,54 @@
+using NzbDrone.Core.Datastore;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Music;
+using NzbDrone.Core.ArtistStats;
+using NzbDrone.SignalR;
+using Lidarr.Api.V3.Albums;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3.Wanted
+{
+ public class CutoffModule : AlbumModuleWithSignalR
+ {
+ private readonly IAlbumCutoffService _albumCutoffService;
+
+ public CutoffModule(IAlbumCutoffService albumCutoffService,
+ IAlbumService albumService,
+ IArtistStatisticsService artistStatisticsService,
+ IArtistService artistService,
+ IUpgradableSpecification upgradableSpecification,
+ IBroadcastSignalRMessage signalRBroadcaster)
+ : base(albumService, artistStatisticsService, artistService, upgradableSpecification, signalRBroadcaster, "wanted/cutoff")
+ {
+ _albumCutoffService = albumCutoffService;
+ GetResourcePaged = GetCutoffUnmetAlbums;
+ }
+
+ private PagingResource GetCutoffUnmetAlbums(PagingResource pagingResource)
+ {
+ var pagingSpec = new PagingSpec
+ {
+ Page = pagingResource.Page,
+ PageSize = pagingResource.PageSize,
+ SortKey = pagingResource.SortKey,
+ SortDirection = pagingResource.SortDirection
+ };
+
+ var includeArtist = Request.GetBooleanQueryParameter("includeArtist");
+
+ if (pagingResource.FilterKey == "monitored" && pagingResource.FilterValue == "false")
+ {
+ pagingSpec.FilterExpression = v => v.Monitored == false || v.Artist.Monitored == false;
+ }
+ else
+ {
+ pagingSpec.FilterExpression = v => v.Monitored == true && v.Artist.Monitored == true;
+ }
+
+ var resource = ApplyToPage(_albumCutoffService.AlbumsWhereCutoffUnmet, pagingSpec, v => MapToResource(v, includeArtist));
+
+ return resource;
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/Wanted/MissingModule.cs b/src/Lidarr.Api.V3/Wanted/MissingModule.cs
new file mode 100644
index 000000000..05eb9b8a5
--- /dev/null
+++ b/src/Lidarr.Api.V3/Wanted/MissingModule.cs
@@ -0,0 +1,50 @@
+using NzbDrone.Core.Datastore;
+using NzbDrone.Core.DecisionEngine;
+using NzbDrone.Core.Music;
+using NzbDrone.Core.ArtistStats;
+using NzbDrone.SignalR;
+using Lidarr.Api.V3.Albums;
+using Lidarr.Http;
+using Lidarr.Http.Extensions;
+
+namespace Lidarr.Api.V3.Wanted
+{
+ public class MissingModule : AlbumModuleWithSignalR
+ {
+ public MissingModule(IAlbumService albumService,
+ IArtistStatisticsService artistStatisticsService,
+ IArtistService artistService,
+ IUpgradableSpecification upgradableSpecification,
+ IBroadcastSignalRMessage signalRBroadcaster)
+ : base(albumService, artistStatisticsService, artistService, upgradableSpecification, signalRBroadcaster, "wanted/missing")
+ {
+ GetResourcePaged = GetMissingAlbums;
+ }
+
+ private PagingResource GetMissingAlbums(PagingResource pagingResource)
+ {
+ var pagingSpec = new PagingSpec
+ {
+ Page = pagingResource.Page,
+ PageSize = pagingResource.PageSize,
+ SortKey = pagingResource.SortKey,
+ SortDirection = pagingResource.SortDirection
+ };
+
+ var includeArtist = Request.GetBooleanQueryParameter("includeArtist");
+
+ if (pagingResource.FilterKey == "monitored" && pagingResource.FilterValue == "false")
+ {
+ pagingSpec.FilterExpression = v => v.Monitored == false || v.Artist.Monitored == false;
+ }
+ else
+ {
+ pagingSpec.FilterExpression = v => v.Monitored == true && v.Artist.Monitored == true;
+ }
+
+ var resource = ApplyToPage(_albumService.AlbumsWithoutFiles, pagingSpec, v => MapToResource(v, includeArtist));
+
+ return resource;
+ }
+ }
+}
diff --git a/src/Lidarr.Api.V3/app.config b/src/Lidarr.Api.V3/app.config
new file mode 100644
index 000000000..d4d6857aa
--- /dev/null
+++ b/src/Lidarr.Api.V3/app.config
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Lidarr.Api.V3/packages.config b/src/Lidarr.Api.V3/packages.config
new file mode 100644
index 000000000..84bf483de
--- /dev/null
+++ b/src/Lidarr.Api.V3/packages.config
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Authentication/1tews5g3.gd1~ b/src/Lidarr.Http/Authentication/1tews5g3.gd1~
similarity index 100%
rename from src/NzbDrone.Api/Authentication/1tews5g3.gd1~
rename to src/Lidarr.Http/Authentication/1tews5g3.gd1~
diff --git a/src/NzbDrone.Api/Authentication/AuthenticationModule.cs b/src/Lidarr.Http/Authentication/AuthenticationModule.cs
similarity index 89%
rename from src/NzbDrone.Api/Authentication/AuthenticationModule.cs
rename to src/Lidarr.Http/Authentication/AuthenticationModule.cs
index df940a947..8feee4589 100644
--- a/src/NzbDrone.Api/Authentication/AuthenticationModule.cs
+++ b/src/Lidarr.Http/Authentication/AuthenticationModule.cs
@@ -7,7 +7,7 @@ using NzbDrone.Common.EnsureThat;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration;
-namespace NzbDrone.Api.Authentication
+namespace Lidarr.Http.Authentication
{
public class AuthenticationModule : NancyModule
{
@@ -33,7 +33,8 @@ namespace NzbDrone.Api.Authentication
if (user == null)
{
- return Context.GetRedirect("~/login?returnUrl=" + (string)Request.Query.returnUrl);
+ var returnUrl = (string)Request.Query.returnUrl;
+ return Context.GetRedirect($"~/login?returnUrl={returnUrl}&loginFailed=true");
}
DateTime? expiry = null;
diff --git a/src/NzbDrone.Api/Authentication/AuthenticationService.cs b/src/Lidarr.Http/Authentication/AuthenticationService.cs
similarity index 95%
rename from src/NzbDrone.Api/Authentication/AuthenticationService.cs
rename to src/Lidarr.Http/Authentication/AuthenticationService.cs
index beb908b11..97436e979 100644
--- a/src/NzbDrone.Api/Authentication/AuthenticationService.cs
+++ b/src/Lidarr.Http/Authentication/AuthenticationService.cs
@@ -4,12 +4,12 @@ using Nancy;
using Nancy.Authentication.Basic;
using Nancy.Authentication.Forms;
using Nancy.Security;
-using NzbDrone.Api.Extensions;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration;
+using Lidarr.Http.Extensions;
-namespace NzbDrone.Api.Authentication
+namespace Lidarr.Http.Authentication
{
public interface IAuthenticationService : IUserValidator, IUserMapper
{
@@ -18,7 +18,6 @@ namespace NzbDrone.Api.Authentication
public class AuthenticationService : IAuthenticationService
{
- private readonly IConfigFileProvider _configFileProvider;
private readonly IUserService _userService;
private static readonly NzbDroneUser AnonymousUser = new NzbDroneUser { UserName = "Anonymous" };
@@ -27,7 +26,6 @@ namespace NzbDrone.Api.Authentication
public AuthenticationService(IConfigFileProvider configFileProvider, IUserService userService)
{
- _configFileProvider = configFileProvider;
_userService = userService;
API_KEY = configFileProvider.ApiKey;
AUTH_METHOD = configFileProvider.AuthenticationMethod;
diff --git a/src/Lidarr.Http/Authentication/EnableAuthInNancy.cs b/src/Lidarr.Http/Authentication/EnableAuthInNancy.cs
new file mode 100644
index 000000000..1d619583d
--- /dev/null
+++ b/src/Lidarr.Http/Authentication/EnableAuthInNancy.cs
@@ -0,0 +1,136 @@
+using System;
+using System.Text;
+using Nancy;
+using Nancy.Authentication.Basic;
+using Nancy.Authentication.Forms;
+using Nancy.Bootstrapper;
+using Nancy.Cookies;
+using Nancy.Cryptography;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Authentication;
+using NzbDrone.Core.Configuration;
+using Lidarr.Http.Extensions;
+using Lidarr.Http.Extensions.Pipelines;
+
+namespace Lidarr.Http.Authentication
+{
+ public class EnableAuthInNancy : IRegisterNancyPipeline
+ {
+ private readonly IAuthenticationService _authenticationService;
+ private readonly IConfigService _configService;
+ private readonly IConfigFileProvider _configFileProvider;
+ private FormsAuthenticationConfiguration FormsAuthConfig;
+
+ public EnableAuthInNancy(IAuthenticationService authenticationService,
+ IConfigService configService,
+ IConfigFileProvider configFileProvider)
+ {
+ _authenticationService = authenticationService;
+ _configService = configService;
+ _configFileProvider = configFileProvider;
+ }
+
+ public int Order => 10;
+
+ public void Register(IPipelines pipelines)
+ {
+ if (_configFileProvider.AuthenticationMethod == AuthenticationType.Forms)
+ {
+ RegisterFormsAuth(pipelines);
+ pipelines.AfterRequest.AddItemToEndOfPipeline((Action)SlidingAuthenticationForFormsAuth);
+ }
+
+ else if (_configFileProvider.AuthenticationMethod == AuthenticationType.Basic)
+ {
+ pipelines.EnableBasicAuthentication(new BasicAuthenticationConfiguration(_authenticationService, "Lidarr"));
+ }
+
+ pipelines.BeforeRequest.AddItemToEndOfPipeline((Func)RequiresAuthentication);
+ pipelines.AfterRequest.AddItemToEndOfPipeline((Action)RemoveLoginHooksForApiCalls);
+ }
+
+ private Response RequiresAuthentication(NancyContext context)
+ {
+ Response response = null;
+
+ if (!_authenticationService.IsAuthenticated(context))
+ {
+ response = new Response { StatusCode = HttpStatusCode.Unauthorized };
+ }
+
+ return response;
+ }
+
+ private void RegisterFormsAuth(IPipelines pipelines)
+ {
+ FormsAuthentication.FormsAuthenticationCookieName = "LidarrAuth";
+
+ var cryptographyConfiguration = new CryptographyConfiguration(
+ new RijndaelEncryptionProvider(new PassphraseKeyGenerator(_configService.RijndaelPassphrase, Encoding.ASCII.GetBytes(_configService.RijndaelSalt))),
+ new DefaultHmacProvider(new PassphraseKeyGenerator(_configService.HmacPassphrase, Encoding.ASCII.GetBytes(_configService.HmacSalt)))
+ );
+
+ FormsAuthConfig = new FormsAuthenticationConfiguration
+
+ {
+ RedirectUrl = _configFileProvider.UrlBase + "/login",
+ UserMapper = _authenticationService,
+ Path = GetCookiePath(),
+ CryptographyConfiguration = cryptographyConfiguration
+ };
+
+ FormsAuthentication.Enable(pipelines, FormsAuthConfig);
+ }
+
+ private void RemoveLoginHooksForApiCalls(NancyContext context)
+ {
+ if (context.Request.IsApiRequest())
+ {
+ if ((context.Response.StatusCode == HttpStatusCode.SeeOther &&
+ context.Response.Headers["Location"].StartsWith($"{_configFileProvider.UrlBase}/login", StringComparison.InvariantCultureIgnoreCase)) ||
+ context.Response.StatusCode == HttpStatusCode.Unauthorized)
+ {
+ context.Response = new { Error = "Unauthorized" }.AsResponse(HttpStatusCode.Unauthorized);
+ }
+ }
+ }
+
+ private void SlidingAuthenticationForFormsAuth(NancyContext context)
+ {
+ if (context.CurrentUser == null)
+ {
+ return;
+ }
+
+ var formsAuthCookieName = FormsAuthentication.FormsAuthenticationCookieName;
+
+ if (!context.Request.Path.Equals("/logout") &&
+ context.Request.Cookies.ContainsKey(formsAuthCookieName))
+ {
+ var formsAuthCookieValue = context.Request.Cookies[formsAuthCookieName];
+
+ if (FormsAuthentication.DecryptAndValidateAuthenticationCookie(formsAuthCookieValue, FormsAuthConfig).IsNotNullOrWhiteSpace())
+ {
+ var formsAuthCookie = new NancyCookie(formsAuthCookieName, formsAuthCookieValue, true, false, DateTime.UtcNow.AddDays(7))
+ {
+ Path = GetCookiePath()
+ };
+
+ context.Response.WithCookie(formsAuthCookie);
+ }
+ }
+ }
+
+ private string GetCookiePath()
+ {
+ var urlBase = _configFileProvider.UrlBase;
+
+ if (urlBase.IsNullOrWhiteSpace())
+ {
+ return "/";
+ }
+
+ return urlBase;
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/Authentication/LoginResource.cs b/src/Lidarr.Http/Authentication/LoginResource.cs
similarity index 81%
rename from src/NzbDrone.Api/Authentication/LoginResource.cs
rename to src/Lidarr.Http/Authentication/LoginResource.cs
index 5d6a5c9f5..db1b94513 100644
--- a/src/NzbDrone.Api/Authentication/LoginResource.cs
+++ b/src/Lidarr.Http/Authentication/LoginResource.cs
@@ -1,4 +1,4 @@
-namespace NzbDrone.Api.Authentication
+namespace Lidarr.Http.Authentication
{
public class LoginResource
{
diff --git a/src/NzbDrone.Api/Authentication/NzbDroneUser.cs b/src/Lidarr.Http/Authentication/NzbDroneUser.cs
similarity index 85%
rename from src/NzbDrone.Api/Authentication/NzbDroneUser.cs
rename to src/Lidarr.Http/Authentication/NzbDroneUser.cs
index c8fce02fd..a83a0fda5 100644
--- a/src/NzbDrone.Api/Authentication/NzbDroneUser.cs
+++ b/src/Lidarr.Http/Authentication/NzbDroneUser.cs
@@ -1,7 +1,7 @@
using System.Collections.Generic;
using Nancy.Security;
-namespace NzbDrone.Api.Authentication
+namespace Lidarr.Http.Authentication
{
public class NzbDroneUser : IUserIdentity
{
diff --git a/src/NzbDrone.Api/ClientSchema/Field.cs b/src/Lidarr.Http/ClientSchema/Field.cs
similarity index 92%
rename from src/NzbDrone.Api/ClientSchema/Field.cs
rename to src/Lidarr.Http/ClientSchema/Field.cs
index ec611e8d6..332ed871d 100644
--- a/src/NzbDrone.Api/ClientSchema/Field.cs
+++ b/src/Lidarr.Http/ClientSchema/Field.cs
@@ -1,6 +1,6 @@
using System.Collections.Generic;
-namespace NzbDrone.Api.ClientSchema
+namespace Lidarr.Http.ClientSchema
{
public class Field
{
diff --git a/src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs b/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs
similarity index 99%
rename from src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs
rename to src/Lidarr.Http/ClientSchema/SchemaBuilder.cs
index 0a7acb9e1..c6b8240d5 100644
--- a/src/NzbDrone.Api/ClientSchema/SchemaBuilder.cs
+++ b/src/Lidarr.Http/ClientSchema/SchemaBuilder.cs
@@ -7,7 +7,7 @@ using NzbDrone.Common.Extensions;
using NzbDrone.Common.Reflection;
using NzbDrone.Core.Annotations;
-namespace NzbDrone.Api.ClientSchema
+namespace Lidarr.Http.ClientSchema
{
public static class SchemaBuilder
{
diff --git a/src/NzbDrone.Api/ClientSchema/SelectOption.cs b/src/Lidarr.Http/ClientSchema/SelectOption.cs
similarity index 76%
rename from src/NzbDrone.Api/ClientSchema/SelectOption.cs
rename to src/Lidarr.Http/ClientSchema/SelectOption.cs
index fe42f46dd..58029e8fa 100644
--- a/src/NzbDrone.Api/ClientSchema/SelectOption.cs
+++ b/src/Lidarr.Http/ClientSchema/SelectOption.cs
@@ -1,4 +1,4 @@
-namespace NzbDrone.Api.ClientSchema
+namespace Lidarr.Http.ClientSchema
{
public class SelectOption
{
diff --git a/src/NzbDrone.Api/ErrorManagement/ErrorHandler.cs b/src/Lidarr.Http/ErrorManagement/ErrorHandler.cs
similarity index 93%
rename from src/NzbDrone.Api/ErrorManagement/ErrorHandler.cs
rename to src/Lidarr.Http/ErrorManagement/ErrorHandler.cs
index 6ba25d741..4307f9d53 100644
--- a/src/NzbDrone.Api/ErrorManagement/ErrorHandler.cs
+++ b/src/Lidarr.Http/ErrorManagement/ErrorHandler.cs
@@ -1,8 +1,8 @@
using Nancy;
using Nancy.ErrorHandling;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http.Extensions;
-namespace NzbDrone.Api.ErrorManagement
+namespace Lidarr.Http.ErrorManagement
{
public class ErrorHandler : IStatusCodeHandler
{
diff --git a/src/NzbDrone.Api/ErrorManagement/ErrorModel.cs b/src/Lidarr.Http/ErrorManagement/ErrorModel.cs
similarity index 83%
rename from src/NzbDrone.Api/ErrorManagement/ErrorModel.cs
rename to src/Lidarr.Http/ErrorManagement/ErrorModel.cs
index b88600717..cc2b95562 100644
--- a/src/NzbDrone.Api/ErrorManagement/ErrorModel.cs
+++ b/src/Lidarr.Http/ErrorManagement/ErrorModel.cs
@@ -1,4 +1,6 @@
-namespace NzbDrone.Api.ErrorManagement
+using Lidarr.Http.Exceptions;
+
+namespace Lidarr.Http.ErrorManagement
{
public class ErrorModel
{
diff --git a/src/Lidarr.Http/ErrorManagement/LidarrErrorPipeline.cs b/src/Lidarr.Http/ErrorManagement/LidarrErrorPipeline.cs
new file mode 100644
index 000000000..dc36c83e3
--- /dev/null
+++ b/src/Lidarr.Http/ErrorManagement/LidarrErrorPipeline.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Data.SQLite;
+using FluentValidation;
+using Nancy;
+using NLog;
+using NzbDrone.Core.Exceptions;
+using Lidarr.Http.Exceptions;
+using Lidarr.Http.Extensions;
+using HttpStatusCode = Nancy.HttpStatusCode;
+
+namespace Lidarr.Http.ErrorManagement
+{
+ public class LidarrErrorPipeline
+ {
+ private readonly Logger _logger;
+
+ public LidarrErrorPipeline(Logger logger)
+ {
+ _logger = logger;
+ }
+
+ public Response HandleException(NancyContext context, Exception exception)
+ {
+ _logger.Trace("Handling Exception");
+
+ var apiException = exception as ApiException;
+
+ if (apiException != null)
+ {
+ _logger.Warn(apiException, "API Error");
+ return apiException.ToErrorResponse();
+ }
+
+ var validationException = exception as ValidationException;
+
+ if (validationException != null)
+ {
+ _logger.Warn("Invalid request {0}", validationException.Message);
+
+ return validationException.Errors.AsResponse(HttpStatusCode.BadRequest);
+ }
+
+ var clientException = exception as NzbDroneClientException;
+
+ if (clientException != null)
+ {
+ return new ErrorModel
+ {
+ Message = exception.Message,
+ Description = exception.ToString()
+ }.AsResponse((HttpStatusCode)clientException.StatusCode);
+ }
+
+ var sqLiteException = exception as SQLiteException;
+
+ if (sqLiteException != null)
+ {
+ if (context.Request.Method == "PUT" || context.Request.Method == "POST")
+ {
+ if (sqLiteException.Message.Contains("constraint failed"))
+ return new ErrorModel
+ {
+ Message = exception.Message,
+ }.AsResponse(HttpStatusCode.Conflict);
+ }
+
+ _logger.Error(sqLiteException, "[{0} {1}]", context.Request.Method, context.Request.Path);
+ }
+
+ _logger.Fatal(exception, "Request Failed. {0} {1}", context.Request.Method, context.Request.Path);
+
+ return new ErrorModel
+ {
+ Message = exception.Message,
+ Description = exception.ToString()
+ }.AsResponse(HttpStatusCode.InternalServerError);
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/ErrorManagement/ApiException.cs b/src/Lidarr.Http/Exceptions/ApiException.cs
similarity index 90%
rename from src/NzbDrone.Api/ErrorManagement/ApiException.cs
rename to src/Lidarr.Http/Exceptions/ApiException.cs
index 2a9f2678f..7823c552f 100644
--- a/src/NzbDrone.Api/ErrorManagement/ApiException.cs
+++ b/src/Lidarr.Http/Exceptions/ApiException.cs
@@ -1,9 +1,10 @@
using System;
using Nancy;
using Nancy.Responses;
-using NzbDrone.Api.Extensions;
+using Lidarr.Http.ErrorManagement;
+using Lidarr.Http.Extensions;
-namespace NzbDrone.Api.ErrorManagement
+namespace Lidarr.Http.Exceptions
{
public abstract class ApiException : Exception
{
diff --git a/src/NzbDrone.Api/Exceptions/InvalidApiKeyException.cs b/src/Lidarr.Http/Exceptions/InvalidApiKeyException.cs
similarity index 87%
rename from src/NzbDrone.Api/Exceptions/InvalidApiKeyException.cs
rename to src/Lidarr.Http/Exceptions/InvalidApiKeyException.cs
index 8c16e8133..067f0ccc4 100644
--- a/src/NzbDrone.Api/Exceptions/InvalidApiKeyException.cs
+++ b/src/Lidarr.Http/Exceptions/InvalidApiKeyException.cs
@@ -1,6 +1,6 @@
using System;
-namespace NzbDrone.Api.Exceptions
+namespace Lidarr.Http.Exceptions
{
public class InvalidApiKeyException : Exception
{
diff --git a/src/NzbDrone.Api/Extensions/AccessControlHeaders.cs b/src/Lidarr.Http/Extensions/AccessControlHeaders.cs
similarity index 92%
rename from src/NzbDrone.Api/Extensions/AccessControlHeaders.cs
rename to src/Lidarr.Http/Extensions/AccessControlHeaders.cs
index 5a32395cb..5ffc632d7 100644
--- a/src/NzbDrone.Api/Extensions/AccessControlHeaders.cs
+++ b/src/Lidarr.Http/Extensions/AccessControlHeaders.cs
@@ -1,4 +1,4 @@
-namespace NzbDrone.Api.Extensions
+namespace Lidarr.Http.Extensions
{
public static class AccessControlHeaders
{
diff --git a/src/NzbDrone.Api/Extensions/NancyJsonSerializer.cs b/src/Lidarr.Http/Extensions/NancyJsonSerializer.cs
similarity index 93%
rename from src/NzbDrone.Api/Extensions/NancyJsonSerializer.cs
rename to src/Lidarr.Http/Extensions/NancyJsonSerializer.cs
index 00b3c3b2c..ff3459de9 100644
--- a/src/NzbDrone.Api/Extensions/NancyJsonSerializer.cs
+++ b/src/Lidarr.Http/Extensions/NancyJsonSerializer.cs
@@ -3,7 +3,7 @@ using System.IO;
using Nancy;
using NzbDrone.Common.Serializer;
-namespace NzbDrone.Api.Extensions
+namespace Lidarr.Http.Extensions
{
public class NancyJsonSerializer : ISerializer
{
diff --git a/src/NzbDrone.Api/Extensions/Pipelines/CacheHeaderPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs
similarity index 92%
rename from src/NzbDrone.Api/Extensions/Pipelines/CacheHeaderPipeline.cs
rename to src/Lidarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs
index 94c738d9b..febfc17c3 100644
--- a/src/NzbDrone.Api/Extensions/Pipelines/CacheHeaderPipeline.cs
+++ b/src/Lidarr.Http/Extensions/Pipelines/CacheHeaderPipeline.cs
@@ -1,9 +1,9 @@
using System;
using Nancy;
using Nancy.Bootstrapper;
-using NzbDrone.Api.Frontend;
+using Lidarr.Http.Frontend;
-namespace NzbDrone.Api.Extensions.Pipelines
+namespace Lidarr.Http.Extensions.Pipelines
{
public class CacheHeaderPipeline : IRegisterNancyPipeline
{
diff --git a/src/NzbDrone.Api/Extensions/Pipelines/CorsPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/CorsPipeline.cs
similarity index 97%
rename from src/NzbDrone.Api/Extensions/Pipelines/CorsPipeline.cs
rename to src/Lidarr.Http/Extensions/Pipelines/CorsPipeline.cs
index b8c83298a..3f980524f 100644
--- a/src/NzbDrone.Api/Extensions/Pipelines/CorsPipeline.cs
+++ b/src/Lidarr.Http/Extensions/Pipelines/CorsPipeline.cs
@@ -3,7 +3,7 @@ using System.Linq;
using Nancy;
using Nancy.Bootstrapper;
-namespace NzbDrone.Api.Extensions.Pipelines
+namespace Lidarr.Http.Extensions.Pipelines
{
public class CorsPipeline : IRegisterNancyPipeline
{
diff --git a/src/NzbDrone.Api/Extensions/Pipelines/GZipPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/GZipPipeline.cs
similarity index 98%
rename from src/NzbDrone.Api/Extensions/Pipelines/GZipPipeline.cs
rename to src/Lidarr.Http/Extensions/Pipelines/GZipPipeline.cs
index 12293f23c..782dd5b15 100644
--- a/src/NzbDrone.Api/Extensions/Pipelines/GZipPipeline.cs
+++ b/src/Lidarr.Http/Extensions/Pipelines/GZipPipeline.cs
@@ -7,7 +7,7 @@ using Nancy.Bootstrapper;
using NLog;
using NzbDrone.Common.Extensions;
-namespace NzbDrone.Api.Extensions.Pipelines
+namespace Lidarr.Http.Extensions.Pipelines
{
public class GzipCompressionPipeline : IRegisterNancyPipeline
{
@@ -63,20 +63,24 @@ namespace NzbDrone.Api.Extensions.Pipelines
private static bool ContentLengthIsTooSmall(Response response)
{
var contentLength = response.Headers.GetValueOrDefault("Content-Length");
+
if (contentLength != null && long.Parse(contentLength) < 1024)
{
return true;
}
+
return false;
}
private static bool AlreadyGzipEncoded(Response response)
{
var contentEncoding = response.Headers.GetValueOrDefault("Content-Encoding");
+
if (contentEncoding == "gzip")
{
return true;
}
+
return false;
}
}
diff --git a/src/NzbDrone.Api/Extensions/Pipelines/IRegisterNancyPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/IRegisterNancyPipeline.cs
similarity index 78%
rename from src/NzbDrone.Api/Extensions/Pipelines/IRegisterNancyPipeline.cs
rename to src/Lidarr.Http/Extensions/Pipelines/IRegisterNancyPipeline.cs
index 0376ccc70..84105c46b 100644
--- a/src/NzbDrone.Api/Extensions/Pipelines/IRegisterNancyPipeline.cs
+++ b/src/Lidarr.Http/Extensions/Pipelines/IRegisterNancyPipeline.cs
@@ -1,6 +1,6 @@
using Nancy.Bootstrapper;
-namespace NzbDrone.Api.Extensions.Pipelines
+namespace Lidarr.Http.Extensions.Pipelines
{
public interface IRegisterNancyPipeline
{
diff --git a/src/NzbDrone.Api/Extensions/Pipelines/IfModifiedPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs
similarity index 93%
rename from src/NzbDrone.Api/Extensions/Pipelines/IfModifiedPipeline.cs
rename to src/Lidarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs
index 68abf4ade..cf619745d 100644
--- a/src/NzbDrone.Api/Extensions/Pipelines/IfModifiedPipeline.cs
+++ b/src/Lidarr.Http/Extensions/Pipelines/IfModifiedPipeline.cs
@@ -1,9 +1,9 @@
using System;
using Nancy;
using Nancy.Bootstrapper;
-using NzbDrone.Api.Frontend;
+using Lidarr.Http.Frontend;
-namespace NzbDrone.Api.Extensions.Pipelines
+namespace Lidarr.Http.Extensions.Pipelines
{
public class IfModifiedPipeline : IRegisterNancyPipeline
{
diff --git a/src/Lidarr.Http/Extensions/Pipelines/LidarrVersionPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/LidarrVersionPipeline.cs
new file mode 100644
index 000000000..1a425031d
--- /dev/null
+++ b/src/Lidarr.Http/Extensions/Pipelines/LidarrVersionPipeline.cs
@@ -0,0 +1,25 @@
+using System;
+using Nancy;
+using Nancy.Bootstrapper;
+using NzbDrone.Common.EnvironmentInfo;
+
+namespace Lidarr.Http.Extensions.Pipelines
+{
+ public class LidarrVersionPipeline : IRegisterNancyPipeline
+ {
+ public int Order => 0;
+
+ public void Register(IPipelines pipelines)
+ {
+ pipelines.AfterRequest.AddItemToStartOfPipeline((Action) Handle);
+ }
+
+ private void Handle(NancyContext context)
+ {
+ if (!context.Response.Headers.ContainsKey("X-ApplicationVersion"))
+ {
+ context.Response.Headers.Add("X-ApplicationVersion", BuildInfo.Version.ToString());
+ }
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/Extensions/Pipelines/RequestLoggingPipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/RequestLoggingPipeline.cs
similarity index 91%
rename from src/NzbDrone.Api/Extensions/Pipelines/RequestLoggingPipeline.cs
rename to src/Lidarr.Http/Extensions/Pipelines/RequestLoggingPipeline.cs
index 73668bc81..34d40cd5a 100644
--- a/src/NzbDrone.Api/Extensions/Pipelines/RequestLoggingPipeline.cs
+++ b/src/Lidarr.Http/Extensions/Pipelines/RequestLoggingPipeline.cs
@@ -1,91 +1,93 @@
-using System;
-using System.Threading;
-using Nancy;
-using Nancy.Bootstrapper;
-using NLog;
-using NzbDrone.Api.ErrorManagement;
-using NzbDrone.Common.Extensions;
-
-namespace NzbDrone.Api.Extensions.Pipelines
-{
- public class RequestLoggingPipeline : IRegisterNancyPipeline
- {
- private static readonly Logger _loggerHttp = LogManager.GetLogger("Http");
- private static readonly Logger _loggerApi = LogManager.GetLogger("Api");
-
- private static int _requestSequenceID;
-
- private readonly NzbDroneErrorPipeline _errorPipeline;
-
- public RequestLoggingPipeline(NzbDroneErrorPipeline errorPipeline)
- {
- _errorPipeline = errorPipeline;
- }
-
- public int Order => 100;
-
- public void Register(IPipelines pipelines)
- {
- pipelines.BeforeRequest.AddItemToStartOfPipeline(LogStart);
- pipelines.AfterRequest.AddItemToEndOfPipeline(LogEnd);
- pipelines.OnError.AddItemToEndOfPipeline(LogError);
- }
-
- private Response LogStart(NancyContext context)
- {
- var id = Interlocked.Increment(ref _requestSequenceID);
-
- context.Items["ApiRequestSequenceID"] = id;
- context.Items["ApiRequestStartTime"] = DateTime.UtcNow;
-
- var reqPath = GetRequestPathAndQuery(context.Request);
-
- _loggerHttp.Trace("Req: {0} [{1}] {2}", id, context.Request.Method, reqPath);
-
- return null;
- }
-
- private void LogEnd(NancyContext context)
- {
- var id = (int)context.Items["ApiRequestSequenceID"];
- var startTime = (DateTime)context.Items["ApiRequestStartTime"];
-
- var endTime = DateTime.UtcNow;
- var duration = endTime - startTime;
-
- var reqPath = GetRequestPathAndQuery(context.Request);
-
- _loggerHttp.Trace("Res: {0} [{1}] {2}: {3}.{4} ({5} ms)", id, context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds);
-
- if (context.Request.IsApiRequest())
- {
- _loggerApi.Debug("[{0}] {1}: {2}.{3} ({4} ms)", context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds);
- }
- }
-
- private Response LogError(NancyContext context, Exception exception)
- {
- var response = _errorPipeline.HandleException(context, exception);
-
- context.Response = response;
-
- LogEnd(context);
-
- context.Response = null;
-
- return response;
- }
-
- private static string GetRequestPathAndQuery(Request request)
- {
- if (request.Url.Query.IsNotNullOrWhiteSpace())
- {
- return string.Concat(request.Url.Path, request.Url.Query);
- }
- else
- {
- return request.Url.Path;
- }
- }
- }
-}
+using System;
+using System.Threading;
+using Nancy;
+using Nancy.Bootstrapper;
+using NLog;
+using NzbDrone.Common.Extensions;
+using Lidarr.Http.ErrorManagement;
+using Lidarr.Http.Extensions;
+using Lidarr.Http.Extensions.Pipelines;
+
+namespace NzbDrone.Api.Extensions.Pipelines
+{
+ public class RequestLoggingPipeline : IRegisterNancyPipeline
+ {
+ private static readonly Logger _loggerHttp = LogManager.GetLogger("Http");
+ private static readonly Logger _loggerApi = LogManager.GetLogger("Api");
+
+ private static int _requestSequenceID;
+
+ private readonly LidarrErrorPipeline _errorPipeline;
+
+ public RequestLoggingPipeline(LidarrErrorPipeline errorPipeline)
+ {
+ _errorPipeline = errorPipeline;
+ }
+
+ public int Order => 100;
+
+ public void Register(IPipelines pipelines)
+ {
+ pipelines.BeforeRequest.AddItemToStartOfPipeline(LogStart);
+ pipelines.AfterRequest.AddItemToEndOfPipeline(LogEnd);
+ pipelines.OnError.AddItemToEndOfPipeline(LogError);
+ }
+
+ private Response LogStart(NancyContext context)
+ {
+ var id = Interlocked.Increment(ref _requestSequenceID);
+
+ context.Items["ApiRequestSequenceID"] = id;
+ context.Items["ApiRequestStartTime"] = DateTime.UtcNow;
+
+ var reqPath = GetRequestPathAndQuery(context.Request);
+
+ _loggerHttp.Trace("Req: {0} [{1}] {2}", id, context.Request.Method, reqPath);
+
+ return null;
+ }
+
+ private void LogEnd(NancyContext context)
+ {
+ var id = (int)context.Items["ApiRequestSequenceID"];
+ var startTime = (DateTime)context.Items["ApiRequestStartTime"];
+
+ var endTime = DateTime.UtcNow;
+ var duration = endTime - startTime;
+
+ var reqPath = GetRequestPathAndQuery(context.Request);
+
+ _loggerHttp.Trace("Res: {0} [{1}] {2}: {3}.{4} ({5} ms)", id, context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds);
+
+ if (context.Request.IsApiRequest())
+ {
+ _loggerApi.Debug("[{0}] {1}: {2}.{3} ({4} ms)", context.Request.Method, reqPath, (int)context.Response.StatusCode, context.Response.StatusCode, (int)duration.TotalMilliseconds);
+ }
+ }
+
+ private Response LogError(NancyContext context, Exception exception)
+ {
+ var response = _errorPipeline.HandleException(context, exception);
+
+ context.Response = response;
+
+ LogEnd(context);
+
+ context.Response = null;
+
+ return response;
+ }
+
+ private static string GetRequestPathAndQuery(Request request)
+ {
+ if (request.Url.Query.IsNotNullOrWhiteSpace())
+ {
+ return string.Concat(request.Url.Path, request.Url.Query);
+ }
+ else
+ {
+ return request.Url.Path;
+ }
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/Extensions/Pipelines/UrlBasePipeline.cs b/src/Lidarr.Http/Extensions/Pipelines/UrlBasePipeline.cs
similarity index 100%
rename from src/NzbDrone.Api/Extensions/Pipelines/UrlBasePipeline.cs
rename to src/Lidarr.Http/Extensions/Pipelines/UrlBasePipeline.cs
diff --git a/src/NzbDrone.Api/Extensions/ReqResExtensions.cs b/src/Lidarr.Http/Extensions/ReqResExtensions.cs
similarity index 98%
rename from src/NzbDrone.Api/Extensions/ReqResExtensions.cs
rename to src/Lidarr.Http/Extensions/ReqResExtensions.cs
index 1f1d89180..78a3d911a 100644
--- a/src/NzbDrone.Api/Extensions/ReqResExtensions.cs
+++ b/src/Lidarr.Http/Extensions/ReqResExtensions.cs
@@ -6,7 +6,7 @@ using Nancy.Responses;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Serializer;
-namespace NzbDrone.Api.Extensions
+namespace Lidarr.Http.Extensions
{
public static class ReqResExtensions
{
diff --git a/src/Lidarr.Http/Extensions/RequestExtensions.cs b/src/Lidarr.Http/Extensions/RequestExtensions.cs
new file mode 100644
index 000000000..d9dc691a1
--- /dev/null
+++ b/src/Lidarr.Http/Extensions/RequestExtensions.cs
@@ -0,0 +1,52 @@
+using System;
+using Nancy;
+
+namespace Lidarr.Http.Extensions
+{
+ public static class RequestExtensions
+ {
+ public static bool IsApiRequest(this Request request)
+ {
+ return request.Path.StartsWith("/api/", StringComparison.InvariantCultureIgnoreCase);
+ }
+
+ public static bool IsFeedRequest(this Request request)
+ {
+ return request.Path.StartsWith("/feed/", StringComparison.InvariantCultureIgnoreCase);
+ }
+
+ public static bool IsSignalRRequest(this Request request)
+ {
+ return request.Path.StartsWith("/signalr/", StringComparison.InvariantCultureIgnoreCase);
+ }
+
+ public static bool IsLocalRequest(this Request request)
+ {
+ return (request.UserHostAddress.Equals("localhost") ||
+ request.UserHostAddress.Equals("127.0.0.1") ||
+ request.UserHostAddress.Equals("::1"));
+ }
+
+ public static bool IsLoginRequest(this Request request)
+ {
+ return request.Path.Equals("/login", StringComparison.InvariantCultureIgnoreCase);
+ }
+
+ public static bool IsContentRequest(this Request request)
+ {
+ return request.Path.StartsWith("/Content/", StringComparison.InvariantCultureIgnoreCase);
+ }
+
+ public static bool GetBooleanQueryParameter(this Request request, string parameter, bool defaultValue = false)
+ {
+ var parameterValue = request.Query[parameter];
+
+ if (parameterValue.HasValue)
+ {
+ return bool.Parse(parameterValue.Value);
+ }
+
+ return defaultValue;
+ }
+ }
+}
diff --git a/src/NzbDrone.Api/Frontend/CacheableSpecification.cs b/src/Lidarr.Http/Frontend/CacheableSpecification.cs
similarity index 93%
rename from src/NzbDrone.Api/Frontend/CacheableSpecification.cs
rename to src/Lidarr.Http/Frontend/CacheableSpecification.cs
index 7995c7da1..7d934530f 100644
--- a/src/NzbDrone.Api/Frontend/CacheableSpecification.cs
+++ b/src/Lidarr.Http/Frontend/CacheableSpecification.cs
@@ -3,7 +3,7 @@ using Nancy;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
-namespace NzbDrone.Api.Frontend
+namespace Lidarr.Http.Frontend
{
public interface ICacheableSpecification
{
@@ -29,7 +29,7 @@ namespace NzbDrone.Api.Frontend
}
if (context.Request.Path.StartsWith("/signalr", StringComparison.CurrentCultureIgnoreCase)) return false;
- if (context.Request.Path.EndsWith("main.js")) return false;
+ if (context.Request.Path.EndsWith("index.js")) return false;
if (context.Request.Path.StartsWith("/feed", StringComparison.CurrentCultureIgnoreCase)) return false;
if (context.Request.Path.StartsWith("/log", StringComparison.CurrentCultureIgnoreCase) &&
diff --git a/src/NzbDrone.Api/Frontend/Mappers/BackupFileMapper.cs b/src/Lidarr.Http/Frontend/Mappers/BackupFileMapper.cs
similarity index 95%
rename from src/NzbDrone.Api/Frontend/Mappers/BackupFileMapper.cs
rename to src/Lidarr.Http/Frontend/Mappers/BackupFileMapper.cs
index 9e4912524..bc79a53fb 100644
--- a/src/NzbDrone.Api/Frontend/Mappers/BackupFileMapper.cs
+++ b/src/Lidarr.Http/Frontend/Mappers/BackupFileMapper.cs
@@ -4,7 +4,7 @@ using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
-namespace NzbDrone.Api.Frontend.Mappers
+namespace Lidarr.Http.Frontend.Mappers
{
public class BackupFileMapper : StaticResourceMapperBase
{
diff --git a/src/Lidarr.Http/Frontend/Mappers/BrowserConfig.cs b/src/Lidarr.Http/Frontend/Mappers/BrowserConfig.cs
new file mode 100644
index 000000000..4d7bb0808
--- /dev/null
+++ b/src/Lidarr.Http/Frontend/Mappers/BrowserConfig.cs
@@ -0,0 +1,34 @@
+using System.IO;
+using NLog;
+using NzbDrone.Common.Disk;
+using NzbDrone.Common.EnvironmentInfo;
+using NzbDrone.Core.Configuration;
+
+namespace Lidarr.Http.Frontend.Mappers
+{
+ public class BrowserConfig : StaticResourceMapperBase
+ {
+ private readonly IAppFolderInfo _appFolderInfo;
+ private readonly IConfigFileProvider _configFileProvider;
+
+ public BrowserConfig(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger)
+ : base(diskProvider, logger)
+ {
+ _appFolderInfo = appFolderInfo;
+ _configFileProvider = configFileProvider;
+ }
+
+ public override string Map(string resourceUrl)
+ {
+ var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar);
+ path = path.Trim(Path.DirectorySeparatorChar);
+
+ return Path.ChangeExtension(Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path), "xml");
+ }
+
+ public override bool CanHandle(string resourceUrl)
+ {
+ return resourceUrl.StartsWith("/Content/Images/Icons/browserconfig");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/NzbDrone.Api/Frontend/Mappers/CacheBreakerProvider.cs b/src/Lidarr.Http/Frontend/Mappers/CacheBreakerProvider.cs
similarity index 96%
rename from src/NzbDrone.Api/Frontend/Mappers/CacheBreakerProvider.cs
rename to src/Lidarr.Http/Frontend/Mappers/CacheBreakerProvider.cs
index 53ebb2986..f165594a8 100644
--- a/src/NzbDrone.Api/Frontend/Mappers/CacheBreakerProvider.cs
+++ b/src/Lidarr.Http/Frontend/Mappers/CacheBreakerProvider.cs
@@ -3,7 +3,7 @@ using System.Linq;
using NzbDrone.Common.Crypto;
using NzbDrone.Common.Extensions;
-namespace NzbDrone.Api.Frontend.Mappers
+namespace Lidarr.Http.Frontend.Mappers
{
public interface ICacheBreakerProvider
{
diff --git a/src/NzbDrone.Api/Frontend/Mappers/FaviconMapper.cs b/src/Lidarr.Http/Frontend/Mappers/FaviconMapper.cs
similarity index 88%
rename from src/NzbDrone.Api/Frontend/Mappers/FaviconMapper.cs
rename to src/Lidarr.Http/Frontend/Mappers/FaviconMapper.cs
index 002ffa7ce..c269fa1b3 100644
--- a/src/NzbDrone.Api/Frontend/Mappers/FaviconMapper.cs
+++ b/src/Lidarr.Http/Frontend/Mappers/FaviconMapper.cs
@@ -1,10 +1,10 @@
-using System.IO;
+using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
-namespace NzbDrone.Api.Frontend.Mappers
+namespace Lidarr.Http.Frontend.Mappers
{
public class FaviconMapper : StaticResourceMapperBase
{
@@ -27,7 +27,7 @@ namespace NzbDrone.Api.Frontend.Mappers
fileName = "favicon-debug.ico";
}
- var path = Path.Combine("Content", "Images", fileName);
+ var path = Path.Combine("Content", "Images", "Icons", fileName);
return Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path);
}
diff --git a/src/NzbDrone.Api/Frontend/Mappers/IMapHttpRequestsToDisk.cs b/src/Lidarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs
similarity index 84%
rename from src/NzbDrone.Api/Frontend/Mappers/IMapHttpRequestsToDisk.cs
rename to src/Lidarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs
index 6390a2545..1bfd458ad 100644
--- a/src/NzbDrone.Api/Frontend/Mappers/IMapHttpRequestsToDisk.cs
+++ b/src/Lidarr.Http/Frontend/Mappers/IMapHttpRequestsToDisk.cs
@@ -1,7 +1,7 @@

using Nancy;
-namespace NzbDrone.Api.Frontend.Mappers
+namespace Lidarr.Http.Frontend.Mappers
{
public interface IMapHttpRequestsToDisk
{
diff --git a/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs b/src/Lidarr.Http/Frontend/Mappers/IndexHtmlMapper.cs
similarity index 87%
rename from src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs
rename to src/Lidarr.Http/Frontend/Mappers/IndexHtmlMapper.cs
index ae66b2aa2..aefe17fb6 100644
--- a/src/NzbDrone.Api/Frontend/Mappers/IndexHtmlMapper.cs
+++ b/src/Lidarr.Http/Frontend/Mappers/IndexHtmlMapper.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.IO;
using System.Text.RegularExpressions;
using Nancy;
@@ -8,7 +8,7 @@ using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Analytics;
using NzbDrone.Core.Configuration;
-namespace NzbDrone.Api.Frontend.Mappers
+namespace Lidarr.Http.Frontend.Mappers
{
public class IndexHtmlMapper : StaticResourceMapperBase
{
@@ -17,7 +17,7 @@ namespace NzbDrone.Api.Frontend.Mappers
private readonly IAnalyticsService _analyticsService;
private readonly Func _cacheBreakProviderFactory;
private readonly string _indexPath;
- private static readonly Regex ReplaceRegex = new Regex(@"(?:(?href|src)=\"")(?