Merge pull request #110 from lidarr/react

React UI Base and Many Updates
pull/111/head
Qstick 7 years ago committed by GitHub
commit 5c240cd653
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,9 @@
{
"paths": [
"frontend/src/**/*.js"
],
"ignored": [
"**/node_modules/**/*"
],
"port": 5004
}

4
.gitignore vendored

@ -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

@ -1 +0,0 @@
Lidarr

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.idea" />
<excludeFolder url="file://$MODULE_DIR$/Logo" />
<excludeFolder url="file://$MODULE_DIR$/_output" />
<excludeFolder url="file://$MODULE_DIR$/_output_mono" />
<excludeFolder url="file://$MODULE_DIR$/_output_osx" />
<excludeFolder url="file://$MODULE_DIR$/_output_osx_app" />
<excludeFolder url="file://$MODULE_DIR$/_start" />
<excludeFolder url="file://$MODULE_DIR$/_tests" />
<excludeFolder url="file://$MODULE_DIR$/debian" />
<excludeFolder url="file://$MODULE_DIR$/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/osx" />
<excludeFolder url="file://$MODULE_DIR$/schemas" />
<excludeFolder url="file://$MODULE_DIR$/setup" />
<excludeFolder url="file://$MODULE_DIR$/src" />
<excludeFolder url="file://$MODULE_DIR$/tools" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Sonarr node_modules" level="project" />
</component>
</module>

@ -1,59 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectCodeStyleSettingsManager">
<option name="PER_PROJECT_SETTINGS">
<value>
<option name="LINE_SEPARATOR" value="&#13;&#10;" />
<option name="RIGHT_MARGIN" value="190" />
<option name="HTML_ATTRIBUTE_WRAP" value="0" />
<option name="HTML_KEEP_LINE_BREAKS" value="false" />
<option name="HTML_KEEP_BLANK_LINES" value="1" />
<option name="HTML_ALIGN_ATTRIBUTES" value="false" />
<option name="HTML_INLINE_ELEMENTS" value="" />
<option name="HTML_DONT_ADD_BREAKS_IF_INLINE_CONTENT" value="" />
<CssCodeStyleSettings>
<option name="HEX_COLOR_LOWER_CASE" value="true" />
<option name="HEX_COLOR_LONG_FORMAT" value="true" />
<option name="VALUE_ALIGNMENT" value="1" />
</CssCodeStyleSettings>
<JSCodeStyleSettings>
<option name="SPACE_BEFORE_PROPERTY_COLON" value="true" />
<option name="ALIGN_OBJECT_PROPERTIES" value="2" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="OBJECT_LITERAL_WRAP" value="2" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
</JSCodeStyleSettings>
<XML>
<option name="XML_LEGACY_SETTINGS_IMPORTED" value="true" />
</XML>
<codeStyleSettings language="CSS">
<indentOptions>
<option name="SMART_TABS" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="LINE_COMMENT_AT_FIRST_COLUMN" value="true" />
<option name="KEEP_LINE_BREAKS" value="false" />
<option name="KEEP_FIRST_COLUMN_COMMENT" value="false" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="CATCH_ON_NEW_LINE" value="true" />
<option name="FINALLY_ON_NEW_LINE" value="true" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="ALIGN_MULTILINE_BINARY_OPERATION" value="true" />
<option name="SPACE_BEFORE_METHOD_PARENTHESES" value="true" />
<option name="CALL_PARAMETERS_WRAP" value="1" />
<option name="BINARY_OPERATION_WRAP" value="1" />
<option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
<option name="ARRAY_INITIALIZER_WRAP" value="2" />
<option name="ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE" value="true" />
<option name="ARRAY_INITIALIZER_RBRACE_ON_NEXT_LINE" value="true" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
</codeStyleSettings>
</value>
</option>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</component>
</project>

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="PROJECT" charset="UTF-8" />
</component>
</project>

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="ECMAScript 6" />
</component>
</project>

@ -1,14 +0,0 @@
<component name="libraryTable">
<library name="Sonarr node_modules" type="javaScript">
<properties>
<option name="frameworkName" value="node_modules" />
<sourceFilesUrls>
<item url="file://$PROJECT_DIR$/node_modules" />
</sourceFilesUrls>
</properties>
<CLASSES>
<root url="file://$PROJECT_DIR$/node_modules" />
</CLASSES>
<SOURCES />
</library>
</component>

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectLevelVcsManager" settingsEditedManually="false">
<OptionsSetting value="true" id="Add" />
<OptionsSetting value="true" id="Remove" />
<OptionsSetting value="true" id="Checkout" />
<OptionsSetting value="true" id="Update" />
<OptionsSetting value="true" id="Status" />
<OptionsSetting value="true" id="Edit" />
<ConfirmationsSetting value="0" id="Add" />
<ConfirmationsSetting value="0" id="Remove" />
</component>
<component name="ProjectRootManager" version="2" />
</project>

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Sonarr.iml" filepath="$PROJECT_DIR$/.idea/Sonarr.iml" />
</modules>
</component>
</project>

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

@ -0,0 +1 @@
save-prefix=""

@ -0,0 +1 @@
save-prefix ""

@ -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&amp;utm_medium=referral&amp;utm_content=lidarr/Lidarr&amp;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&amp;utm_medium=referral&amp;utm_content=lidarr/Lidarr&amp;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'

@ -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"
});

@ -1 +0,0 @@
Write-Warning "DEPRECATED -- Please use build.sh instead."

@ -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

@ -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
}

@ -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
}
}
}

@ -0,0 +1 @@
**/JsLibraries/**

@ -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
}
}

@ -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
}
}

@ -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
}
}

@ -0,0 +1,7 @@
{
"ecmaVersion": 6,
"libs": [
"browser",
"jquery"
]
}

@ -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'
]);
});

@ -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]);
});

@ -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());
});

@ -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');

@ -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');
};

@ -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 =`
<!-- begin ${resourcePath} -->
${source}
<!-- end ${resourcePath} -->`;
return wrappedSource;
};

@ -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;

@ -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/'));
});

@ -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);
});
});

@ -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);
});

@ -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');
});

@ -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);
});

@ -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;
};

@ -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;
};

@ -0,0 +1,4 @@
// Place your settings in this file to overwrite default and user settings.
{
"files.insertFinalNewline": true
}

@ -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 (
<PageContent title="Blacklist">
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Clear"
iconName={icons.CLEAR}
isSpinning={isClearingBlacklistExecuting}
onPress={onClearBlacklistPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to load blacklist</div>
}
{
isPopulated && !error && !items.length &&
<div>
No history blacklist
</div>
}
{
isPopulated && !error && !!items.length &&
<div>
<Table
columns={columns}
{...otherProps}
>
<TableBody>
{
items.map((item) => {
return (
<BlacklistRowConnector
key={item.id}
columns={columns}
{...item}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetching}
{...otherProps}
/>
</div>
}
</PageContentBodyConnector>
</PageContent>
);
}
}
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;

@ -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 (
<Blacklist
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onTableOptionChange={this.onTableOptionChange}
onClearBlacklistPress={this.onClearBlacklistPress}
{...this.props}
/>
);
}
}
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);

@ -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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
Details
</ModalHeader>
<ModalBody>
<DescriptionList>
<DescriptionListItem
title="Name"
data={sourceTitle}
/>
<DescriptionListItem
title="Protocol"
data={protocol}
/>
{
!!message &&
<DescriptionListItem
title="Indexer"
data={indexer}
/>
}
{
!!message &&
<DescriptionListItem
title="Message"
data={message}
/>
}
</DescriptionList>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
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;

@ -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;
}

@ -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 (
<TableRow>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'artist.sortName') {
return (
<TableRowCell key={name}>
<ArtistNameLink
nameSlug={artist.nameSlug}
artistName={artist.artistName}
/>
</TableRowCell>
);
}
if (name === 'sourceTitle') {
return (
<TableRowCell key={name}>
{sourceTitle}
</TableRowCell>
);
}
if (name === 'language') {
return (
<TableRowCell
key={name}
className={styles.language}
>
<EpisodeLanguage
language={language}
/>
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell
key={name}
className={styles.quality}
>
<EpisodeQuality
quality={quality}
/>
</TableRowCell>
);
}
if (name === 'date') {
return (
<RelativeDateCellConnector
key={name}
date={date}
/>
);
}
if (name === 'indexer') {
return (
<TableRowCell
key={name}
className={styles.indexer}
>
{indexer}
</TableRowCell>
);
}
if (name === 'details') {
return (
<TableRowCell
key={name}
className={styles.details}
>
<IconButton
name={icons.INFO}
onPress={this.onDetailsPress}
/>
</TableRowCell>
);
}
return null;
})
}
<BlacklistDetailsModal
isOpen={this.state.isDetailsModalOpen}
sourceTitle={sourceTitle}
protocol={protocol}
indexer={indexer}
message={message}
onModalClose={this.onDetailsModalClose}
/>
</TableRow>
);
}
}
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;

@ -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);

@ -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 (
<DescriptionList>
<DescriptionListItem
title="Name"
data={sourceTitle}
/>
{
!!indexer &&
<DescriptionListItem
title="Indexer"
data={indexer}
/>
}
{
!!releaseGroup &&
<DescriptionListItem
title="Release Group"
data={releaseGroup}
/>
}
{
!!nzbInfoUrl &&
<span>
<DescriptionListItemTitle>
Info URL
</DescriptionListItemTitle>
<DescriptionListItemDescription>
<Link to={nzbInfoUrl}>{nzbInfoUrl}</Link>
</DescriptionListItemDescription>
</span>
}
{
!!downloadClient &&
<DescriptionListItem
title="Download Client"
data={downloadClient}
/>
}
{
!!downloadId &&
<DescriptionListItem
title="Grab ID"
data={downloadId}
/>
}
{
!!indexer &&
<DescriptionListItem
title="Age (when grabbed)"
data={formatAge(age, ageHours, ageMinutes)}
/>
}
{
!!publishedDate &&
<DescriptionListItem
title="Published Date"
data={formatDateTime(publishedDate, shortDateFormat, timeFormat, { includeSeconds: true })}
/>
}
</DescriptionList>
);
}
if (eventType === 'downloadFailed') {
const {
message
} = data;
return (
<DescriptionList>
<DescriptionListItem
title="Name"
data={sourceTitle}
/>
{
!!message &&
<DescriptionListItem
title="Message"
data={message}
/>
}
</DescriptionList>
);
}
if (eventType === 'downloadFolderImported') {
const {
droppedPath,
importedPath
} = data;
return (
<DescriptionList>
<DescriptionListItem
title="Name"
data={sourceTitle}
/>
{
!!droppedPath &&
<DescriptionListItem
title="Source"
data={droppedPath}
/>
}
{
!!importedPath &&
<DescriptionListItem
title="Imported To"
data={importedPath}
/>
}
</DescriptionList>
);
}
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 (
<DescriptionList>
<DescriptionListItem
title="Name"
data={sourceTitle}
/>
<DescriptionListItem
title="Reason"
data={reasonMessage}
/>
</DescriptionList>
);
}
if (eventType === 'trackFileRenamed') {
const {
sourcePath,
sourceRelativePath,
path,
relativePath
} = data;
return (
<DescriptionList>
<DescriptionListItem
title="Source Path"
data={sourcePath}
/>
<DescriptionListItem
title="Source Relative Path"
data={sourceRelativePath}
/>
<DescriptionListItem
title="Destination Path"
data={path}
/>
<DescriptionListItem
title="Destination Relative Path"
data={relativePath}
/>
</DescriptionList>
);
}
}
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;

@ -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);

@ -0,0 +1,5 @@
.markAsFailedButton {
composes: button from 'Components/Link/Button.css';
margin-right: auto;
}

@ -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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{getHeaderTitle(eventType)}
</ModalHeader>
<ModalBody>
<HistoryDetails
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
/>
</ModalBody>
<ModalFooter>
{
eventType === 'grabbed' &&
<SpinnerButton
className={styles.markAsFailedButton}
kind={kinds.DANGER}
isSpinning={isMarkingAsFailed}
onPress={onMarkAsFailedPress}
>
Mark as Failed
</SpinnerButton>
}
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
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;

@ -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 (
<PageContent title="History">
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Refresh"
iconName={icons.REFRESH}
isSpinning={isFetching}
onPress={onFirstPagePress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<FilterMenu alignMenu={align.RIGHT}>
<MenuContent>
<FilterMenuItem
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
All
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="1"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Grabbed
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="3"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Imported
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="4"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Failed
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="5"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Deleted
</FilterMenuItem>
<FilterMenuItem
name="eventType"
value="6"
filterKey={filterKey}
filterValue={filterValue}
onPress={onFilterSelect}
>
Renamed
</FilterMenuItem>
</MenuContent>
</FilterMenu>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector>
{
isFetchingAny && !isAllPopulated &&
<LoadingIndicator />
}
{
!isFetchingAny && hasError &&
<div>Unable to load history</div>
}
{
// 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 &&
<div>
No history found
</div>
}
{
isAllPopulated && !hasError && !!items.length &&
<div>
<Table
columns={columns}
{...otherProps}
>
<TableBody>
{
items.map((item) => {
return (
<HistoryRowConnector
key={item.id}
columns={columns}
{...item}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetchingAny}
onFirstPagePress={onFirstPagePress}
{...otherProps}
/>
</div>
}
</PageContentBodyConnector>
</PageContent>
);
}
}
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;

@ -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 (
<History
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
{...this.props}
/>
);
}
}
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);

@ -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 (
<TableRowCell
className={styles.cell}
title={tooltip}
>
<Icon
name={iconName}
kind={iconKind}
/>
</TableRowCell>
);
}
HistoryEventTypeCell.propTypes = {
eventType: PropTypes.string.isRequired,
data: PropTypes.object
};
HistoryEventTypeCell.defaultProps = {
data: {}
};
export default HistoryEventTypeCell;

@ -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;
}

@ -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 (
<TableRow>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'eventType') {
return (
<HistoryEventTypeCell
key={name}
eventType={eventType}
data={data}
/>
);
}
if (name === 'artist.sortName') {
return (
<TableRowCell key={name}>
<ArtistNameLink
nameSlug={artist.nameSlug}
artistName={artist.artistName}
/>
</TableRowCell>
);
}
if (name === 'episodeTitle') {
return (
<TableRowCell key={name}>
<EpisodeTitleLink
albumId={albumId}
episodeEntity={episodeEntities.EPISODES}
artistId={artist.id}
episodeTitle={album.title}
showOpenArtistButton={true}
/>
</TableRowCell>
);
}
if (name === 'trackTitle') {
return (
<TableRowCell key={name}>
{track.title}
</TableRowCell>
);
}
if (name === 'language') {
return (
<TableRowCell key={name}>
<EpisodeLanguage
language={language}
/>
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name}>
<EpisodeQuality
quality={quality}
/>
</TableRowCell>
);
}
if (name === 'date') {
return (
<RelativeDateCellConnector
key={name}
date={date}
/>
);
}
if (name === 'downloadClient') {
return (
<TableRowCell
key={name}
className={styles.downloadClient}
>
{data.downloadClient}
</TableRowCell>
);
}
if (name === 'indexer') {
return (
<TableRowCell
key={name}
className={styles.indexer}
>
{data.indexer}
</TableRowCell>
);
}
if (name === 'releaseGroup') {
return (
<TableRowCell
key={name}
className={styles.releaseGroup}
>
{data.releaseGroup}
</TableRowCell>
);
}
if (name === 'details') {
return (
<TableRowCell
key={name}
className={styles.details}
>
<IconButton
name={icons.INFO}
onPress={this.onDetailsPress}
/>
</TableRowCell>
);
}
return null;
})
}
<HistoryDetailsModal
isOpen={this.state.isDetailsModalOpen}
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
isMarkingAsFailed={isMarkingAsFailed}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
onMarkAsFailedPress={onMarkAsFailedPress}
onModalClose={this.onDetailsModalClose}
/>
</TableRow>
);
}
}
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;

@ -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 (
<HistoryRow
{...this.props}
onMarkAsFailedPress={this.onMarkAsFailedPress}
/>
);
}
}
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);

@ -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;
}

@ -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 (
<Label className={styles[protocol]}>
{protocolName}
</Label>
);
}
ProtocolLabel.propTypes = {
protocol: PropTypes.string.isRequired
};
export default ProtocolLabel;

@ -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 (
<PageContent title="Queue">
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Refresh"
iconName={icons.REFRESH}
isSpinning={isRefreshing}
onPress={onRefreshPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label="Grab Selected"
iconName={icons.DOWNLOAD}
isDisabled={disableSelectedActions || !isPendingSelected}
isSpinning={isGrabbing}
onPress={this.onGrabSelectedPress}
/>
<PageToolbarButton
label="Remove Selected"
iconName={icons.REMOVE}
isDisabled={disableSelectedActions}
isSpinning={isRemoving}
onPress={this.onRemoveSelectedPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector>
{
isRefreshing && !isAllPopulated &&
<LoadingIndicator />
}
{
!isRefreshing && hasError &&
<div>
Failed to load Queue
</div>
}
{
isPopulated && !hasError && !items.length &&
<div>
Queue is empty
</div>
}
{
isAllPopulated && !hasError && !!items.length &&
<div>
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
{...otherProps}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<QueueRowConnector
key={item.id}
albumId={item.albumId}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isRefreshing}
{...otherProps}
/>
</div>
}
</PageContentBodyConnector>
<RemoveQueueItemsModal
isOpen={isConfirmRemoveModalOpen}
selectedCount={selectedCount}
onRemovePress={this.onRemoveSelectedConfirmed}
onModalClose={this.onConfirmRemoveModalClose}
/>
</PageContent>
);
}
}
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;

@ -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 (
<Queue
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onTableOptionChange={this.onTableOptionChange}
onRefreshPress={this.onRefreshPress}
onGrabSelectedPress={this.onGrabSelectedPress}
onRemoveSelectedPress={this.onRemoveSelectedPress}
{...this.props}
/>
);
}
}
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);

@ -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 (
<Icon
name={icons.PENDING}
title={`Release will be processed ${moment(estimatedCompletionTime).fromNow()}`}
/>
);
}
if (status === 'completed') {
if (errorMessage) {
return (
<Icon
name={icons.DOWNLOAD}
kind={kinds.DANGER}
title={`Import failed: ${errorMessage}`}
/>
);
}
// TODO: show an icon when download is complete, but not imported yet?
}
if (errorMessage) {
return (
<Icon
name={icons.DOWNLOADING}
kind={kinds.DANGER}
title={`Download failed: ${errorMessage}`}
/>
);
}
if (status === 'failed') {
return (
<Icon
name={icons.DOWNLOADING}
kind={kinds.DANGER}
title="Download failed: check download client for more details"
/>
);
}
if (status === 'warning') {
return (
<Icon
name={icons.DOWNLOADING}
kind={kinds.WARNING}
title="Download warning: check download client for more details"
/>
);
}
if (progress < 5) {
return (
<Icon
name={icons.DOWNLOADING}
title={`Episode is downloading - ${progress.toFixed(1)}% ${title}`}
/>
);
}
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;

@ -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;
}

@ -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 (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'status') {
return (
<QueueStatusCell
key={name}
sourceTitle={title}
status={status}
trackedDownloadStatus={trackedDownloadStatus}
statusMessages={statusMessages}
errorMessage={errorMessage}
/>
);
}
if (name === 'artist.sortName') {
return (
<TableRowCell key={name}>
<ArtistNameLink
nameSlug={artist.nameSlug}
artistName={artist.artistName}
/>
</TableRowCell>
);
}
if (name === 'artist') {
return (
<TableRowCell key={name}>
<ArtistNameLink
nameSlug={artist.nameSlug}
artistName={artist.artistName}
/>
</TableRowCell>
);
}
if (name === 'episodeTitle') {
return (
<TableRowCell key={name}>
<EpisodeTitleLink
albumId={episode.id}
artistId={artist.id}
trackFileId={episode.trackFileId}
episodeTitle={episode.title}
showOpenArtistButton={true}
/>
</TableRowCell>
);
}
if (name === 'quality') {
return (
<TableRowCell key={name}>
<EpisodeQuality
quality={quality}
/>
</TableRowCell>
);
}
if (name === 'protocol') {
return (
<TableRowCell key={name}>
<ProtocolLabel
protocol={protocol}
/>
</TableRowCell>
);
}
if (name === 'indexer') {
return (
<TableRowCell key={name}>
{indexer}
</TableRowCell>
);
}
if (name === 'downloadClient') {
return (
<TableRowCell key={name}>
{downloadClient}
</TableRowCell>
);
}
if (name === 'estimatedCompletionTime') {
return (
<TimeleftCell
key={name}
status={status}
estimatedCompletionTime={estimatedCompletionTime}
timeleft={timeleft}
size={size}
sizeleft={sizeleft}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
/>
);
}
if (name === 'progress') {
return (
<TableRowCell
key={name}
className={styles.progress}
>
{
!!progress &&
<ProgressBar
progress={progress}
title={`${progress.toFixed(1)}%`}
/>
}
</TableRowCell>
);
}
if (name === 'actions') {
return (
<TableRowCell
key={name}
className={styles.actions}
>
{
showInteractiveImport &&
<IconButton
name={icons.INTERACTIVE}
onPress={this.onInteractiveImportPress}
/>
}
{
isPending &&
<SpinnerIconButton
name={icons.DOWNLOAD}
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
isSpinning={isGrabbing}
onPress={onGrabPress}
/>
}
<SpinnerIconButton
name={icons.REMOVE}
isSpinning={isRemoving}
onPress={this.onRemoveQueueItemPress}
/>
</TableRowCell>
);
}
return null;
})
}
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
downloadId={downloadId}
title={title}
onModalClose={this.onInteractiveImportModalClose}
/>
<RemoveQueueItemModal
isOpen={isRemoveQueueItemModalOpen}
sourceTitle={title}
onRemovePress={this.onRemoveQueueItemModalConfirmed}
onModalClose={this.onRemoveQueueItemModalClose}
/>
</TableRow>
);
}
}
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;

@ -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 (
<QueueRow
{...this.props}
onGrabPress={this.onGrabPress}
onRemoveQueueItemPress={this.onRemoveQueueItemPress}
/>
);
}
}
QueueRowConnector.propTypes = {
id: PropTypes.number.isRequired,
episode: PropTypes.object,
grabQueueItem: PropTypes.func.isRequired,
removeQueueItem: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueueRowConnector);

@ -0,0 +1,5 @@
.status {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 30px;
}

@ -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 (
<div>
{
statusMessages.map(({ title, messages }) => {
return (
<div key={title}>
{title}
<ul>
{
messages.map((message) => {
return (
<li key={message}>
{message}
</li>
);
})
}
</ul>
</div>
);
})
}
</div>
);
}
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 (
<TableRowCell className={styles.status}>
<Popover
anchor={
<Icon
name={iconName}
kind={iconKind}
/>
}
title={title}
body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle}
position={tooltipPositions.RIGHT}
/>
</TableRowCell>
);
}
QueueStatusCell.propTypes = {
sourceTitle: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
trackedDownloadStatus: PropTypes.string,
statusMessages: PropTypes.arrayOf(PropTypes.object),
errorMessage: PropTypes.string
};
export default QueueStatusCell;

@ -0,0 +1,3 @@
.message {
margin-bottom: 30px;
}

@ -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 (
<Modal
isOpen={isOpen}
size={sizes.MEDIUM}
onModalClose={this.onModalClose}
>
<ModalContent
onModalClose={this.onModalClose}
>
<ModalHeader>
Remove - {sourceTitle}
</ModalHeader>
<ModalBody>
<div className={styles.message}>
Are you sure you want to remove '{sourceTitle}' from the queue?
</div>
<FormGroup>
<FormLabel>Blacklist Release</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="blacklist"
value={blacklist}
helpText="Prevents Lidarr from automatically grabbing this episode again"
onChange={this.onBlacklistChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button onPress={this.onModalClose}>
Close
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onRemoveQueueItemConfirmed}
>
Remove
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
RemoveQueueItemModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
sourceTitle: PropTypes.string.isRequired,
onRemovePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RemoveQueueItemModal;

@ -0,0 +1,3 @@
.message {
margin-bottom: 30px;
}

@ -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 (
<Modal
isOpen={isOpen}
size={sizes.MEDIUM}
onModalClose={this.onModalClose}
>
<ModalContent
onModalClose={this.onModalClose}
>
<ModalHeader>
Remove Selected Item{selectedCount > 1 ? 's' : ''}
</ModalHeader>
<ModalBody>
<div className={styles.message}>
Are you sure you want to remove {selectedCount} item{selectedCount > 1 ? 's' : ''} from the queue?
</div>
<FormGroup>
<FormLabel>Blacklist Release</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="blacklist"
value={blacklist}
helpText="Prevents Lidarr from automatically grabbing this episode again"
onChange={this.onBlacklistChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button onPress={this.onModalClose}>
Close
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onRemoveQueueItemConfirmed}
>
Remove
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
RemoveQueueItemsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
selectedCount: PropTypes.number.isRequired,
onRemovePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RemoveQueueItemsModal;

@ -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 (
<PageSidebarStatus
{...this.props}
/>
);
}
}
QueueStatusConnector.propTypes = {
isConnected: PropTypes.bool.isRequired,
isReconnecting: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
fetchQueueStatus: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector);

@ -0,0 +1,5 @@
.timeleft {
composes: cell from 'Components/Table/Cells/TableRowCell.css';
width: 100px;
}

@ -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 (
<TableRowCell
className={styles.timeleft}
title={`Delaying download until ${date} at ${time}`}
>
-
</TableRowCell>
);
}
if (status === 'DownloadClientUnavailable') {
const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates);
const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true });
return (
<TableRowCell
className={styles.timeleft}
title={`Retrying download ${date} at ${time}`}
>
-
</TableRowCell>
);
}
if (!timeleft) {
return (
<TableRowCell className={styles.timeleft}>
-
</TableRowCell>
);
}
const totalSize = formatBytes(size);
const remainingSize = formatBytes(sizeleft);
return (
<TableRowCell
className={styles.timeleft}
title={`${remainingSize} / ${totalSize}`}
>
{formatTimeSpan(timeleft)}
</TableRowCell>
);
}
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;

@ -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;
}
}

@ -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;
}

@ -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 (
<PageContent title="Add New Artist">
<PageContentBodyConnector>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon
name={icons.SEARCH}
size={20}
/>
</div>
<TextInput
className={styles.searchInput}
name="artistLookup"
value={term}
placeholder="eg. Breaking Benjamin, lidarr:####"
onChange={this.onSearchInputChange}
/>
<Button
className={styles.clearLookupButton}
onPress={this.onClearArtistLookupPress}
>
<Icon
name={icons.REMOVE}
size={20}
/>
</Button>
</div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Failed to load search results, please try again.</div>
}
{
!isFetching && !error && !!items.length &&
<div className={styles.searchResults}>
{
items.map((item) => {
return (
<AddNewArtistSearchResultConnector
key={item.foreignArtistId}
{...item}
/>
);
})
}
</div>
}
{
!isFetching && !error && !items.length && !!term &&
<div className={styles.message}>
<div className={styles.noResults}>Couldn't find any results for '{term}'</div>
<div>You can also search using MusicBrainz ID of a show. eg. lidarr:71663</div>
<div>
<Link to="https://github.com/Lidarr/Lidarr/wiki/FAQ#why-cant-i-add-a-new-artist-when-i-know-the-tvdb-id">
Why can't I find my artist?
</Link>
</div>
</div>
}
{
!term &&
<div className={styles.message}>
<div className={styles.helpText}>It's easy to add a new artist, just start typing the name the artist you want to add.</div>
<div>You can also search using MusicBrainz ID of a show. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234</div>
</div>
}
<div />
</PageContentBodyConnector>
</PageContent>
);
}
}
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;

@ -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 (
<AddNewArtist
term={term}
{...otherProps}
onArtistLookupChange={this.onArtistLookupChange}
onClearArtistLookup={this.onClearArtistLookup}
/>
);
}
}
AddNewArtistConnector.propTypes = {
term: PropTypes.string,
lookupArtist: PropTypes.func.isRequired,
clearAddArtist: PropTypes.func.isRequired,
fetchRootFolders: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewArtistConnector);

@ -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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddNewArtistModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddNewArtistModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddNewArtistModal;

@ -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;
}
}

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{artistName}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{
!isSmallScreen &&
<div className={styles.poster}>
<ArtistPoster
className={styles.poster}
images={images}
size={250}
/>
</div>
}
<div className={styles.info}>
<div className={styles.overview}>
<TextTruncate
truncateText="…"
line={8}
text={overview}
/>
</div>
<Form>
<FormGroup>
<FormLabel>Root Folder</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
onChange={onInputChange}
{...rootFolderPath}
/>
</FormGroup>
<FormGroup>
<FormLabel>
Monitor
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title="Monitoring Options"
body={<ArtistMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_ALBUMS_SELECT}
name="monitor"
onChange={onInputChange}
{...monitor}
/>
</FormGroup>
<FormGroup>
<FormLabel>Quality Profile</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
onChange={this.onQualityProfileIdChange}
{...qualityProfileId}
/>
</FormGroup>
<FormGroup className={showLanguageProfile ? null : styles.hideLanguageProfile}>
<FormLabel>Language Profile</FormLabel>
<FormInputGroup
type={inputTypes.LANGUAGE_PROFILE_SELECT}
name="languageProfileId"
onChange={this.onLanguageProfileIdChange}
{...languageProfileId}
/>
</FormGroup>
<FormGroup>
<FormLabel>Album Folder</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="albumFolder"
onChange={onInputChange}
{...albumFolder}
/>
</FormGroup>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<label className={styles.searchForMissingAlbumsLabelContainer}>
<span className={styles.searchForMissingAlbumsLabel}>
Start search for missing albums
</span>
<CheckInput
containerClassName={styles.searchForMissingAlbumsContainer}
className={styles.searchForMissingAlbumsInput}
name="searchForMissingAlbums"
value={this.state.searchForMissingAlbums}
onChange={this.onSearchForMissingAlbumsChange}
/>
</label>
<SpinnerButton
className={styles.addButton}
kind={kinds.SUCCESS}
isSpinning={isAdding}
onPress={this.onAddArtistPress}
>
Add {artistName}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
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;

@ -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 (
<AddNewArtistModalContent
{...this.props}
onInputChange={this.onInputChange}
onAddArtistPress={this.onAddArtistPress}
/>
);
}
}
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);

@ -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;
}

@ -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 (
<Link
className={styles.searchResult}
{...linkProps}
>
{
!isSmallScreen &&
<ArtistPoster
className={styles.poster}
images={images}
size={250}
/>
}
<div>
<div className={styles.name}>
{artistName}
{
!name.contains(year) && !!year &&
<span className={styles.year}>({year})</span>
}
{
!!disambiguation &&
<span className={styles.year}>({disambiguation})</span>
}
{
isExistingArtist &&
<Icon
className={styles.alreadyExistsIcon}
name={icons.CHECK_CIRCLE}
size={36}
title="Already in your library"
/>
}
</div>
<div>
<Label size={sizes.LARGE}>
<HeartRating
rating={ratings.value}
iconSize={13}
/>
</Label>
{
!!artistType &&
<Label size={sizes.LARGE}>
{artistType}
</Label>
}
{
!!albumCount &&
<Label size={sizes.LARGE}>
{albums}
</Label>
}
{
status === 'ended' &&
<Label
kind={kinds.DANGER}
size={sizes.LARGE}
>
Ended
</Label>
}
</div>
<div>
<div
className={styles.overview}
style={{
maxHeight: `${height}px`
}}
>
<TextTruncate
truncateText="…"
line={Math.floor(height / (defaultFontSize * lineHeight))}
text={overview}
/>
</div>
</div>
</div>
<AddNewArtistModal
isOpen={this.state.isNewAddArtistModalOpen && !isExistingArtist}
foreignArtistId={foreignArtistId}
artistName={artistName}
year={year}
overview={overview}
images={images}
onModalClose={this.onAddSerisModalClose}
/>
</Link>
);
}
}
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;

@ -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);

@ -0,0 +1,46 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
function ArtistMonitoringOptionsPopoverContent() {
return (
<DescriptionList>
<DescriptionListItem
title="All Albums"
data="Monitor all albums except specials"
/>
<DescriptionListItem
title="Future Albums"
data="Monitor albums that have not released yet"
/>
<DescriptionListItem
title="Missing Albums"
data="Monitor albums that do not have files or have not released yet"
/>
<DescriptionListItem
title="Existing Albums"
data="Monitor albums that have files or have not released yet"
/>
<DescriptionListItem
title="First Album"
data="Monitor the first albums. All other albums will be ignored"
/>
<DescriptionListItem
title="Latest Album"
data="Monitor the latest albums and future albums"
/>
<DescriptionListItem
title="None"
data="No albums will be monitored."
/>
</DescriptionList>
);
}
export default ArtistMonitoringOptionsPopoverContent;

@ -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 (
<PageContent title="Import Artist">
<PageContentBodyConnector
ref={this.setContentBodyRef}
onScroll={this.onScroll}
>
{
rootFoldersFetching && !rootFoldersPopulated &&
<LoadingIndicator />
}
{
!rootFoldersFetching && !!rootFoldersError &&
<div>Unable to load root folders</div>
}
{
!rootFoldersError && rootFoldersPopulated && !unmappedFolders.length &&
<div>
All artist in {path} have been imported
</div>
}
{
!rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && contentBody &&
<ImportArtistTableConnector
rootFolderId={rootFolderId}
unmappedFolders={unmappedFolders}
allSelected={allSelected}
allUnselected={allUnselected}
selectedState={selectedState}
contentBody={contentBody}
showLanguageProfile={showLanguageProfile}
scrollTop={this.state.scrollTop}
onSelectAllChange={this.onSelectAllChange}
onSelectedChange={this.onSelectedChange}
onRemoveSelectedStateItem={this.onRemoveSelectedStateItem}
onScroll={this.onScroll}
/>
}
</PageContentBodyConnector>
{
!rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length &&
<ImportArtistFooterConnector
selectedIds={this.getSelectedIds()}
showLanguageProfile={showLanguageProfile}
onInputChange={this.onInputChange}
onImportPress={this.onImportPress}
/>
}
</PageContent>
);
}
}
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;

@ -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 (
<ImportArtist
{...this.props}
onInputChange={this.onInputChange}
onImportPress={this.onImportPress}
/>
);
}
}
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);

@ -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;
}

@ -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 (
<PageContentFooter>
<div className={styles.inputContainer}>
<div className={styles.label}>
Monitor
</div>
<FormInputGroup
type={inputTypes.MONITOR_ALBUMS_SELECT}
name="monitor"
value={monitor}
isDisabled={!selectedCount}
includeMixed={isMonitorMixed}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>
Quality Profile
</div>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
isDisabled={!selectedCount}
includeMixed={isQualityProfileIdMixed}
onChange={this.onInputChange}
/>
</div>
{
showLanguageProfile &&
<div className={styles.inputContainer}>
<div className={styles.label}>
Language Profile
</div>
<FormInputGroup
type={inputTypes.LANGUAGE_PROFILE_SELECT}
name="languageProfileId"
value={languageProfileId}
isDisabled={!selectedCount}
includeMixed={isLanguageProfileIdMixed}
onChange={this.onInputChange}
/>
</div>
}
<div className={styles.inputContainer}>
<div className={styles.label}>
Album Folder
</div>
<CheckInput
name="albumFolder"
value={albumFolder}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div>
<div className={styles.label}>
&nbsp;
</div>
<div className={styles.importButtonContainer}>
<SpinnerButton
className={styles.importButton}
kind={kinds.PRIMARY}
isSpinning={isImporting}
isDisabled={!selectedCount || isLookingUpArtist}
onPress={onImportPress}
>
Import {selectedCount} Artist(s)
</SpinnerButton>
{
isLookingUpArtist &&
<LoadingIndicator
className={styles.loading}
size={24}
/>
}
{
isLookingUpArtist &&
'Processing Folders'
}
</div>
</div>
</PageContentFooter>
);
}
}
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;

@ -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);

@ -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;
}

@ -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 (
<VirtualTableHeader>
<VirtualTableSelectAllHeaderCell
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
<VirtualTableHeaderCell
className={styles.folder}
name="folder"
>
Folder
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.monitor}
name="monitor"
>
Monitor
<Popover
anchor={
<Icon
className={styles.detailsIcon}
name={icons.INFO}
/>
}
title="Monitoring Options"
body={<ArtistMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.qualityProfile}
name="qualityProfileId"
>
Quality Profile
</VirtualTableHeaderCell>
{
showLanguageProfile &&
<VirtualTableHeaderCell
className={styles.languageProfile}
name="languageProfileId"
>
Language Profile
</VirtualTableHeaderCell>
}
<VirtualTableHeaderCell
className={styles.albumFolder}
name="albumFolder"
>
Album Folder
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.artist}
name="artist"
>
Artist
</VirtualTableHeaderCell>
</VirtualTableHeader>
);
}
ImportArtistHeader.propTypes = {
showLanguageProfile: PropTypes.bool.isRequired,
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
onSelectAllChange: PropTypes.func.isRequired
};
export default ImportArtistHeader;

@ -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;
}

@ -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 (
<VirtualTableRow style={style}>
<VirtualTableSelectCell
inputClassName={styles.selectInput}
id={id}
isSelected={isSelected}
isDisabled={!selectedArtist || isExistingArtist}
onSelectedChange={onSelectedChange}
/>
<VirtualTableRowCell className={styles.folder}>
{id}
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.monitor}>
<FormInputGroup
type={inputTypes.MONITOR_ALBUMS_SELECT}
name="monitor"
value={monitor}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.qualityProfile}>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell
className={showLanguageProfile ? styles.languageProfile : styles.hideLanguageProfile}
>
<FormInputGroup
type={inputTypes.LANGUAGE_PROFILE_SELECT}
name="languageProfileId"
value={languageProfileId}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.albumFolder}>
<FormInputGroup
type={inputTypes.CHECK}
name="albumFolder"
value={albumFolder}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.artist}>
<ImportArtistSelectArtistConnector
id={id}
isExistingArtist={isExistingArtist}
/>
</VirtualTableRowCell>
</VirtualTableRow>
);
}
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;

@ -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 (
<ImportArtistRow
{...this.props}
onInputChange={this.onInputChange}
onArtistSelect={this.onArtistSelect}
/>
);
}
}
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);

@ -0,0 +1,3 @@
.input {
composes: input from 'Components/Form/CheckInput.css';
}

@ -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 (
<ImportArtistRowConnector
key={key}
style={style}
rootFolderId={rootFolderId}
showLanguageProfile={showLanguageProfile}
isSelected={selectedState[item.id]}
onSelectedChange={onSelectedChange}
id={item.id}
/>
);
}
//
// Render
render() {
const {
items,
allSelected,
allUnselected,
isSmallScreen,
contentBody,
showLanguageProfile,
scrollTop,
onSelectAllChange,
onScroll
} = this.props;
if (!items.length) {
return null;
}
return (
<VirtualTable
ref={this.setTableRef}
items={items}
contentBody={contentBody}
isSmallScreen={isSmallScreen}
rowHeight={52}
scrollTop={scrollTop}
overscanRowCount={2}
rowRenderer={this.rowRenderer}
header={
<ImportArtistHeader
showLanguageProfile={showLanguageProfile}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
}
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;

@ -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);

@ -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;
}

@ -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 (
<div className={styles.artistNameContainer}>
<div className={styles.artistName}>
{artistName}
</div>
<div className={styles.disambiguation}>
{disambiguation}
</div>
{
isExistingArtist &&
<Label
kind={kinds.WARNING}
>
Existing
</Label>
}
</div>
);
}
ImportArtistName.propTypes = {
artistName: PropTypes.string.isRequired,
disambiguation: PropTypes.string,
// year: PropTypes.number.isRequired,
isExistingArtist: PropTypes.bool.isRequired
};
export default ImportArtistName;

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save