diff --git a/.devcontainer/Lidarr.code-workspace b/.devcontainer/Lidarr.code-workspace new file mode 100644 index 000000000..a46158e44 --- /dev/null +++ b/.devcontainer/Lidarr.code-workspace @@ -0,0 +1,13 @@ +// This file is used to open the backend and frontend in the same workspace, which is necessary as +// the frontend has vscode settings that are distinct from the backend +{ + "folders": [ + { + "path": ".." + }, + { + "path": "../frontend" + } + ], + "settings": {} +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..d0fa03d5f --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet +{ + "name": "Lidarr", + "image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "nodeGypDependencies": true, + "version": "20", + "nvmVersion": "latest" + } + }, + "forwardPorts": [8686], + "customizations": { + "vscode": { + "extensions": ["esbenp.prettier-vscode"] + } + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 31f001e52..491815370 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -60,6 +60,7 @@ body: - Master - Develop - Nightly + - Plugins (experimental) - Other (This issue will be closed) validations: required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..f33a02cd1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/label-actions.yml b/.github/workflows/label-actions.yml index a7fc89446..a6246a6b3 100644 --- a/.github/workflows/label-actions.yml +++ b/.github/workflows/label-actions.yml @@ -12,6 +12,6 @@ jobs: action: runs-on: ubuntu-latest steps: - - uses: dessant/label-actions@v3 + - uses: dessant/label-actions@v4 with: process-only: 'issues' diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index cf38066c5..1d50cb1f1 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -9,7 +9,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v4 + - uses: dessant/lock-threads@v5 with: github-token: ${{ github.token }} issue-inactive-days: '90' diff --git a/.gitignore b/.gitignore index d2dc01467..a5d6bb7c8 100644 --- a/.gitignore +++ b/.gitignore @@ -121,11 +121,13 @@ _artifacts _rawPackage/ _dotTrace* _tests/ +_temp* *.Result.xml coverage*.xml coverage*.json setup/Output/ *.~is +.mono # VS outout folders bin @@ -138,12 +140,6 @@ project.fragment.lock.json artifacts/ **/Properties/launchSettings.json -#VS outout folders -bin -obj -output/* - - # macOS metadata files ._* .DS_Store @@ -162,34 +158,12 @@ Thumbs.db /tools/Addins/* packages.config.md5sum - -# Common IntelliJ Platform excludes - -# User specific -**/.idea/**/workspace.xml -**/.idea/**/tasks.xml -**/.idea/shelf/* -**/.idea/dictionaries -**/.idea/.idea.Radarr.Posix -**/.idea/.idea.Radarr.Windows - -# Sensitive or high-churn files -**/.idea/**/dataSources/ -**/.idea/**/dataSources.ids -**/.idea/**/dataSources.xml -**/.idea/**/dataSources.local.xml -**/.idea/**/sqlDataSources.xml -**/.idea/**/dynamic.xml - -# Rider -# Rider auto-generates .iml files, and contentModel.xml -**/.idea/**/*.iml -**/.idea/**/contentModel.xml -**/.idea/**/modules.xml - # ignore node_modules symlink node_modules node_modules.nosync # API doc generation .config/ + +# Ignore Jetbrains IntelliJ Workspace Directories +.idea/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..7a36fefe1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "ms-dotnettools.csdevkit", + "ms-vscode-remote.remote-containers" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..74b8d418b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md + "name": "Run Lidarr", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build dotnet", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/_output/net6.0/Lidarr", + "args": [], + "cwd": "${workspaceFolder}", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "integratedTerminal", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..4b3b00b89 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,44 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build dotnet", + "command": "dotnet", + "type": "process", + "args": [ + "msbuild", + "-restore", + "${workspaceFolder}/src/Lidarr.sln", + "-p:GenerateFullPaths=true", + "-p:Configuration=Debug", + "-p:Platform=Posix", + "-consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/src/Lidarr.sln", + "-property:GenerateFullPaths=true", + "-consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/Lidarr.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/README.md b/README.md index f5c8cdf84..6e643760f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Lidarr [![Build Status](https://dev.azure.com/Lidarr/Lidarr/_apis/build/status/lidarr.Lidarr?branchName=develop)](https://dev.azure.com/Lidarr/Lidarr/_build/latest?definitionId=1&branchName=develop) +[![Translation status](https://translate.servarr.com/widget/servarr/lidarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/?utm_source=widget) [![Docker Pulls](https://img.shields.io/docker/pulls/linuxserver/lidarr.svg)](https://wiki.servarr.com/lidarr/installation#docker) ![Github Downloads](https://img.shields.io/github/downloads/lidarr/lidarr/total.svg) [![Backers on Open Collective](https://opencollective.com/lidarr/backers/badge.svg)](#backers) @@ -8,6 +9,9 @@ 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. +> [!WARNING] +> NOTICE - The Lidarr Metadata Server is currently down impacting adding artists, etc. Please follow [GHI 5498](https://github.com/Lidarr/Lidarr/issues/5498) or see Discord for detaila. + ## Major Features Include: * Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc. diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 5a55ec505..85d13499a 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,18 +9,18 @@ variables: testsFolder: './_tests' yarnCacheFolder: $(Pipeline.Workspace)/.yarn nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages - majorVersion: '2.1.4' + majorVersion: '2.13.3' minorVersion: $[counter('minorVersion', 1076)] lidarrVersion: '$(majorVersion).$(minorVersion)' buildName: '$(Build.SourceBranchName).$(lidarrVersion)' sentryOrg: 'servarr' sentryUrl: 'https://sentry.servarr.com' - dotnetVersion: '6.0.417' - nodeVersion: '16.X' + dotnetVersion: '6.0.427' + nodeVersion: '20.X' innoVersion: '6.2.0' windowsImage: 'windows-2022' - linuxImage: 'ubuntu-20.04' - macImage: 'macOS-11' + linuxImage: 'ubuntu-22.04' + macImage: 'macOS-13' trigger: branches: @@ -166,10 +166,10 @@ stages: pool: vmImage: $(imageName) steps: - - task: NodeTool@0 + - task: UseNode@1 displayName: Set Node.js version inputs: - versionSpec: $(nodeVersion) + version: $(nodeVersion) - checkout: self submodules: true fetchDepth: 1 @@ -1093,10 +1093,10 @@ stages: pool: vmImage: $(imageName) steps: - - task: NodeTool@0 + - task: UseNode@1 displayName: Set Node.js version inputs: - versionSpec: $(nodeVersion) + version: $(nodeVersion) - checkout: self submodules: true fetchDepth: 1 @@ -1120,19 +1120,19 @@ stages: vmImage: ${{ variables.windowsImage }} steps: - checkout: self # Need history for Sonar analysis - - task: SonarCloudPrepare@1 + - task: SonarCloudPrepare@3 env: SONAR_SCANNER_OPTS: '' inputs: SonarCloud: 'SonarCloud' organization: 'lidarr' - scannerMode: 'CLI' + scannerMode: 'cli' configMode: 'manual' cliProjectKey: 'lidarr_Lidarr.UI' cliProjectName: 'LidarrUI' cliProjectVersion: '$(lidarrVersion)' cliSources: './frontend' - - task: SonarCloudAnalyze@1 + - task: SonarCloudAnalyze@3 - job: Api_Docs displayName: API Docs @@ -1208,12 +1208,12 @@ stages: submodules: true - powershell: Set-Service SCardSvr -StartupType Manual displayName: Enable Windows Test Service - - task: SonarCloudPrepare@1 + - task: SonarCloudPrepare@3 condition: eq(variables['System.PullRequest.IsFork'], 'False') inputs: SonarCloud: 'SonarCloud' organization: 'lidarr' - scannerMode: 'MSBuild' + scannerMode: 'dotnet' projectKey: 'lidarr_Lidarr' projectName: 'Lidarr' projectVersion: '$(lidarrVersion)' @@ -1226,21 +1226,16 @@ stages: ./build.sh --backend -f net6.0 -r win-x64 TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage displayName: Coverage Unit Tests - - task: SonarCloudAnalyze@1 + - task: SonarCloudAnalyze@3 condition: eq(variables['System.PullRequest.IsFork'], 'False') displayName: Publish SonarCloud Results - - task: reportgenerator@4 + - task: reportgenerator@5.3.11 displayName: Generate Coverage Report inputs: reports: '$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml' targetdir: '$(Build.SourcesDirectory)/CoverageResults/combined' reporttypes: 'HtmlInline_AzurePipelines;Cobertura;Badges' - - task: PublishCodeCoverageResults@1 - displayName: Publish Coverage Report - inputs: - codeCoverageTool: 'cobertura' - summaryFileLocation: './CoverageResults/combined/Cobertura.xml' - reportDirectory: './CoverageResults/combined/' + publishCodeCoverageResults: true - stage: Report_Out dependsOn: diff --git a/distribution/debian/install.sh b/distribution/debian/install.sh new file mode 100644 index 000000000..b71eb20c9 --- /dev/null +++ b/distribution/debian/install.sh @@ -0,0 +1,182 @@ +#!/bin/bash +### Description: Lidarr .NET Debian install +### Originally written for Radarr by: DoctorArr - doctorarr@the-rowlands.co.uk on 2021-10-01 v1.0 +### Updates for servarr suite made by Bakerboy448, DoctorArr, brightghost, aeramor and VP-EN +### Version v1.0.0 2023-12-29 - StevieTV - adapted from servarr script for Lidarr installs +### Version V1.0.1 2024-01-02 - StevieTV - remove UTF8-BOM +### Version V1.0.2 2024-01-03 - markus101 - Get user input from /dev/tty +### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory + +### Boilerplate Warning +#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +#EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +#MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +#NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +#LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +#OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +#WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +scriptversion="1.0.3" +scriptdate="2024-01-06" + +set -euo pipefail + +echo "Running Lidarr Install Script - Version [$scriptversion] as of [$scriptdate]" + +# Am I root?, need root! + +if [ "$EUID" -ne 0 ]; then + echo "Please run as root." + exit +fi + +app="lidarr" +app_port="8686" +app_prereq="curl sqlite3 wget" +app_umask="0002" +branch="main" + +# Constants +### Update these variables as required for your specific instance +installdir="/opt" # {Update me if needed} Install Location +bindir="${installdir}/${app^}" # Full Path to Install Location +datadir="/var/lib/$app/" # {Update me if needed} AppData directory to use +app_bin=${app^} # Binary Name of the app + +# This script should not be ran from installdir, otherwise later in the script the extracted files will be removed before they can be moved to installdir. +if [ "$installdir" == "$(dirname -- "$( readlink -f -- "$0"; )")" ] || [ "$bindir" == "$(dirname -- "$( readlink -f -- "$0"; )")" ]; then + echo "You should not run this script from the intended install directory. The script will exit. Please re-run it from another directory" + exit +fi + +# Prompt User +read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty +app_uid=$(echo "$app_uid" | tr -d ' ') +app_uid=${app_uid:-$app} +# Prompt Group +read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty +app_guid=$(echo "$app_guid" | tr -d ' ') +app_guid=${app_guid:-media} + +echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory" +echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories" +read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty + +# Create User / Group as needed +if [ "$app_guid" != "$app_uid" ]; then + if ! getent group "$app_guid" >/dev/null; then + groupadd "$app_guid" + fi +fi +if ! getent passwd "$app_uid" >/dev/null; then + adduser --system --no-create-home --ingroup "$app_guid" "$app_uid" + echo "Created and added User [$app_uid] to Group [$app_guid]" +fi +if ! getent group "$app_guid" | grep -qw "$app_uid"; then + echo "User [$app_uid] did not exist in Group [$app_guid]" + usermod -a -G "$app_guid" "$app_uid" + echo "Added User [$app_uid] to Group [$app_guid]" +fi + +# Stop the App if running +if service --status-all | grep -Fq "$app"; then + systemctl stop "$app" + systemctl disable "$app".service + echo "Stopped existing $app" +fi + +# Create Appdata Directory + +# AppData +mkdir -p "$datadir" +chown -R "$app_uid":"$app_guid" "$datadir" +chmod 775 "$datadir" +echo "Directories created" +# Download and install the App + +# prerequisite packages +echo "" +echo "Installing pre-requisite Packages" +# shellcheck disable=SC2086 +apt update && apt install -y $app_prereq +echo "" +ARCH=$(dpkg --print-architecture) +# get arch +dlbase="https://lidarr.servarr.com/v1/update/$branch/updatefile?os=linux&runtime=netcore" +case "$ARCH" in +"amd64") DLURL="${dlbase}&arch=x64" ;; +"armhf") DLURL="${dlbase}&arch=arm" ;; +"arm64") DLURL="${dlbase}&arch=arm64" ;; +*) + echo "Arch not supported" + exit 1 + ;; +esac +echo "" +echo "Removing previous tarballs" +# -f to Force so we fail if it doesn't exist +rm -f "${app^}".*.tar.gz +echo "" +echo "Downloading..." +wget --content-disposition "$DLURL" +tar -xvzf "${app^}".*.tar.gz +echo "" +echo "Installation files downloaded and extracted" + +# remove existing installs +echo "Removing existing installation" +rm -rf "$bindir" +echo "Installing..." +mv "${app^}" $installdir +chown "$app_uid":"$app_guid" -R "$bindir" +chmod 775 "$bindir" +rm -rf "${app^}.*.tar.gz" +# Ensure we check for an update in case user installs older version or different branch +touch "$datadir"/update_required +chown "$app_uid":"$app_guid" "$datadir"/update_required +echo "App Installed" +# Configure Autostart + +# Remove any previous app .service +echo "Removing old service file" +rm -rf /etc/systemd/system/"$app".service + +# Create app .service with correct user startup +echo "Creating service file" +cat </dev/null +[Unit] +Description=${app^} Daemon +After=syslog.target network.target +[Service] +User=$app_uid +Group=$app_guid +UMask=$app_umask +Type=simple +ExecStart=$bindir/$app_bin -nobrowser -data=$datadir +TimeoutStopSec=20 +KillMode=process +Restart=on-failure +[Install] +WantedBy=multi-user.target +EOF + +# Start the App +echo "Service file created. Attempting to start the app" +systemctl -q daemon-reload +systemctl enable --now -q "$app" + +# Finish Update/Installation +host=$(hostname -I) +ip_local=$(grep -oP '^\S*' <<<"$host") +echo "" +echo "Install complete" +sleep 10 +STATUS="$(systemctl is-active "$app")" +if [ "${STATUS}" = "active" ]; then + echo "Browse to http://$ip_local:$app_port for the ${app^} GUI" +else + echo "${app^} failed to start" +fi + +# Exit +exit 0 diff --git a/distribution/debian/lidarr.service b/distribution/debian/lidarr.service new file mode 100644 index 000000000..8ec5b5b1d --- /dev/null +++ b/distribution/debian/lidarr.service @@ -0,0 +1,20 @@ +# This file is owned by the lidarr package, DO NOT MODIFY MANUALLY +# Instead use 'dpkg-reconfigure -plow lidarr' to modify User/Group/UMask/-data +# Or use systemd built-in override functionality using 'systemctl edit lidarr' +[Unit] +Description=Lidarr Daemon +After=network.target + +[Service] +User=lidarr +Group=lidarr +UMask=002 + +Type=simple +ExecStart=/opt/Lidarr/Lidarr -nobrowser -data=/var/lib/lidarr +TimeoutStopSec=20 +KillMode=process +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/docs.sh b/docs.sh index 9cbb02756..a44dc90ce 100644 --- a/docs.sh +++ b/docs.sh @@ -1,13 +1,18 @@ +#!/bin/bash +set -e + +FRAMEWORK="net6.0" PLATFORM=$1 +ARCHITECTURE="${2:-x64}" if [ "$PLATFORM" = "Windows" ]; then - RUNTIME="win-x64" + RUNTIME="win-$ARCHITECTURE" elif [ "$PLATFORM" = "Linux" ]; then - RUNTIME="linux-x64" + RUNTIME="linux-$ARCHITECTURE" elif [ "$PLATFORM" = "Mac" ]; then - RUNTIME="osx-x64" + RUNTIME="osx-$ARCHITECTURE" else - echo "Platform must be provided as first arguement: Windows, Linux or Mac" + echo "Platform must be provided as first argument: Windows, Linux or Mac" exit 1 fi @@ -21,15 +26,21 @@ slnFile=src/Lidarr.sln platform=Posix +if [ "$PLATFORM" = "Windows" ]; then + application=Lidarr.Console.dll +else + application=Lidarr.dll +fi + dotnet clean $slnFile -c Debug dotnet clean $slnFile -c Release dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids dotnet new tool-manifest -dotnet tool install --version 6.5.0 Swashbuckle.AspNetCore.Cli +dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli -dotnet tool run swagger tofile --output ./src/Lidarr.Api.V1/openapi.json "$outputFolder/net6.0/$RUNTIME/lidarr.console.dll" v1 & +dotnet tool run swagger tofile --output ./src/Lidarr.Api.V1/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v1 & sleep 45 diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 603b20a48..cc26a2633 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -28,7 +28,8 @@ module.exports = { globals: { expect: false, chai: false, - sinon: false + sinon: false, + JSX: true }, parserOptions: { diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json index edb88e0e7..8da95337f 100644 --- a/frontend/.vscode/settings.json +++ b/frontend/.vscode/settings.json @@ -9,7 +9,7 @@ "editor.formatOnSave": false, "editor.codeActionsOnSave": { - "source.fixAll": true + "source.fixAll": "explicit" }, "typescript.preferences.quoteStyle": "single", diff --git a/frontend/babel.config.js b/frontend/babel.config.js index 5c0d5ecdc..ade9f24a2 100644 --- a/frontend/babel.config.js +++ b/frontend/babel.config.js @@ -2,6 +2,8 @@ const loose = true; module.exports = { plugins: [ + '@babel/plugin-transform-logical-assignment-operators', + // Stage 1 '@babel/plugin-proposal-export-default-from', ['@babel/plugin-transform-optional-chaining', { loose }], diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js index e0ec27c27..d1873380e 100644 --- a/frontend/build/webpack.config.js +++ b/frontend/build/webpack.config.js @@ -26,6 +26,7 @@ module.exports = (env) => { const config = { mode: isProduction ? 'production' : 'development', devtool: isProduction ? 'source-map' : 'eval-source-map', + target: 'web', stats: { children: false @@ -67,7 +68,7 @@ module.exports = (env) => { output: { path: distFolder, publicPath: '/', - filename: '[name]-[contenthash].js', + filename: isProduction ? '[name]-[contenthash].js' : '[name].js', sourceMapFilename: '[file].map' }, @@ -92,7 +93,7 @@ module.exports = (env) => { new MiniCssExtractPlugin({ filename: 'Content/styles.css', - chunkFilename: 'Content/[id]-[chunkhash].css' + chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css' }), new HtmlWebpackPlugin({ @@ -134,6 +135,12 @@ module.exports = (env) => { { source: 'frontend/src/Content/robots.txt', destination: path.join(distFolder, 'Content/robots.txt') + }, + + // manifest.json and browserconfig.xml + { + source: 'frontend/src/Content/*.(json|xml)', + destination: path.join(distFolder, 'Content') } ] } @@ -181,7 +188,7 @@ module.exports = (env) => { loose: true, debug: false, useBuiltIns: 'entry', - corejs: 3 + corejs: '3.41' } ] ] @@ -202,7 +209,7 @@ module.exports = (env) => { options: { importLoaders: 1, modules: { - localIdentName: '[name]/[local]/[hash:base64:5]' + localIdentName: isProduction ? '[name]/[local]/[hash:base64:5]' : '[name]/[local]' } } }, diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index f657adf28..89db00f8c 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -16,6 +16,7 @@ const mixinsFiles = [ module.exports = { plugins: [ + 'autoprefixer', ['postcss-mixins', { mixinsFiles }], diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js index b90a64f47..84aa3e0f2 100644 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -172,7 +172,8 @@ function HistoryDetails(props) { if (eventType === 'downloadFailed') { const { - message + message, + indexer } = data; return ( @@ -192,6 +193,14 @@ function HistoryDetails(props) { null } + { + indexer ? ( + + ) : null} + { message ? - { + const item = items.find((i) => i.id === id); + + return !!(item && item.downloadClientHasPostImportCategory); + }) + )} canIgnore={isConfirmRemoveModalOpen && ( selectedIds.every((id) => { const item = items.find((i) => i.id === id); @@ -319,7 +326,7 @@ class Queue extends Component { return !!(item && item.artistId && item.albumId); }) )} - allPending={isConfirmRemoveModalOpen && ( + pending={isConfirmRemoveModalOpen && ( selectedIds.every((id) => { const item = items.find((i) => i.id === id); diff --git a/frontend/src/Activity/Queue/QueueRow.css b/frontend/src/Activity/Queue/QueueRow.css index 6636f2f9d..2a0df3595 100644 --- a/frontend/src/Activity/Queue/QueueRow.css +++ b/frontend/src/Activity/Queue/QueueRow.css @@ -26,4 +26,5 @@ composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 90px; + text-align: right; } diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js index 4e3a0b380..d0f1fbacf 100644 --- a/frontend/src/Activity/Queue/QueueRow.js +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -98,6 +98,7 @@ class QueueRow extends Component { indexer, outputPath, downloadClient, + downloadClientHasPostImportCategory, downloadForced, estimatedCompletionTime, added, @@ -403,6 +404,7 @@ class QueueRow extends Component { { - this.setState({ removeFromClient: value }); - }; - - onBlocklistChange = ({ value }) => { - this.setState({ blocklist: value }); - }; - - onSkipRedownloadChange = ({ value }) => { - this.setState({ skipRedownload: value }); - }; - - onRemoveConfirmed = () => { - const state = this.state; - - this.resetState(); - this.props.onRemovePress(state); - }; - - onModalClose = () => { - this.resetState(); - this.props.onModalClose(); - }; - - // - // Render - - render() { - const { - isOpen, - sourceTitle, - canIgnore, - isPending - } = this.props; - - const { removeFromClient, blocklist, skipRedownload } = this.state; - - return ( - - - - Remove - {sourceTitle} - - - -
- Are you sure you want to remove '{sourceTitle}' from the queue? -
- - { - isPending ? - null : - - {translate('RemoveFromDownloadClient')} - - - - } - - - - {translate('BlocklistRelease')} - - - - - - { - blocklist && - - - {translate('SkipRedownload')} - - - - } - -
- - - - - - -
-
- ); - } -} - -RemoveQueueItemModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - sourceTitle: PropTypes.string.isRequired, - canIgnore: PropTypes.bool.isRequired, - isPending: PropTypes.bool.isRequired, - onRemovePress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default RemoveQueueItemModal; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx b/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx new file mode 100644 index 000000000..f25bb0d1b --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx @@ -0,0 +1,231 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './RemoveQueueItemModal.css'; + +interface RemovePressProps { + removeFromClient: boolean; + changeCategory: boolean; + blocklist: boolean; + skipRedownload: boolean; +} + +interface RemoveQueueItemModalProps { + isOpen: boolean; + sourceTitle: string; + canChangeCategory: boolean; + canIgnore: boolean; + isPending: boolean; + selectedCount?: number; + onRemovePress(props: RemovePressProps): void; + onModalClose: () => void; +} + +type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore'; +type BlocklistMethod = + | 'doNotBlocklist' + | 'blocklistAndSearch' + | 'blocklistOnly'; + +function RemoveQueueItemModal(props: RemoveQueueItemModalProps) { + const { + isOpen, + sourceTitle, + canIgnore, + canChangeCategory, + isPending, + selectedCount, + onRemovePress, + onModalClose, + } = props; + + const multipleSelected = selectedCount && selectedCount > 1; + + const [removalMethod, setRemovalMethod] = + useState('removeFromClient'); + const [blocklistMethod, setBlocklistMethod] = + useState('doNotBlocklist'); + + const { title, message } = useMemo(() => { + if (!selectedCount) { + return { + title: translate('RemoveQueueItem', { sourceTitle }), + message: translate('RemoveQueueItemConfirmation', { sourceTitle }), + }; + } + + if (selectedCount === 1) { + return { + title: translate('RemoveSelectedItem'), + message: translate('RemoveSelectedItemQueueMessageText'), + }; + } + + return { + title: translate('RemoveSelectedItems'), + message: translate('RemoveSelectedItemsQueueMessageText', { + selectedCount, + }), + }; + }, [sourceTitle, selectedCount]); + + const removalMethodOptions = useMemo(() => { + return [ + { + key: 'removeFromClient', + value: translate('RemoveFromDownloadClient'), + hint: multipleSelected + ? translate('RemoveMultipleFromDownloadClientHint') + : translate('RemoveFromDownloadClientHint'), + }, + { + key: 'changeCategory', + value: translate('ChangeCategory'), + isDisabled: !canChangeCategory, + hint: multipleSelected + ? translate('ChangeCategoryMultipleHint') + : translate('ChangeCategoryHint'), + }, + { + key: 'ignore', + value: multipleSelected + ? translate('IgnoreDownloads') + : translate('IgnoreDownload'), + isDisabled: !canIgnore, + hint: multipleSelected + ? translate('IgnoreDownloadsHint') + : translate('IgnoreDownloadHint'), + }, + ]; + }, [canChangeCategory, canIgnore, multipleSelected]); + + const blocklistMethodOptions = useMemo(() => { + return [ + { + key: 'doNotBlocklist', + value: translate('DoNotBlocklist'), + hint: translate('DoNotBlocklistHint'), + }, + { + key: 'blocklistAndSearch', + value: translate('BlocklistAndSearch'), + isDisabled: isPending, + hint: multipleSelected + ? translate('BlocklistAndSearchMultipleHint') + : translate('BlocklistAndSearchHint'), + }, + { + key: 'blocklistOnly', + value: translate('BlocklistOnly'), + hint: multipleSelected + ? translate('BlocklistMultipleOnlyHint') + : translate('BlocklistOnlyHint'), + }, + ]; + }, [isPending, multipleSelected]); + + const handleRemovalMethodChange = useCallback( + ({ value }: { value: RemovalMethod }) => { + setRemovalMethod(value); + }, + [setRemovalMethod] + ); + + const handleBlocklistMethodChange = useCallback( + ({ value }: { value: BlocklistMethod }) => { + setBlocklistMethod(value); + }, + [setBlocklistMethod] + ); + + const handleConfirmRemove = useCallback(() => { + onRemovePress({ + removeFromClient: removalMethod === 'removeFromClient', + changeCategory: removalMethod === 'changeCategory', + blocklist: blocklistMethod !== 'doNotBlocklist', + skipRedownload: blocklistMethod === 'blocklistOnly', + }); + + setRemovalMethod('removeFromClient'); + setBlocklistMethod('doNotBlocklist'); + }, [ + removalMethod, + blocklistMethod, + setRemovalMethod, + setBlocklistMethod, + onRemovePress, + ]); + + const handleModalClose = useCallback(() => { + setRemovalMethod('removeFromClient'); + setBlocklistMethod('doNotBlocklist'); + + onModalClose(); + }, [setRemovalMethod, setBlocklistMethod, onModalClose]); + + return ( + + + {title} + + +
{message}
+ + {isPending ? null : ( + + {translate('RemoveQueueItemRemovalMethod')} + + + + )} + + + + {multipleSelected + ? translate('BlocklistReleases') + : translate('BlocklistRelease')} + + + + +
+ + + + + + +
+
+ ); +} + +export default RemoveQueueItemModal; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js deleted file mode 100644 index f607161b0..000000000 --- a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js +++ /dev/null @@ -1,176 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import Modal from 'Components/Modal/Modal'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes, kinds, sizes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import styles from './RemoveQueueItemsModal.css'; - -class RemoveQueueItemsModal extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - removeFromClient: true, - blocklist: false, - skipRedownload: false - }; - } - - // - // Control - - resetState = function() { - this.setState({ - removeFromClient: true, - blocklist: false, - skipRedownload: false - }); - }; - - // - // Listeners - - onRemoveFromClientChange = ({ value }) => { - this.setState({ removeFromClient: value }); - }; - - onBlocklistChange = ({ value }) => { - this.setState({ blocklist: value }); - }; - - onSkipRedownloadChange = ({ value }) => { - this.setState({ skipRedownload: value }); - }; - - onRemoveConfirmed = () => { - const state = this.state; - - this.resetState(); - this.props.onRemovePress(state); - }; - - onModalClose = () => { - this.resetState(); - this.props.onModalClose(); - }; - - // - // Render - - render() { - const { - isOpen, - selectedCount, - canIgnore, - allPending - } = this.props; - - const { removeFromClient, blocklist, skipRedownload } = this.state; - - return ( - - - - {selectedCount > 1 ? translate('RemoveSelectedItems') : translate('RemoveSelectedItem')} - - - -
- {selectedCount > 1 ? translate('RemoveSelectedItemsQueueMessageText', [selectedCount]) : translate('RemoveSelectedItemQueueMessageText')} -
- - { - allPending ? - null : - - {translate('RemoveFromDownloadClient')} - - - - } - - - - {selectedCount > 1 ? translate('BlocklistReleases') : translate('BlocklistRelease')} - - - - - - { - blocklist && - - - {translate('SkipRedownload')} - - - - } - -
- - - - - - -
-
- ); - } -} - -RemoveQueueItemsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - selectedCount: PropTypes.number.isRequired, - canIgnore: PropTypes.bool.isRequired, - allPending: PropTypes.bool.isRequired, - onRemovePress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default RemoveQueueItemsModal; diff --git a/frontend/src/Activity/Queue/TimeleftCell.js b/frontend/src/Activity/Queue/TimeleftCell.js index f2aa1390a..b280b5a06 100644 --- a/frontend/src/Activity/Queue/TimeleftCell.js +++ b/frontend/src/Activity/Queue/TimeleftCell.js @@ -1,6 +1,9 @@ import PropTypes from 'prop-types'; import React from 'react'; +import Icon from 'Components/Icon'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import formatTime from 'Utilities/Date/formatTime'; import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; import getRelativeDate from 'Utilities/Date/getRelativeDate'; @@ -25,11 +28,13 @@ function TimeleftCell(props) { const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); return ( - - - + + } + tooltip={translate('DelayingDownloadUntil', { date, time })} + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> ); } @@ -39,11 +44,13 @@ function TimeleftCell(props) { const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); return ( - - - + + } + tooltip={translate('RetryingDownloadOn', { date, time })} + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> ); } diff --git a/frontend/src/AddArtist/ArtistMonitorNewItemsOptionsPopoverContent.js b/frontend/src/AddArtist/ArtistMonitorNewItemsOptionsPopoverContent.js index 5a837b1eb..cda224e2f 100644 --- a/frontend/src/AddArtist/ArtistMonitorNewItemsOptionsPopoverContent.js +++ b/frontend/src/AddArtist/ArtistMonitorNewItemsOptionsPopoverContent.js @@ -8,17 +8,17 @@ function ArtistMonitorNewItemsOptionsPopoverContent() { ); diff --git a/frontend/src/Album/Album.ts b/frontend/src/Album/Album.ts index c9f10a87c..86f1ed5fe 100644 --- a/frontend/src/Album/Album.ts +++ b/frontend/src/Album/Album.ts @@ -10,6 +10,7 @@ export interface Statistics { } interface Album extends ModelBase { + artistId: number; artist: Artist; foreignAlbumId: string; title: string; @@ -19,6 +20,7 @@ interface Album extends ModelBase { monitored: boolean; releaseDate: string; statistics: Statistics; + lastSearchTime?: string; isSaving?: boolean; } diff --git a/frontend/src/Album/AlbumSearchCell.js b/frontend/src/Album/AlbumSearchCell.js index 382cbec21..f9ca44800 100644 --- a/frontend/src/Album/AlbumSearchCell.js +++ b/frontend/src/Album/AlbumSearchCell.js @@ -4,6 +4,7 @@ import IconButton from 'Components/Link/IconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; import AlbumInteractiveSearchModalConnector from './Search/AlbumInteractiveSearchModalConnector'; import styles from './AlbumSearchCell.css'; @@ -49,11 +50,13 @@ class AlbumSearchCell extends Component { name={icons.SEARCH} isSpinning={isSearching} onPress={onSearchPress} + title={translate('AutomaticSearch')} /> - {title}{disambiguation ? ` (${disambiguation})` : ''} + + {albumTitle} ); } diff --git a/frontend/src/Album/Delete/DeleteAlbumModalContent.js b/frontend/src/Album/Delete/DeleteAlbumModalContent.js index e45985c97..28505ea75 100644 --- a/frontend/src/Album/Delete/DeleteAlbumModalContent.js +++ b/frontend/src/Album/Delete/DeleteAlbumModalContent.js @@ -53,7 +53,7 @@ class DeleteAlbumModalContent extends Component { render() { const { title, - statistics, + statistics = {}, onModalClose } = this.props; diff --git a/frontend/src/Album/Details/AlbumDetails.css b/frontend/src/Album/Details/AlbumDetails.css index d87920074..a676ae574 100644 --- a/frontend/src/Album/Details/AlbumDetails.css +++ b/frontend/src/Album/Details/AlbumDetails.css @@ -121,6 +121,8 @@ .releaseDate, .sizeOnDisk, +.albumType, +.secondaryTypes, .qualityProfileName, .links, .tags { diff --git a/frontend/src/Album/Details/AlbumDetails.css.d.ts b/frontend/src/Album/Details/AlbumDetails.css.d.ts index 4c126c8b5..1d14a0ccf 100644 --- a/frontend/src/Album/Details/AlbumDetails.css.d.ts +++ b/frontend/src/Album/Details/AlbumDetails.css.d.ts @@ -3,6 +3,7 @@ interface CssExports { 'albumNavigationButton': string; 'albumNavigationButtons': string; + 'albumType': string; 'alternateTitlesIconContainer': string; 'backdrop': string; 'backdropOverlay': string; @@ -20,6 +21,7 @@ interface CssExports { 'overview': string; 'qualityProfileName': string; 'releaseDate': string; + 'secondaryTypes': string; 'sizeOnDisk': string; 'tags': string; 'title': string; diff --git a/frontend/src/Album/Details/AlbumDetails.js b/frontend/src/Album/Details/AlbumDetails.js index 783216a61..fe007e168 100644 --- a/frontend/src/Album/Details/AlbumDetails.js +++ b/frontend/src/Album/Details/AlbumDetails.js @@ -192,6 +192,7 @@ class AlbumDetails extends Component { duration, overview, albumType, + secondaryTypes, statistics = {}, monitored, releaseDate, @@ -204,6 +205,7 @@ class AlbumDetails extends Component { isFetching, isPopulated, albumsError, + tracksError, trackFilesError, hasTrackFiles, shortDateFormat, @@ -396,10 +398,11 @@ class AlbumDetails extends Component {
{ - !!duration && + duration ? {formatDuration(duration)} - + : + null } - - - - {moment(releaseDate).format(shortDateFormat)} - +
+ + + {moment(releaseDate).format(shortDateFormat)} + +
- - - - { - formatBytes(sizeOnDisk || 0) - } - +
+ + + {formatBytes(sizeOnDisk)} + +
} tooltip={ @@ -459,32 +462,55 @@ class AlbumDetails extends Component { className={styles.detailsLabel} size={sizes.LARGE} > - - - - {monitored ? translate('Monitored') : translate('Unmonitored')} - +
+ + + {monitored ? translate('Monitored') : translate('Unmonitored')} + +
{ - !!albumType && + albumType ? : + null + } - - {albumType} - - + { + secondaryTypes.length ? + : + null } - - - - {translate('Links')} - +
+ + + {translate('Links')} + +
} tooltip={ @@ -526,8 +553,9 @@ class AlbumDetails extends Component {
{ - !isPopulated && !albumsError && !trackFilesError && - + !isPopulated && !albumsError && !tracksError && !trackFilesError ? + : + null } { @@ -538,6 +566,14 @@ class AlbumDetails extends Component { null } + { + !isFetching && tracksError ? + + {translate('TracksLoadError')} + : + null + } + { !isFetching && trackFilesError ? @@ -566,6 +602,14 @@ class AlbumDetails extends Component {
} + { + isPopulated && !media.length ? + + {translate('NoMediumInformation')} + : + null + } +
{ if (track.trackFileId) { - trackCount++; trackFileCount++; - } else { - trackCount++; } totalTrackCount++; @@ -175,7 +172,7 @@ class AlbumDetailsMedium extends Component { :
- No tracks in this medium + {translate('NoTracksInThisMedium')}
}
diff --git a/frontend/src/Album/Details/TrackRow.css b/frontend/src/Album/Details/TrackRow.css index 11ebb64fa..912c00101 100644 --- a/frontend/src/Album/Details/TrackRow.css +++ b/frontend/src/Album/Details/TrackRow.css @@ -35,3 +35,9 @@ width: 55px; } + +.indexerFlags { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} diff --git a/frontend/src/Album/Details/TrackRow.css.d.ts b/frontend/src/Album/Details/TrackRow.css.d.ts index c5644a2d4..79bbdaf43 100644 --- a/frontend/src/Album/Details/TrackRow.css.d.ts +++ b/frontend/src/Album/Details/TrackRow.css.d.ts @@ -4,6 +4,7 @@ interface CssExports { 'audio': string; 'customFormatScore': string; 'duration': string; + 'indexerFlags': string; 'monitored': string; 'size': string; 'status': string; diff --git a/frontend/src/Album/Details/TrackRow.js b/frontend/src/Album/Details/TrackRow.js index 5f60df882..db128d493 100644 --- a/frontend/src/Album/Details/TrackRow.js +++ b/frontend/src/Album/Details/TrackRow.js @@ -2,15 +2,19 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import AlbumFormats from 'Album/AlbumFormats'; import EpisodeStatusConnector from 'Album/EpisodeStatusConnector'; +import IndexerFlags from 'Album/IndexerFlags'; +import Icon from 'Components/Icon'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; +import Popover from 'Components/Tooltip/Popover'; import Tooltip from 'Components/Tooltip/Tooltip'; -import { tooltipPositions } from 'Helpers/Props'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import MediaInfoConnector from 'TrackFile/MediaInfoConnector'; import * as mediaInfoTypes from 'TrackFile/mediaInfoTypes'; import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; import formatBytes from 'Utilities/Number/formatBytes'; import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import translate from 'Utilities/String/translate'; import TrackActionsCell from './TrackActionsCell'; import styles from './TrackRow.css'; @@ -32,6 +36,7 @@ class TrackRow extends Component { trackFileSize, customFormats, customFormatScore, + indexerFlags, columns, deleteTrackFile } = this.props; @@ -141,12 +146,30 @@ class TrackRow extends Component { customFormats.length )} tooltip={} - position={tooltipPositions.BOTTOM} + position={tooltipPositions.LEFT} /> ); } + if (name === 'indexerFlags') { + return ( + + {indexerFlags ? ( + } + title={translate('IndexerFlags')} + body={} + position={tooltipPositions.LEFT} + /> + ) : null} + + ); + } + if (name === 'size') { return ( (indexerFlags & item.id) === item.id + ); + + return flags.length ? ( +
    + {flags.map((flag, index) => { + return
  • {flag.name}
  • ; + })} +
+ ) : null; +} + +export default IndexerFlags; diff --git a/frontend/src/Album/Search/AlbumInteractiveSearchModal.js b/frontend/src/Album/Search/AlbumInteractiveSearchModal.js index 5049658a0..6ce488615 100644 --- a/frontend/src/Album/Search/AlbumInteractiveSearchModal.js +++ b/frontend/src/Album/Search/AlbumInteractiveSearchModal.js @@ -15,7 +15,7 @@ function AlbumInteractiveSearchModal(props) { return ( diff --git a/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js b/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js index 97261ee35..370f67ab1 100644 --- a/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js +++ b/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js @@ -7,6 +7,7 @@ import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { scrollDirections } from 'Helpers/Props'; import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector'; +import translate from 'Utilities/String/translate'; function AlbumInteractiveSearchModalContent(props) { const { @@ -18,7 +19,10 @@ function AlbumInteractiveSearchModalContent(props) { return ( - Interactive Search {albumId != null && `- ${albumTitle}`} + {albumTitle === undefined ? + translate('InteractiveSearchModalHeader') : + translate('InteractiveSearchModalHeaderTitle', { title: albumTitle }) + } @@ -32,7 +36,7 @@ function AlbumInteractiveSearchModalContent(props) { diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js index 3871b14e9..9e8d508ac 100644 --- a/frontend/src/App/App.js +++ b/frontend/src/App/App.js @@ -12,11 +12,10 @@ function App({ store, history }) { - - - - - + + + + diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 0af990f43..c1004d36d 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -11,7 +11,7 @@ import CalendarPageConnector from 'Calendar/CalendarPageConnector'; import NotFound from 'Components/NotFound'; import Switch from 'Components/Router/Switch'; import AddNewItemConnector from 'Search/AddNewItemConnector'; -import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector'; +import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage'; import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector'; @@ -29,7 +29,7 @@ import LogsTableConnector from 'System/Events/LogsTableConnector'; import Logs from 'System/Logs/Logs'; import Status from 'System/Status/Status'; import Tasks from 'System/Tasks/Tasks'; -import UpdatesConnector from 'System/Updates/UpdatesConnector'; +import Updates from 'System/Updates/Updates'; import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector'; import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector'; @@ -184,7 +184,7 @@ function AppRoutes(props) { state.settings.ui.item.theme || window.Lidarr.theme, - ( - theme - ) => { - return { - theme - }; - } - ); -} - -function ApplyTheme({ theme, children }) { - // Update the CSS Variables - - const updateCSSVariables = useCallback(() => { - const arrayOfVariableKeys = Object.keys(themes[theme]); - const arrayOfVariableValues = Object.values(themes[theme]); - - // Loop through each array key and set the CSS Variables - arrayOfVariableKeys.forEach((cssVariableKey, index) => { - // Based on our snippet from MDN - document.documentElement.style.setProperty( - `--${cssVariableKey}`, - arrayOfVariableValues[index] - ); - }); - }, [theme]); - - // On Component Mount and Component Update - useEffect(() => { - updateCSSVariables(theme); - }, [updateCSSVariables, theme]); - - return {children}; -} - -ApplyTheme.propTypes = { - theme: PropTypes.string.isRequired, - children: PropTypes.object.isRequired -}; - -export default connect(createMapStateToProps)(ApplyTheme); diff --git a/frontend/src/App/ApplyTheme.tsx b/frontend/src/App/ApplyTheme.tsx new file mode 100644 index 000000000..e04dda8c4 --- /dev/null +++ b/frontend/src/App/ApplyTheme.tsx @@ -0,0 +1,37 @@ +import React, { Fragment, ReactNode, useCallback, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import themes from 'Styles/Themes'; +import AppState from './State/AppState'; + +interface ApplyThemeProps { + children: ReactNode; +} + +function createThemeSelector() { + return createSelector( + (state: AppState) => state.settings.ui.item.theme || window.Lidarr.theme, + (theme) => { + return theme; + } + ); +} + +function ApplyTheme({ children }: ApplyThemeProps) { + const theme = useSelector(createThemeSelector()); + + const updateCSSVariables = useCallback(() => { + Object.entries(themes[theme]).forEach(([key, value]) => { + document.documentElement.style.setProperty(`--${key}`, value); + }); + }, [theme]); + + // On Component Mount and Component Update + useEffect(() => { + updateCSSVariables(); + }, [updateCSSVariables, theme]); + + return {children}; +} + +export default ApplyTheme; diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 04f7a609f..cb8da5987 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,8 +1,12 @@ +import ParseAppState from 'App/State/ParseAppState'; import AlbumAppState from './AlbumAppState'; import ArtistAppState, { ArtistIndexAppState } from './ArtistAppState'; +import CalendarAppState from './CalendarAppState'; +import CommandAppState from './CommandAppState'; import HistoryAppState from './HistoryAppState'; import QueueAppState from './QueueAppState'; import SettingsAppState from './SettingsAppState'; +import SystemAppState from './SystemAppState'; import TagsAppState from './TagsAppState'; import TrackFilesAppState from './TrackFilesAppState'; import TracksAppState from './TracksAppState'; @@ -40,6 +44,7 @@ export interface CustomFilter { } export interface AppSectionState { + version: string; dimensions: { isSmallScreen: boolean; width: number; @@ -52,12 +57,16 @@ interface AppState { app: AppSectionState; artist: ArtistAppState; artistIndex: ArtistIndexAppState; + calendar: CalendarAppState; + commands: CommandAppState; history: HistoryAppState; + parse: ParseAppState; queue: QueueAppState; settings: SettingsAppState; tags: TagsAppState; trackFiles: TrackFilesAppState; tracksSelection: TracksAppState; + system: SystemAppState; } export default AppState; diff --git a/frontend/src/App/State/CalendarAppState.ts b/frontend/src/App/State/CalendarAppState.ts new file mode 100644 index 000000000..503d2c25b --- /dev/null +++ b/frontend/src/App/State/CalendarAppState.ts @@ -0,0 +1,10 @@ +import Album from 'Album/Album'; +import AppSectionState, { + AppSectionFilterState, +} from 'App/State/AppSectionState'; + +interface CalendarAppState + extends AppSectionState, + AppSectionFilterState {} + +export default CalendarAppState; diff --git a/frontend/src/App/State/CommandAppState.ts b/frontend/src/App/State/CommandAppState.ts new file mode 100644 index 000000000..1bde37371 --- /dev/null +++ b/frontend/src/App/State/CommandAppState.ts @@ -0,0 +1,6 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Command from 'Commands/Command'; + +export type CommandAppState = AppSectionState; + +export default CommandAppState; diff --git a/frontend/src/App/State/ParseAppState.ts b/frontend/src/App/State/ParseAppState.ts new file mode 100644 index 000000000..827d5b1a7 --- /dev/null +++ b/frontend/src/App/State/ParseAppState.ts @@ -0,0 +1,35 @@ +import Album from 'Album/Album'; +import ModelBase from 'App/ModelBase'; +import { AppSectionItemState } from 'App/State/AppSectionState'; +import Artist from 'Artist/Artist'; +import { QualityModel } from 'Quality/Quality'; +import CustomFormat from 'typings/CustomFormat'; + +export interface ArtistTitleInfo { + title: string; +} + +export interface ParsedAlbumInfo { + albumTitle: string; + artistName: string; + artistTitleInfo: ArtistTitleInfo; + discography: boolean; + quality: QualityModel; + releaseGroup?: string; + releaseHash: string; + releaseTitle: string; + releaseTokens: string; +} + +export interface ParseModel extends ModelBase { + title: string; + parsedAlbumInfo: ParsedAlbumInfo; + artist?: Artist; + albums: Album[]; + customFormats?: CustomFormat[]; + customFormatScore?: number; +} + +type ParseAppState = AppSectionItemState; + +export default ParseAppState; diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index c5b974610..b387e13fd 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -1,21 +1,28 @@ import AppSectionState, { AppSectionDeleteState, + AppSectionItemState, AppSectionSaveState, AppSectionSchemaState, } from 'App/State/AppSectionState'; +import CustomFormat from 'typings/CustomFormat'; import DownloadClient from 'typings/DownloadClient'; import ImportList from 'typings/ImportList'; import Indexer from 'typings/Indexer'; +import IndexerFlag from 'typings/IndexerFlag'; import MetadataProfile from 'typings/MetadataProfile'; import Notification from 'typings/Notification'; import QualityProfile from 'typings/QualityProfile'; -import { UiSettings } from 'typings/UiSettings'; +import RootFolder from 'typings/RootFolder'; +import General from 'typings/Settings/General'; +import UiSettings from 'typings/Settings/UiSettings'; export interface DownloadClientAppState extends AppSectionState, AppSectionDeleteState, AppSectionSaveState {} +export type GeneralAppState = AppSectionItemState; + export interface ImportListAppState extends AppSectionState, AppSectionDeleteState, @@ -38,16 +45,32 @@ export interface MetadataProfilesAppState extends AppSectionState, AppSectionSchemaState {} -export type UiSettingsAppState = AppSectionState; +export interface CustomFormatAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export interface RootFolderAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export type IndexerFlagSettingsAppState = AppSectionState; +export type UiSettingsAppState = AppSectionItemState; interface SettingsAppState { + advancedSettings: boolean; + customFormats: CustomFormatAppState; downloadClients: DownloadClientAppState; + general: GeneralAppState; importLists: ImportListAppState; + indexerFlags: IndexerFlagSettingsAppState; indexers: IndexerAppState; metadataProfiles: MetadataProfilesAppState; notifications: NotificationAppState; qualityProfiles: QualityProfilesAppState; - uiSettings: UiSettingsAppState; + rootFolders: RootFolderAppState; + ui: UiSettingsAppState; } export default SettingsAppState; diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts new file mode 100644 index 000000000..3c150fcfb --- /dev/null +++ b/frontend/src/App/State/SystemAppState.ts @@ -0,0 +1,13 @@ +import SystemStatus from 'typings/SystemStatus'; +import Update from 'typings/Update'; +import AppSectionState, { AppSectionItemState } from './AppSectionState'; + +export type SystemStatusAppState = AppSectionItemState; +export type UpdateAppState = AppSectionState; + +interface SystemAppState { + updates: UpdateAppState; + status: SystemStatusAppState; +} + +export default SystemAppState; diff --git a/frontend/src/App/State/TagsAppState.ts b/frontend/src/App/State/TagsAppState.ts index d1f1d5a2f..edaf3a158 100644 --- a/frontend/src/App/State/TagsAppState.ts +++ b/frontend/src/App/State/TagsAppState.ts @@ -1,12 +1,32 @@ import ModelBase from 'App/ModelBase'; import AppSectionState, { AppSectionDeleteState, + AppSectionSaveState, } from 'App/State/AppSectionState'; export interface Tag extends ModelBase { label: string; } -interface TagsAppState extends AppSectionState, AppSectionDeleteState {} +export interface TagDetail extends ModelBase { + label: string; + autoTagIds: number[]; + delayProfileIds: number[]; + downloadClientIds: []; + importListIds: number[]; + indexerIds: number[]; + notificationIds: number[]; + restrictionIds: number[]; + artistIds: number[]; +} + +export interface TagDetailAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +interface TagsAppState extends AppSectionState, AppSectionDeleteState { + details: TagDetailAppState; +} export default TagsAppState; diff --git a/frontend/src/Artist/Artist.ts b/frontend/src/Artist/Artist.ts index 61266a8d4..813dbea08 100644 --- a/frontend/src/Artist/Artist.ts +++ b/frontend/src/Artist/Artist.ts @@ -23,7 +23,6 @@ export interface Ratings { interface Artist extends ModelBase { added: string; - artistMetadataId: string; foreignArtistId: string; cleanName: string; ended: boolean; @@ -36,6 +35,7 @@ interface Artist extends ModelBase { nextAlbum?: Album; qualityProfileId: number; metadataProfileId: number; + monitorNewItems: string; ratings: Ratings; rootFolderPath: string; sortName: string; diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Delete/DeleteArtistModalContent.js index 0542f718b..ac1e2b041 100644 --- a/frontend/src/Artist/Delete/DeleteArtistModalContent.js +++ b/frontend/src/Artist/Delete/DeleteArtistModalContent.js @@ -135,14 +135,14 @@ class DeleteArtistModalContent extends Component { @@ -161,9 +161,7 @@ DeleteArtistModalContent.propTypes = { }; DeleteArtistModalContent.defaultProps = { - statistics: { - trackFileCount: 0 - } + statistics: {} }; export default DeleteArtistModalContent; diff --git a/frontend/src/Artist/Details/AlbumGroupInfo.js b/frontend/src/Artist/Details/AlbumGroupInfo.js index 0fb62d4a3..139cd7765 100644 --- a/frontend/src/Artist/Details/AlbumGroupInfo.js +++ b/frontend/src/Artist/Details/AlbumGroupInfo.js @@ -10,6 +10,7 @@ function AlbumGroupInfo(props) { const { totalAlbumCount, monitoredAlbumCount, + albumFileCount, trackFileCount, sizeOnDisk } = props; @@ -30,6 +31,13 @@ function AlbumGroupInfo(props) { data={monitoredAlbumCount} /> + + - { - secondaryTypes - } + {secondaryTypes.join(', ')}
); } @@ -158,7 +158,7 @@ class AlbumRow extends Component { return ( { - statistics.totalTrackCount + totalTrackCount } ); @@ -196,6 +196,17 @@ class AlbumRow extends Component { ); } + if (name === 'size') { + return ( + + {!!sizeOnDisk && formatBytes(sizeOnDisk)} + + ); + } + if (name === 'status') { return ( { - if (album.statistics) { - sizeOnDisk = sizeOnDisk + album.statistics.sizeOnDisk; - trackFileCount = trackFileCount + album.statistics.trackFileCount; + albums.forEach(({ monitored, releaseDate, statistics = {} }) => { + const { + trackFileCount: albumTrackFileCount = 0, + totalTrackCount: albumTotalTrackCount = 0, + sizeOnDisk: albumSizeOnDisk = 0 + } = statistics; - if (album.statistics.trackFileCount === album.statistics.totalTrackCount || (album.monitored && isBefore(album.airDateUtc))) { - albumCount++; - } + const hasFiles = albumTrackFileCount > 0 && albumTrackFileCount === albumTotalTrackCount; + + if (hasFiles || (monitored && isBefore(releaseDate))) { + albumCount++; } - if (album.monitored) { + if (hasFiles) { + albumFileCount++; + } + + if (monitored) { monitoredAlbumCount++; hasMonitoredAlbums = true; } totalAlbumCount++; + trackFileCount = trackFileCount + albumTrackFileCount; + sizeOnDisk = sizeOnDisk + albumSizeOnDisk; }); return { albumCount, + albumFileCount, totalAlbumCount, trackFileCount, monitoredAlbumCount, @@ -56,8 +67,8 @@ function getAlbumStatistics(albums) { }; } -function getAlbumCountKind(monitored, albumCount, monitoredAlbumCount) { - if (albumCount === monitoredAlbumCount && monitoredAlbumCount > 0) { +function getAlbumCountKind(monitored, albumCount, albumFileCount) { + if (albumCount === albumFileCount && albumFileCount > 0) { return kinds.SUCCESS; } @@ -192,11 +203,12 @@ class ArtistDetailsSeason extends Component { const { albumCount, + albumFileCount, totalAlbumCount, trackFileCount, monitoredAlbumCount, hasMonitoredAlbums, - sizeOnDisk + sizeOnDisk = 0 } = getAlbumStatistics(items); const { @@ -226,9 +238,9 @@ class ArtistDetailsSeason extends Component { anchor={ } title={translate('GroupInformation')} @@ -237,6 +249,7 @@ class ArtistDetailsSeason extends Component { diff --git a/frontend/src/Artist/Details/ArtistTagsConnector.js b/frontend/src/Artist/Details/ArtistTagsConnector.js index 33ced5f0d..1d24a5755 100644 --- a/frontend/src/Artist/Details/ArtistTagsConnector.js +++ b/frontend/src/Artist/Details/ArtistTagsConnector.js @@ -2,6 +2,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import createArtistSelector from 'Store/Selectors/createArtistSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import sortByProp from 'Utilities/Array/sortByProp'; import ArtistTags from './ArtistTags'; function createMapStateToProps() { @@ -12,8 +13,8 @@ function createMapStateToProps() { const tags = artist.tags .map((tagId) => tagList.find((tag) => tag.id === tagId)) .filter((tag) => !!tag) - .map((tag) => tag.label) - .sort((a, b) => a.localeCompare(b)); + .sort(sortByProp('label')) + .map((tag) => tag.label); return { tags diff --git a/frontend/src/Artist/Edit/EditArtistModalContent.js b/frontend/src/Artist/Edit/EditArtistModalContent.js index 82a390d84..bca6e3ea6 100644 --- a/frontend/src/Artist/Edit/EditArtistModalContent.js +++ b/frontend/src/Artist/Edit/EditArtistModalContent.js @@ -15,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import Popover from 'Components/Tooltip/Popover'; -import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; +import { icons, inputTypes, kinds, sizes, tooltipPositions } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './EditArtistModalContent.css'; @@ -93,7 +93,7 @@ class EditArtistModalContent extends Component {
- + {translate('Monitored')} @@ -107,9 +107,10 @@ class EditArtistModalContent extends Component { /> - + {translate('MonitorNewItems')} + - + {translate('QualityProfile')} @@ -146,10 +147,10 @@ class EditArtistModalContent extends Component { { - showMetadataProfile && - + showMetadataProfile ? + - Metadata Profile + {translate('MetadataProfile')} - + : + null } - + {translate('Path')} @@ -189,7 +191,7 @@ class EditArtistModalContent extends Component { /> - + {translate('Tags')} @@ -209,7 +211,7 @@ class EditArtistModalContent extends Component { kind={kinds.DANGER} onPress={onDeleteArtistPress} > - Delete + {translate('Delete')}
+ +
+
+
{translate('ArtistIndexFooterDownloading')}
+
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx index d59ea0187..f6a03c521 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx @@ -183,13 +183,15 @@ function ArtistIndexBanner(props: ArtistIndexBannerProps) {
{showTitle ? ( @@ -204,7 +206,7 @@ function ArtistIndexBanner(props: ArtistIndexBannerProps) {
) : null} - {showQualityProfile ? ( + {showQualityProfile && !!qualityProfile?.name ? (
{qualityProfile.name}
diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx index 50c0f3f9a..3582da097 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx @@ -1,8 +1,9 @@ import { throttle } from 'lodash'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window'; import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; import Artist from 'Artist/Artist'; import ArtistIndexBanner from 'Artist/Index/Banners/ArtistIndexBanner'; import useMeasure from 'Helpers/Hooks/useMeasure'; @@ -21,7 +22,7 @@ const columnPaddingSmallScreen = parseInt( const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); -const ADDITIONAL_COLUMN_COUNT = { +const ADDITIONAL_COLUMN_COUNT: Record = { small: 3, medium: 2, large: 1, @@ -41,17 +42,17 @@ interface CellItemData { interface ArtistIndexBannersProps { items: Artist[]; - sortKey?: string; + sortKey: string; sortDirection?: SortDirection; jumpToCharacter?: string; scrollTop?: number; - scrollerRef: React.MutableRefObject; + scrollerRef: RefObject; isSelectMode: boolean; isSmallScreen: boolean; } const artistIndexSelector = createSelector( - (state) => state.artistIndex.bannerOptions, + (state: AppState) => state.artistIndex.bannerOptions, (bannerOptions) => { return { bannerOptions, @@ -108,7 +109,7 @@ export default function ArtistIndexBanners(props: ArtistIndexBannersProps) { } = props; const { bannerOptions } = useSelector(artistIndexSelector); - const ref: React.MutableRefObject = useRef(); + const ref = useRef(null); const [measureRef, bounds] = useMeasure(); const [size, setSize] = useState({ width: 0, height: 0 }); @@ -222,8 +223,8 @@ export default function ArtistIndexBanners(props: ArtistIndexBannersProps) { }, [isSmallScreen, scrollerRef, bounds]); useEffect(() => { - const currentScrollListener = isSmallScreen ? window : scrollerRef.current; - const currentScrollerRef = scrollerRef.current; + const currentScrollerRef = scrollerRef.current as HTMLElement; + const currentScrollListener = isSmallScreen ? window : currentScrollerRef; const handleScroll = throttle(() => { const { offsetTop = 0 } = currentScrollerRef; @@ -232,7 +233,7 @@ export default function ArtistIndexBanners(props: ArtistIndexBannersProps) { ? getWindowScrollTopPosition() : currentScrollerRef.scrollTop) - offsetTop; - ref.current.scrollTo({ scrollLeft: 0, scrollTop }); + ref.current?.scrollTo({ scrollLeft: 0, scrollTop }); }, 10); currentScrollListener.addEventListener('scroll', handleScroll); @@ -255,8 +256,8 @@ export default function ArtistIndexBanners(props: ArtistIndexBannersProps) { const scrollTop = rowIndex * rowHeight + padding; - ref.current.scrollTo({ scrollLeft: 0, scrollTop }); - scrollerRef.current.scrollTo(0, scrollTop); + ref.current?.scrollTo({ scrollLeft: 0, scrollTop }); + scrollerRef.current?.scrollTo(0, scrollTop); } } }, [ diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx index f75311bca..f889ea450 100644 --- a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx +++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx @@ -59,7 +59,7 @@ function ArtistIndexBannerOptionsModalContent( const dispatch = useDispatch(); const onBannerOptionChange = useCallback( - ({ name, value }) => { + ({ name, value }: { name: string; value: unknown }) => { dispatch(setArtistBannerOption({ [name]: value })); }, [dispatch] diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.tsx b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.tsx index 19be069e5..91ebbef2d 100644 --- a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.tsx +++ b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.tsx @@ -1,10 +1,18 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import { CustomFilter } from 'App/State/AppState'; import ArtistIndexFilterModal from 'Artist/Index/ArtistIndexFilterModal'; import FilterMenu from 'Components/Menu/FilterMenu'; import { align } from 'Helpers/Props'; -function ArtistIndexFilterMenu(props) { +interface ArtistIndexFilterMenuProps { + selectedFilterKey: string | number; + filters: object[]; + customFilters: CustomFilter[]; + isDisabled: boolean; + onFilterSelect(filterName: string): unknown; +} + +function ArtistIndexFilterMenu(props: ArtistIndexFilterMenuProps) { const { selectedFilterKey, filters, @@ -26,15 +34,6 @@ function ArtistIndexFilterMenu(props) { ); } -ArtistIndexFilterMenu.propTypes = { - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) - .isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - isDisabled: PropTypes.bool.isRequired, - onFilterSelect: PropTypes.func.isRequired, -}; - ArtistIndexFilterMenu.defaultProps = { showCustomFilters: false, }; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.tsx b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.tsx index 3d4b8ded0..1b72d0f4c 100644 --- a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.tsx +++ b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.tsx @@ -1,11 +1,19 @@ -import PropTypes from 'prop-types'; import React from 'react'; import MenuContent from 'Components/Menu/MenuContent'; import SortMenu from 'Components/Menu/SortMenu'; import SortMenuItem from 'Components/Menu/SortMenuItem'; -import { align, sortDirections } from 'Helpers/Props'; +import { align } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; +import translate from 'Utilities/String/translate'; -function ArtistIndexSortMenu(props) { +interface SeriesIndexSortMenuProps { + sortKey?: string; + sortDirection?: SortDirection; + isDisabled: boolean; + onSortSelect(sortKey: string): unknown; +} + +function ArtistIndexSortMenu(props: SeriesIndexSortMenuProps) { const { sortKey, sortDirection, isDisabled, onSortSelect } = props; return ( @@ -17,7 +25,7 @@ function ArtistIndexSortMenu(props) { sortDirection={sortDirection} onPress={onSortSelect} > - Monitored/Status + {translate('MonitoredStatus')} - Name + {translate('Name')} - Type + {translate('Type')} - Quality Profile + {translate('QualityProfile')} - Metadata Profile + {translate('MetadataProfile')} - Next Album + {translate('NextAlbum')} - Last Album + {translate('LastAlbum')} - Added + {translate('Added')} - Albums + {translate('Albums')} - Tracks + {translate('Tracks')} - Track Count + {translate('TrackCount')} - Path + {translate('Path')} - Size on Disk + {translate('SizeOnDisk')} - Tags + {translate('Tags')} ); } -ArtistIndexSortMenu.propTypes = { - sortKey: PropTypes.string, - sortDirection: PropTypes.oneOf(sortDirections.all), - isDisabled: PropTypes.bool.isRequired, - onSortSelect: PropTypes.func.isRequired, -}; - export default ArtistIndexSortMenu; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.tsx b/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.tsx index f4be32db3..bb88d9149 100644 --- a/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.tsx +++ b/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import MenuContent from 'Components/Menu/MenuContent'; import ViewMenu from 'Components/Menu/ViewMenu'; @@ -6,7 +5,13 @@ import ViewMenuItem from 'Components/Menu/ViewMenuItem'; import { align } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; -function ArtistIndexViewMenu(props) { +interface ArtistIndexViewMenuProps { + view: string; + isDisabled: boolean; + onViewSelect(value: string): unknown; +} + +function ArtistIndexViewMenu(props: ArtistIndexViewMenuProps) { const { view, isDisabled, onViewSelect } = props; return ( @@ -36,10 +41,4 @@ function ArtistIndexViewMenu(props) { ); } -ArtistIndexViewMenu.propTypes = { - view: PropTypes.string.isRequired, - isDisabled: PropTypes.bool.isRequired, - onViewSelect: PropTypes.func.isRequired, -}; - export default ArtistIndexViewMenu; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx index b6a9da738..ebef28264 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx @@ -160,13 +160,15 @@ function ArtistIndexOverview(props: ArtistIndexOverviewProps) { diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx index 33c4ad2a9..c95d34b84 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx @@ -1,15 +1,51 @@ +import { IconDefinition } from '@fortawesome/free-regular-svg-icons'; import React, { useMemo } from 'react'; import { useSelector } from 'react-redux'; import Album from 'Album/Album'; import { icons } from 'Helpers/Props'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import dimensions from 'Styles/Variables/dimensions'; +import QualityProfile from 'typings/QualityProfile'; +import UiSettings from 'typings/Settings/UiSettings'; import formatDateTime from 'Utilities/Date/formatDateTime'; import getRelativeDate from 'Utilities/Date/getRelativeDate'; import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; import ArtistIndexOverviewInfoRow from './ArtistIndexOverviewInfoRow'; import styles from './ArtistIndexOverviewInfo.css'; +interface RowProps { + name: string; + showProp: string; + valueProp: string; +} + +interface RowInfoProps { + title: string; + iconName: IconDefinition; + label: string; +} + +interface ArtistIndexOverviewInfoProps { + height: number; + showMonitored: boolean; + showQualityProfile: boolean; + showLastAlbum: boolean; + showAdded: boolean; + showAlbumCount: boolean; + showPath: boolean; + showSizeOnDisk: boolean; + monitored: boolean; + nextAlbum?: Album; + qualityProfile?: QualityProfile; + lastAlbum?: Album; + added?: string; + albumCount: number; + path: string; + sizeOnDisk?: number; + sortKey: string; +} + const infoRowHeight = parseInt(dimensions.artistIndexOverviewInfoRowHeight); const rows = [ @@ -50,11 +86,17 @@ const rows = [ }, ]; -function getInfoRowProps(row, props, uiSettings) { +function getInfoRowProps( + row: RowProps, + props: ArtistIndexOverviewInfoProps, + uiSettings: UiSettings +): RowInfoProps | null { const { name } = row; if (name === 'monitored') { - const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored'; + const monitoredText = props.monitored + ? translate('Monitored') + : translate('Unmonitored'); return { title: monitoredText, @@ -63,9 +105,9 @@ function getInfoRowProps(row, props, uiSettings) { }; } - if (name === 'qualityProfileId') { + if (name === 'qualityProfileId' && !!props.qualityProfile?.name) { return { - title: 'Quality Profile', + title: translate('QualityProfile'), iconName: icons.PROFILE, label: props.qualityProfile.name, }; @@ -78,15 +120,16 @@ function getInfoRowProps(row, props, uiSettings) { return { title: `Last Album: ${lastAlbum.title}`, iconName: icons.CALENDAR, - label: getRelativeDate( - lastAlbum.releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true, - } - ), + label: + getRelativeDate( + lastAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + ) ?? '', }; } @@ -98,10 +141,11 @@ function getInfoRowProps(row, props, uiSettings) { return { title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, iconName: icons.ADD, - label: getRelativeDate(added, shortDateFormat, showRelativeDates, { - timeFormat, - timeForToday: true, - }), + label: + getRelativeDate(added, shortDateFormat, showRelativeDates, { + timeFormat, + timeForToday: true, + }) ?? '', }; } @@ -116,7 +160,7 @@ function getInfoRowProps(row, props, uiSettings) { } return { - title: 'Album Count', + title: translate('AlbumCount'), iconName: icons.CIRCLE, label: albums, }; @@ -124,7 +168,7 @@ function getInfoRowProps(row, props, uiSettings) { if (name === 'path') { return { - title: 'Path', + title: translate('Path'), iconName: icons.FOLDER, label: props.path, }; @@ -132,31 +176,13 @@ function getInfoRowProps(row, props, uiSettings) { if (name === 'sizeOnDisk') { return { - title: 'Size on Disk', + title: translate('SizeOnDisk'), iconName: icons.DRIVE, label: formatBytes(props.sizeOnDisk), }; } -} -interface ArtistIndexOverviewInfoProps { - height: number; - showMonitored: boolean; - showQualityProfile: boolean; - showLastAlbum: boolean; - showAdded: boolean; - showAlbumCount: boolean; - showPath: boolean; - showSizeOnDisk: boolean; - monitored: boolean; - nextAlbum?: Album; - qualityProfile: object; - lastAlbum?: Album; - added?: string; - albumCount: number; - path: string; - sizeOnDisk?: number; - sortKey: string; + return null; } function ArtistIndexOverviewInfo(props: ArtistIndexOverviewInfoProps) { @@ -175,6 +201,8 @@ function ArtistIndexOverviewInfo(props: ArtistIndexOverviewInfoProps) { const { name, showProp, valueProp } = row; const isVisible = + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore ts(7053) props[valueProp] != null && (props[showProp] || props.sortKey === name); return { @@ -219,6 +247,10 @@ function ArtistIndexOverviewInfo(props: ArtistIndexOverviewInfoProps) { const infoRowProps = getInfoRowProps(row, props, uiSettings); + if (infoRowProps == null) { + return null; + } + return ; })} diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx index 931d7053c..5d9b4a069 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx @@ -1,11 +1,12 @@ +import { IconDefinition } from '@fortawesome/free-regular-svg-icons'; import React from 'react'; import Icon from 'Components/Icon'; import styles from './ArtistIndexOverviewInfoRow.css'; interface ArtistIndexOverviewInfoRowProps { title?: string; - iconName: object; - label: string; + iconName?: IconDefinition; + label: string | null; } function ArtistIndexOverviewInfoRow(props: ArtistIndexOverviewInfoRowProps) { diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx index 3f1197ef4..11285c1b3 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx @@ -1,5 +1,5 @@ import { throttle } from 'lodash'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; import Artist from 'Artist/Artist'; @@ -33,11 +33,11 @@ interface RowItemData { interface ArtistIndexOverviewsProps { items: Artist[]; - sortKey?: string; + sortKey: string; sortDirection?: string; jumpToCharacter?: string; scrollTop?: number; - scrollerRef: React.MutableRefObject; + scrollerRef: RefObject; isSelectMode: boolean; isSmallScreen: boolean; } @@ -79,7 +79,7 @@ function ArtistIndexOverviews(props: ArtistIndexOverviewsProps) { const { size: posterSize, detailedProgressBar } = useSelector( selectOverviewOptions ); - const listRef: React.MutableRefObject = useRef(); + const listRef = useRef(null); const [measureRef, bounds] = useMeasure(); const [size, setSize] = useState({ width: 0, height: 0 }); @@ -136,8 +136,8 @@ function ArtistIndexOverviews(props: ArtistIndexOverviewsProps) { }, [isSmallScreen, scrollerRef, bounds]); useEffect(() => { - const currentScrollListener = isSmallScreen ? window : scrollerRef.current; - const currentScrollerRef = scrollerRef.current; + const currentScrollerRef = scrollerRef.current as HTMLElement; + const currentScrollListener = isSmallScreen ? window : currentScrollerRef; const handleScroll = throttle(() => { const { offsetTop = 0 } = currentScrollerRef; @@ -146,7 +146,7 @@ function ArtistIndexOverviews(props: ArtistIndexOverviewsProps) { ? getWindowScrollTopPosition() : currentScrollerRef.scrollTop) - offsetTop; - listRef.current.scrollTo(scrollTop); + listRef.current?.scrollTo(scrollTop); }, 10); currentScrollListener.addEventListener('scroll', handleScroll); @@ -175,8 +175,8 @@ function ArtistIndexOverviews(props: ArtistIndexOverviewsProps) { scrollTop += offset; } - listRef.current.scrollTo(scrollTop); - scrollerRef.current.scrollTo(0, scrollTop); + listRef.current?.scrollTo(scrollTop); + scrollerRef.current?.scrollTo(0, scrollTop); } } }, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]); diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx index e19692e41..4ab9391e3 100644 --- a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx +++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx @@ -60,7 +60,7 @@ function ArtistIndexOverviewOptionsModalContent( const dispatch = useDispatch(); const onOverviewOptionChange = useCallback( - ({ name, value }) => { + ({ name, value }: { name: string; value: unknown }) => { dispatch(setArtistOverviewOption({ [name]: value })); }, [dispatch] diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx index b7733d17f..67c37c00d 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx @@ -183,13 +183,15 @@ function ArtistIndexPoster(props: ArtistIndexPosterProps) { {showTitle ? ( @@ -204,7 +206,7 @@ function ArtistIndexPoster(props: ArtistIndexPosterProps) { ) : null} - {showQualityProfile ? ( + {showQualityProfile && !!qualityProfile?.name ? (
{qualityProfile.name}
diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx index cd067053d..c478ac1ae 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx @@ -1,8 +1,9 @@ import { throttle } from 'lodash'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window'; import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; import Artist from 'Artist/Artist'; import ArtistIndexPoster from 'Artist/Index/Posters/ArtistIndexPoster'; import useMeasure from 'Helpers/Hooks/useMeasure'; @@ -21,7 +22,7 @@ const columnPaddingSmallScreen = parseInt( const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); -const ADDITIONAL_COLUMN_COUNT = { +const ADDITIONAL_COLUMN_COUNT: Record = { small: 3, medium: 2, large: 1, @@ -41,17 +42,17 @@ interface CellItemData { interface ArtistIndexPostersProps { items: Artist[]; - sortKey?: string; + sortKey: string; sortDirection?: SortDirection; jumpToCharacter?: string; scrollTop?: number; - scrollerRef: React.MutableRefObject; + scrollerRef: RefObject; isSelectMode: boolean; isSmallScreen: boolean; } const artistIndexSelector = createSelector( - (state) => state.artistIndex.posterOptions, + (state: AppState) => state.artistIndex.posterOptions, (posterOptions) => { return { posterOptions, @@ -108,7 +109,7 @@ export default function ArtistIndexPosters(props: ArtistIndexPostersProps) { } = props; const { posterOptions } = useSelector(artistIndexSelector); - const ref: React.MutableRefObject = useRef(); + const ref = useRef(null); const [measureRef, bounds] = useMeasure(); const [size, setSize] = useState({ width: 0, height: 0 }); @@ -201,11 +202,15 @@ export default function ArtistIndexPosters(props: ArtistIndexPostersProps) { if (isSmallScreen) { const padding = bodyPaddingSmallScreen - 5; + const width = window.innerWidth - padding * 2; + const height = window.innerHeight; - setSize({ - width: window.innerWidth - padding * 2, - height: window.innerHeight, - }); + if (width !== size.width || height !== size.height) { + setSize({ + width, + height, + }); + } return; } @@ -227,8 +232,8 @@ export default function ArtistIndexPosters(props: ArtistIndexPostersProps) { }, [isSmallScreen, size, scrollerRef, bounds]); useEffect(() => { - const currentScrollListener = isSmallScreen ? window : scrollerRef.current; - const currentScrollerRef = scrollerRef.current; + const currentScrollerRef = scrollerRef.current as HTMLElement; + const currentScrollListener = isSmallScreen ? window : currentScrollerRef; const handleScroll = throttle(() => { const { offsetTop = 0 } = currentScrollerRef; @@ -237,7 +242,7 @@ export default function ArtistIndexPosters(props: ArtistIndexPostersProps) { ? getWindowScrollTopPosition() : currentScrollerRef.scrollTop) - offsetTop; - ref.current.scrollTo({ scrollLeft: 0, scrollTop }); + ref.current?.scrollTo({ scrollLeft: 0, scrollTop }); }, 10); currentScrollListener.addEventListener('scroll', handleScroll); @@ -260,8 +265,8 @@ export default function ArtistIndexPosters(props: ArtistIndexPostersProps) { const scrollTop = rowIndex * rowHeight + padding; - ref.current.scrollTo({ scrollLeft: 0, scrollTop }); - scrollerRef.current.scrollTo(0, scrollTop); + ref.current?.scrollTo({ scrollLeft: 0, scrollTop }); + scrollerRef.current?.scrollTo(0, scrollTop); } } }, [ diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx index e1e60801c..2560d855a 100644 --- a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx +++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx @@ -59,7 +59,7 @@ function ArtistIndexPosterOptionsModalContent( const dispatch = useDispatch(); const onPosterOptionChange = useCallback( - ({ name, value }) => { + ({ name, value }: { name: string; value: unknown }) => { dispatch(setArtistPosterOption({ [name]: value })); }, [dispatch] diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.tsx b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.tsx index f76fa6588..2a8167b99 100644 --- a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.tsx +++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.tsx @@ -1,4 +1,8 @@ import React from 'react'; +import { useSelector } from 'react-redux'; +import createArtistQueueItemsDetailsSelector, { + ArtistQueueDetails, +} from 'Artist/Index/createArtistQueueDetailsSelector'; import ProgressBar from 'Components/ProgressBar'; import { sizes } from 'Helpers/Props'; import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; @@ -6,35 +10,51 @@ import translate from 'Utilities/String/translate'; import styles from './ArtistIndexProgressBar.css'; interface ArtistIndexProgressBarProps { + artistId: number; monitored: boolean; status: string; trackCount: number; trackFileCount: number; totalTrackCount: number; - posterWidth: number; + width: number; detailedProgressBar: boolean; + isStandalone: boolean; } function ArtistIndexProgressBar(props: ArtistIndexProgressBarProps) { const { + artistId, monitored, status, trackCount, trackFileCount, totalTrackCount, - posterWidth, + width, detailedProgressBar, + isStandalone, } = props; + const queueDetails: ArtistQueueDetails = useSelector( + createArtistQueueItemsDetailsSelector(artistId) + ); + + const newDownloads = queueDetails.count - queueDetails.tracksWithFiles; const progress = trackCount ? (trackFileCount / trackCount) * 100 : 100; - const text = `${trackFileCount} / ${trackCount}`; + const text = newDownloads + ? `${trackFileCount} + ${newDownloads} / ${trackCount}` + : `${trackFileCount} / ${trackCount}`; return ( 0 + )} size={detailedProgressBar ? sizes.MEDIUM : sizes.SMALL} showText={detailedProgressBar} text={text} @@ -42,8 +62,9 @@ function ArtistIndexProgressBar(props: ArtistIndexProgressBarProps) { trackFileCount, trackCount, totalTrackCount, + downloadingCount: queueDetails.count, })} - width={posterWidth} + width={width} /> ); } diff --git a/frontend/src/Artist/Index/Select/AlbumStudio/AlbumDetails.tsx b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumDetails.tsx index bd6a01e10..255bd9ba4 100644 --- a/frontend/src/Artist/Index/Select/AlbumStudio/AlbumDetails.tsx +++ b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumDetails.tsx @@ -1,6 +1,7 @@ import _ from 'lodash'; import React, { useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { Statistics } from 'Album/Album'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import { kinds } from 'Helpers/Props'; @@ -56,8 +57,8 @@ function AlbumDetails(props: AlbumDetailsProps) { disambiguation, albumType, monitored, - statistics, - isSaving, + statistics = {} as Statistics, + isSaving = false, } = album; return ( diff --git a/frontend/src/Artist/Index/Select/AlbumStudio/AlbumStudioAlbum.tsx b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumStudioAlbum.tsx index ec4ef9074..3e7e0578f 100644 --- a/frontend/src/Artist/Index/Select/AlbumStudio/AlbumStudioAlbum.tsx +++ b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumStudioAlbum.tsx @@ -11,7 +11,7 @@ interface AlbumStudioAlbumProps { artistId: number; albumId: number; title: string; - disambiguation: string; + disambiguation?: string; albumType: string; monitored: boolean; statistics: Statistics; diff --git a/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModalContent.tsx b/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModalContent.tsx index c21c9a8af..b3c2abbbe 100644 --- a/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModalContent.tsx +++ b/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModalContent.tsx @@ -33,7 +33,7 @@ function ChangeMonitoringModalContent( const [monitor, setMonitor] = useState(NO_CHANGE); const onInputChange = useCallback( - ({ value }) => { + ({ value }: { value: string }) => { setMonitor(value); }, [setMonitor] diff --git a/frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.tsx b/frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.tsx index cc072f41a..86b41e8ba 100644 --- a/frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.tsx +++ b/frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { SyntheticEvent, useCallback } from 'react'; import { useSelect } from 'App/SelectContext'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; @@ -15,8 +15,9 @@ function ArtistIndexPosterSelect(props: ArtistIndexPosterSelectProps) { const isSelected = selectState.selectedState[artistId]; const onSelectPress = useCallback( - (event) => { - const shiftKey = event.nativeEvent.shiftKey; + (event: SyntheticEvent) => { + const nativeEvent = event.nativeEvent as PointerEvent; + const shiftKey = nativeEvent.shiftKey; selectDispatch({ type: 'toggleSelected', diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectAllButton.tsx b/frontend/src/Artist/Index/Select/ArtistIndexSelectAllButton.tsx index 7229dbdc0..2b3e9c01c 100644 --- a/frontend/src/Artist/Index/Select/ArtistIndexSelectAllButton.tsx +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectAllButton.tsx @@ -6,7 +6,7 @@ import { icons } from 'Helpers/Props'; interface ArtistIndexSelectAllButtonProps { label: string; isSelectMode: boolean; - overflowComponent: React.FunctionComponent; + overflowComponent: React.FunctionComponent; } function ArtistIndexSelectAllButton(props: ArtistIndexSelectAllButtonProps) { diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.tsx b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.tsx index ffa017a78..f0569d607 100644 --- a/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.tsx +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.tsx @@ -24,6 +24,14 @@ import OrganizeArtistModal from './Organize/OrganizeArtistModal'; import TagsModal from './Tags/TagsModal'; import styles from './ArtistIndexSelectFooter.css'; +interface SavePayload { + monitored?: boolean; + qualityProfileId?: number; + metadataProfileId?: number; + rootFolderPath?: string; + moveFiles?: boolean; +} + const artistEditorSelector = createSelector( (state: AppState) => state.artist, (artist) => { @@ -79,7 +87,7 @@ function ArtistIndexSelectFooter() { }, [setIsEditModalOpen]); const onSavePress = useCallback( - (payload) => { + (payload: SavePayload) => { setIsSavingArtist(true); setIsEditModalOpen(false); @@ -118,7 +126,7 @@ function ArtistIndexSelectFooter() { }, [setIsTagsModalOpen]); const onApplyTagsPress = useCallback( - (tags, applyTags) => { + (tags: number[], applyTags: string) => { setIsSavingTags(true); setIsTagsModalOpen(false); diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectModeButton.tsx b/frontend/src/Artist/Index/Select/ArtistIndexSelectModeButton.tsx index 45fe78536..8679bba99 100644 --- a/frontend/src/Artist/Index/Select/ArtistIndexSelectModeButton.tsx +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectModeButton.tsx @@ -7,7 +7,7 @@ interface ArtistIndexSelectModeButtonProps { label: string; iconName: IconDefinition; isSelectMode: boolean; - overflowComponent: React.FunctionComponent; + overflowComponent: React.FunctionComponent; onPress: () => void; } diff --git a/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.tsx b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.tsx index 5e7d1f1ff..b67ee60aa 100644 --- a/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.tsx +++ b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.tsx @@ -28,9 +28,15 @@ function RetagArtistModalContent(props: RetagArtistModalContentProps) { const dispatch = useDispatch(); const artistNames = useMemo(() => { - const artists = artistIds.map((id) => { - return allArtists.find((a) => a.id === id); - }); + const artists = artistIds.reduce((acc: Artist[], id) => { + const a = allArtists.find((a) => a.id === id); + + if (a) { + acc.push(a); + } + + return acc; + }, []); const sorted = orderBy(artists, ['sortName']); diff --git a/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.tsx b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.tsx index c367a4550..4accc9f0e 100644 --- a/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.tsx +++ b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.tsx @@ -15,6 +15,7 @@ import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds } from 'Helpers/Props'; import { bulkDeleteArtist, setDeleteOption } from 'Store/Actions/artistActions'; import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import { CheckInputChanged } from 'typings/inputs'; import translate from 'Utilities/String/translate'; import styles from './DeleteArtistModalContent.css'; @@ -37,16 +38,16 @@ function DeleteArtistModalContent(props: DeleteArtistModalContentProps) { const [deleteFiles, setDeleteFiles] = useState(false); - const artists = useMemo(() => { - const artists = artistIds.map((id) => { + const artists = useMemo((): Artist[] => { + const artistList = artistIds.map((id) => { return allArtists.find((a) => a.id === id); - }); + }) as Artist[]; - return orderBy(artists, ['sortName']); + return orderBy(artistList, ['sortName']); }, [artistIds, allArtists]); const onDeleteFilesChange = useCallback( - ({ value }) => { + ({ value }: CheckInputChanged) => { setDeleteFiles(value); }, [setDeleteFiles] diff --git a/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.tsx b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.tsx index 94d1e87d2..993be8ce5 100644 --- a/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.tsx +++ b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.tsx @@ -35,7 +35,7 @@ const monitoredOptions = [ get value() { return translate('NoChange'); }, - disabled: true, + isDisabled: true, }, { key: 'monitored', @@ -66,7 +66,7 @@ function EditArtistModalContent(props: EditArtistModalContentProps) { const [isConfirmMoveModalOpen, setIsConfirmMoveModalOpen] = useState(false); const save = useCallback( - (moveFiles) => { + (moveFiles: boolean) => { let hasChanges = false; const payload: SavePayload = {}; @@ -114,7 +114,7 @@ function EditArtistModalContent(props: EditArtistModalContentProps) { ); const onInputChange = useCallback( - ({ name, value }) => { + ({ name, value }: { name: string; value: string }) => { switch (name) { case 'monitored': setMonitored(value); diff --git a/frontend/src/Artist/Index/Select/Tags/TagsModalContent.tsx b/frontend/src/Artist/Index/Select/Tags/TagsModalContent.tsx index c41c0c896..95a7eaae2 100644 --- a/frontend/src/Artist/Index/Select/Tags/TagsModalContent.tsx +++ b/frontend/src/Artist/Index/Select/Tags/TagsModalContent.tsx @@ -1,6 +1,7 @@ import { uniq } from 'lodash'; import React, { useCallback, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; +import { Tag } from 'App/State/TagsAppState'; import Artist from 'Artist/Artist'; import Form from 'Components/Form/Form'; import FormGroup from 'Components/Form/FormGroup'; @@ -28,7 +29,7 @@ function TagsModalContent(props: TagsModalContentProps) { const { artistIds, onModalClose, onApplyTagsPress } = props; const allArtists: Artist[] = useSelector(createAllArtistSelector()); - const tagList = useSelector(createTagsSelector()); + const tagList: Tag[] = useSelector(createTagsSelector()); const [tags, setTags] = useState([]); const [applyTags, setApplyTags] = useState('add'); @@ -48,14 +49,14 @@ function TagsModalContent(props: TagsModalContentProps) { }, [artistIds, allArtists]); const onTagsChange = useCallback( - ({ value }) => { + ({ value }: { value: number[] }) => { setTags(value); }, [setTags] ); const onApplyTagsChange = useCallback( - ({ value }) => { + ({ value }: { value: string }) => { setApplyTags(value); }, [setApplyTags] diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.css b/frontend/src/Artist/Index/Table/ArtistIndexRow.css index b75ad6afd..35d03c263 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexRow.css +++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.css @@ -67,6 +67,7 @@ flex: 1 0 125px; } +.monitorNewItems, .nextAlbum, .lastAlbum, .added, diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.css.d.ts b/frontend/src/Artist/Index/Table/ArtistIndexRow.css.d.ts index fd8d84e17..4855aec75 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexRow.css.d.ts +++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.css.d.ts @@ -14,6 +14,7 @@ interface CssExports { 'lastAlbum': string; 'link': string; 'metadataProfileId': string; + 'monitorNewItems': string; 'nextAlbum': string; 'overlayTitle': string; 'path': string; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.tsx b/frontend/src/Artist/Index/Table/ArtistIndexRow.tsx index da277a418..0398f5502 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexRow.tsx +++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.tsx @@ -9,13 +9,13 @@ import ArtistNameLink from 'Artist/ArtistNameLink'; import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector'; +import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; import ArtistStatusCell from 'Artist/Index/Table/ArtistStatusCell'; import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; import HeartRating from 'Components/HeartRating'; import IconButton from 'Components/Link/IconButton'; import Link from 'Components/Link/Link'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import ProgressBar from 'Components/ProgressBar'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; @@ -23,8 +23,9 @@ import Column from 'Components/Table/Column'; import TagListConnector from 'Components/TagListConnector'; import { icons } from 'Helpers/Props'; import { executeCommand } from 'Store/Actions/commandActions'; -import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; +import { SelectStateInputProps } from 'typings/props'; import formatBytes from 'Utilities/Number/formatBytes'; +import firstCharToUpper from 'Utilities/String/firstCharToUpper'; import translate from 'Utilities/String/translate'; import AlbumsCell from './AlbumsCell'; import hasGrowableColumns from './hasGrowableColumns'; @@ -57,6 +58,7 @@ function ArtistIndexRow(props: ArtistIndexRowProps) { monitored, status, path, + monitorNewItems, nextAlbum, lastAlbum, added, @@ -127,7 +129,7 @@ function ArtistIndexRow(props: ArtistIndexRowProps) { }, [setIsDeleteArtistModalOpen]); const onSelectedChange = useCallback( - ({ id, value, shiftKey }) => { + ({ id, value, shiftKey }: SelectStateInputProps) => { selectDispatch({ type: 'toggleSelected', id, @@ -218,15 +220,7 @@ function ArtistIndexRow(props: ArtistIndexRowProps) { if (name === 'qualityProfileId') { return ( - {qualityProfile.name} - - ); - } - - if (name === 'qualityProfileId') { - return ( - - {qualityProfile.name} + {qualityProfile?.name ?? ''} ); } @@ -234,7 +228,15 @@ function ArtistIndexRow(props: ArtistIndexRowProps) { if (name === 'metadataProfileId') { return ( - {metadataProfile.name} + {metadataProfile?.name ?? ''} + + ); + } + + if (name === 'monitorNewItems') { + return ( + + {translate(firstCharToUpper(monitorNewItems))} ); } @@ -253,7 +255,7 @@ function ArtistIndexRow(props: ArtistIndexRowProps) { } return ( - None + {translate('None')} ); } @@ -272,13 +274,15 @@ function ArtistIndexRow(props: ArtistIndexRowProps) { } return ( - None + {translate('None')} ); } if (name === 'added') { return ( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore ts(2739) - ); @@ -334,7 +333,7 @@ function ArtistIndexRow(props: ArtistIndexRowProps) { if (name === 'path') { return ( - {path} + {path} ); } diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.tsx b/frontend/src/Artist/Index/Table/ArtistIndexTable.tsx index 08f5a9680..c3c8044ce 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTable.tsx +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.tsx @@ -1,8 +1,9 @@ import { throttle } from 'lodash'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; import Artist from 'Artist/Artist'; import ArtistIndexRow from 'Artist/Index/Table/ArtistIndexRow'; import ArtistIndexTableHeader from 'Artist/Index/Table/ArtistIndexTableHeader'; @@ -30,17 +31,17 @@ interface RowItemData { interface ArtistIndexTableProps { items: Artist[]; - sortKey?: string; + sortKey: string; sortDirection?: SortDirection; jumpToCharacter?: string; scrollTop?: number; - scrollerRef: React.MutableRefObject; + scrollerRef: RefObject; isSelectMode: boolean; isSmallScreen: boolean; } const columnsSelector = createSelector( - (state) => state.artistIndex.columns, + (state: AppState) => state.artistIndex.columns, (columns) => columns ); @@ -93,7 +94,7 @@ function ArtistIndexTable(props: ArtistIndexTableProps) { const columns = useSelector(columnsSelector); const { showBanners } = useSelector(selectTableOptions); - const listRef: React.MutableRefObject = useRef(); + const listRef = useRef>(null); const [measureRef, bounds] = useMeasure(); const [size, setSize] = useState({ width: 0, height: 0 }); const windowWidth = window.innerWidth; @@ -104,7 +105,7 @@ function ArtistIndexTable(props: ArtistIndexTableProps) { }, [showBanners]); useEffect(() => { - const current = scrollerRef.current as HTMLElement; + const current = scrollerRef?.current as HTMLElement; if (isSmallScreen) { setSize({ @@ -128,8 +129,8 @@ function ArtistIndexTable(props: ArtistIndexTableProps) { }, [isSmallScreen, windowWidth, windowHeight, scrollerRef, bounds]); useEffect(() => { - const currentScrollListener = isSmallScreen ? window : scrollerRef.current; - const currentScrollerRef = scrollerRef.current; + const currentScrollerRef = scrollerRef.current as HTMLElement; + const currentScrollListener = isSmallScreen ? window : currentScrollerRef; const handleScroll = throttle(() => { const { offsetTop = 0 } = currentScrollerRef; @@ -138,7 +139,7 @@ function ArtistIndexTable(props: ArtistIndexTableProps) { ? getWindowScrollTopPosition() : currentScrollerRef.scrollTop) - offsetTop; - listRef.current.scrollTo(scrollTop); + listRef.current?.scrollTo(scrollTop); }, 10); currentScrollListener.addEventListener('scroll', handleScroll); @@ -167,8 +168,8 @@ function ArtistIndexTable(props: ArtistIndexTableProps) { scrollTop += offset; } - listRef.current.scrollTo(scrollTop); - scrollerRef.current.scrollTo(0, scrollTop); + listRef.current?.scrollTo(scrollTop); + scrollerRef?.current?.scrollTo(0, scrollTop); } } }, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]); diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css index 6da0be920..7ea4e94aa 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css @@ -31,6 +31,7 @@ flex: 1 0 125px; } +.monitorNewItems, .nextAlbum, .lastAlbum, .added, diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css.d.ts b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css.d.ts index 4d9dcd20b..467b401bb 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css.d.ts +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css.d.ts @@ -11,6 +11,7 @@ interface CssExports { 'lastAlbum': string; 'latestAlbum': string; 'metadataProfileId': string; + 'monitorNewItems': string; 'nextAlbum': string; 'path': string; 'qualityProfileId': string; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.tsx b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.tsx index 2e903574d..1b325c225 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.tsx +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.tsx @@ -15,6 +15,7 @@ import { setArtistSort, setArtistTableOption, } from 'Store/Actions/artistIndexActions'; +import { CheckInputChanged } from 'typings/inputs'; import hasGrowableColumns from './hasGrowableColumns'; import styles from './ArtistIndexTableHeader.css'; @@ -32,21 +33,21 @@ function ArtistIndexTableHeader(props: ArtistIndexTableHeaderProps) { const [selectState, selectDispatch] = useSelect(); const onSortPress = useCallback( - (value) => { + (value: string) => { dispatch(setArtistSort({ sortKey: value })); }, [dispatch] ); const onTableOptionChange = useCallback( - (payload) => { + (payload: unknown) => { dispatch(setArtistTableOption(payload)); }, [dispatch] ); const onSelectAllChange = useCallback( - ({ value }) => { + ({ value }: CheckInputChanged) => { selectDispatch({ type: value ? 'selectAll' : 'unselectAll', }); @@ -94,6 +95,8 @@ function ArtistIndexTableHeader(props: ArtistIndexTableHeaderProps) { { + ({ name, value }: CheckInputChanged) => { onTableOptionChange({ tableOptions: { ...tableOptions, diff --git a/frontend/src/Artist/Index/createArtistIndexItemSelector.ts b/frontend/src/Artist/Index/createArtistIndexItemSelector.ts index 86ee8a560..4388a3aeb 100644 --- a/frontend/src/Artist/Index/createArtistIndexItemSelector.ts +++ b/frontend/src/Artist/Index/createArtistIndexItemSelector.ts @@ -1,5 +1,6 @@ import { createSelector } from 'reselect'; import Artist from 'Artist/Artist'; +import Command from 'Commands/Command'; import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; import createArtistMetadataProfileSelector from 'Store/Selectors/createArtistMetadataProfileSelector'; import createArtistQualityProfileSelector from 'Store/Selectors/createArtistQualityProfileSelector'; @@ -12,25 +13,21 @@ function createArtistIndexItemSelector(artistId: number) { createArtistQualityProfileSelector(artistId), createArtistMetadataProfileSelector(artistId), createExecutingCommandsSelector(), - (artist: Artist, qualityProfile, metadataProfile, executingCommands) => { - // If an artist is deleted this selector may fire before the parent - // selectors, which will result in an undefined artist, if that happens - // we want to return early here and again in the render function to avoid - // trying to show an artist that has no information available. - - if (!artist) { - return {}; - } - + ( + artist: Artist, + qualityProfile, + metadataProfile, + executingCommands: Command[] + ) => { const isRefreshingArtist = executingCommands.some((command) => { return ( - command.name === REFRESH_ARTIST && command.body.artistId === artist.id + command.name === REFRESH_ARTIST && command.body.artistId === artistId ); }); const isSearchingArtist = executingCommands.some((command) => { return ( - command.name === ARTIST_SEARCH && command.body.artistId === artist.id + command.name === ARTIST_SEARCH && command.body.artistId === artistId ); }); diff --git a/frontend/src/Artist/Index/createArtistQueueDetailsSelector.ts b/frontend/src/Artist/Index/createArtistQueueDetailsSelector.ts new file mode 100644 index 000000000..34a9c3910 --- /dev/null +++ b/frontend/src/Artist/Index/createArtistQueueDetailsSelector.ts @@ -0,0 +1,39 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +export interface ArtistQueueDetails { + count: number; + tracksWithFiles: number; +} + +function createArtistQueueDetailsSelector(artistId: number) { + return createSelector( + (state: AppState) => state.queue.details.items, + (queueItems) => { + return queueItems.reduce( + (acc: ArtistQueueDetails, item) => { + if ( + item.trackedDownloadState === 'imported' || + item.artistId !== artistId + ) { + return acc; + } + + acc.count += item.trackFileCount ?? 0; + + if (item.trackHasFileCount) { + acc.tracksWithFiles += item.trackHasFileCount; + } + + return acc; + }, + { + count: 0, + tracksWithFiles: 0, + } + ); + } + ); +} + +export default createArtistQueueDetailsSelector; diff --git a/frontend/src/Artist/Search/ArtistInteractiveSearchModal.js b/frontend/src/Artist/Search/ArtistInteractiveSearchModal.js index d7feee98d..9ce7e8f9a 100644 --- a/frontend/src/Artist/Search/ArtistInteractiveSearchModal.js +++ b/frontend/src/Artist/Search/ArtistInteractiveSearchModal.js @@ -14,7 +14,7 @@ function ArtistInteractiveSearchModal(props) { return ( diff --git a/frontend/src/Calendar/CalendarFilterModal.tsx b/frontend/src/Calendar/CalendarFilterModal.tsx new file mode 100644 index 000000000..e26b2928b --- /dev/null +++ b/frontend/src/Calendar/CalendarFilterModal.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setCalendarFilter } from 'Store/Actions/calendarActions'; + +function createCalendarSelector() { + return createSelector( + (state: AppState) => state.calendar.items, + (calendar) => { + return calendar; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.calendar.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface CalendarFilterModalProps { + isOpen: boolean; +} + +export default function CalendarFilterModal(props: CalendarFilterModalProps) { + const sectionItems = useSelector(createCalendarSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'calendar'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setCalendarFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js index 3a4603e82..bf7f46c10 100644 --- a/frontend/src/Calendar/CalendarPage.js +++ b/frontend/src/Calendar/CalendarPage.js @@ -14,6 +14,7 @@ import { align, icons } from 'Helpers/Props'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; import translate from 'Utilities/String/translate'; import CalendarConnector from './CalendarConnector'; +import CalendarFilterModal from './CalendarFilterModal'; import CalendarLinkModal from './iCal/CalendarLinkModal'; import LegendConnector from './Legend/LegendConnector'; import CalendarOptionsModal from './Options/CalendarOptionsModal'; @@ -78,6 +79,7 @@ class CalendarPage extends Component { const { selectedFilterKey, filters, + customFilters, hasArtist, artistError, artistIsFetching, @@ -137,7 +139,8 @@ class CalendarPage extends Component { isDisabled={!hasArtist} selectedFilterKey={selectedFilterKey} filters={filters} - customFilters={[]} + customFilters={customFilters} + filterModalConnectorComponent={CalendarFilterModal} onFilterSelect={onFilterSelect} /> @@ -204,6 +207,7 @@ class CalendarPage extends Component { CalendarPage.propTypes = { selectedFilterKey: PropTypes.string.isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, hasArtist: PropTypes.bool.isRequired, artistError: PropTypes.object, artistIsFetching: PropTypes.bool.isRequired, diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js index d0e7e87af..4221c0339 100644 --- a/frontend/src/Calendar/CalendarPageConnector.js +++ b/frontend/src/Calendar/CalendarPageConnector.js @@ -6,6 +6,7 @@ import withCurrentPage from 'Components/withCurrentPage'; import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; import { executeCommand } from 'Store/Actions/commandActions'; import createArtistCountSelector from 'Store/Selectors/createArtistCountSelector'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; @@ -59,6 +60,7 @@ function createMapStateToProps() { return createSelector( (state) => state.calendar.selectedFilterKey, (state) => state.calendar.filters, + createCustomFiltersSelector('calendar'), createArtistCountSelector(), createUISettingsSelector(), createMissingAlbumIdsSelector(), @@ -67,6 +69,7 @@ function createMapStateToProps() { ( selectedFilterKey, filters, + customFilters, artistCount, uiSettings, missingAlbumIds, @@ -76,6 +79,7 @@ function createMapStateToProps() { return { selectedFilterKey, filters, + customFilters, colorImpairedMode: uiSettings.enableColorImpairedMode, hasArtist: !!artistCount.count, artistError: artistCount.error, diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js index 844ffec5f..3473f4c31 100644 --- a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js @@ -164,7 +164,7 @@ class CalendarLinkModalContent extends Component { type={inputTypes.TAG} name="tags" value={tags} - helpText={translate('TagsHelpText')} + helpText={translate('ICalTagsArtistHelpText')} onChange={this.onInputChange} /> diff --git a/frontend/src/Commands/Command.ts b/frontend/src/Commands/Command.ts new file mode 100644 index 000000000..09a03865d --- /dev/null +++ b/frontend/src/Commands/Command.ts @@ -0,0 +1,38 @@ +import ModelBase from 'App/ModelBase'; + +export interface CommandBody { + sendUpdatesToClient: boolean; + updateScheduledTask: boolean; + completionMessage: string; + requiresDiskAccess: boolean; + isExclusive: boolean; + isLongRunning: boolean; + name: string; + lastExecutionTime: string; + lastStartTime: string; + trigger: string; + suppressMessages: boolean; + artistId?: number; + artistIds?: number[]; +} + +interface Command extends ModelBase { + name: string; + commandName: string; + message: string; + body: CommandBody; + priority: string; + status: string; + result: string; + queued: string; + started: string; + ended: string; + duration: string; + trigger: string; + stateChangeTime: string; + sendUpdatesToClient: boolean; + updateScheduledTask: boolean; + lastExecutionTime: string; +} + +export default Command; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js index 0e4d6a015..dff1fbf6e 100644 --- a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js @@ -3,8 +3,8 @@ import React, { Component } from 'react'; import Alert from 'Components/Alert'; import PathInput from 'Components/Form/PathInput'; import Button from 'Components/Link/Button'; -import Link from 'Components/Link/Link'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; @@ -117,7 +117,7 @@ class FileBrowserModalContent extends Component { className={styles.mappedDrivesWarning} kind={kinds.WARNING} > - Mapped network drives are not available when running as a Windows Service, see the FAQ for more information. + } diff --git a/frontend/src/Components/Filter/Builder/ArtistFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/ArtistFilterBuilderRowValue.tsx index 003206961..486027f35 100644 --- a/frontend/src/Components/Filter/Builder/ArtistFilterBuilderRowValue.tsx +++ b/frontend/src/Components/Filter/Builder/ArtistFilterBuilderRowValue.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useSelector } from 'react-redux'; import Artist from 'Artist/Artist'; import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import FilterBuilderRowValue from './FilterBuilderRowValue'; import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; @@ -11,7 +11,7 @@ function ArtistFilterBuilderRowValue(props: FilterBuilderRowValueProps) { const tagList = allArtists .map((artist) => ({ id: artist.id, name: artist.artistName })) - .sort(sortByName); + .sort(sortByProp('name')); return ; } diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js index d33f4d4fb..0c4a31657 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js @@ -1,3 +1,4 @@ +import { maxBy } from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import FormInputGroup from 'Components/Form/FormInputGroup'; @@ -50,7 +51,7 @@ class FilterBuilderModalContent extends Component { if (id) { dispatchSetFilter({ selectedFilterKey: id }); } else { - const last = customFilters[customFilters.length -1]; + const last = maxBy(customFilters, 'id'); dispatchSetFilter({ selectedFilterKey: last.id }); } @@ -108,7 +109,7 @@ class FilterBuilderModalContent extends Component { this.setState({ labelErrors: [ { - message: 'Label is required' + message: translate('LabelIsRequired') } ] }); @@ -146,7 +147,7 @@ class FilterBuilderModalContent extends Component { return ( - Custom Filter + {translate('CustomFilter')} @@ -166,7 +167,9 @@ class FilterBuilderModalContent extends Component { -
{translate('Filters')}
+
+ {translate('Filters')} +
{ diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js index f613d20bc..77dad7173 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import SelectInput from 'Components/Form/SelectInput'; import IconButton from 'Components/Link/IconButton'; import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props'; +import sortByProp from 'Utilities/Array/sortByProp'; import ArtistFilterBuilderRowValue from './ArtistFilterBuilderRowValue'; import ArtistStatusFilterBuilderRowValue from './ArtistStatusFilterBuilderRowValue'; import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; @@ -10,10 +11,11 @@ import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue'; import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; -import MetadataProfileFilterBuilderRowValueConnector from './MetadataProfileFilterBuilderRowValueConnector'; +import MetadataProfileFilterBuilderRowValue from './MetadataProfileFilterBuilderRowValue'; +import MonitorNewItemsFilterBuilderRowValue from './MonitorNewItemsFilterBuilderRowValue'; import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; -import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector'; +import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue'; import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector'; import styles from './FilterBuilderRow.css'; @@ -66,7 +68,10 @@ function getRowValueConnector(selectedFilterBuilderProp) { return IndexerFilterBuilderRowValueConnector; case filterBuilderValueTypes.METADATA_PROFILE: - return MetadataProfileFilterBuilderRowValueConnector; + return MetadataProfileFilterBuilderRowValue; + + case filterBuilderValueTypes.MONITOR_NEW_ITEMS: + return MonitorNewItemsFilterBuilderRowValue; case filterBuilderValueTypes.PROTOCOL: return ProtocolFilterBuilderRowValue; @@ -75,7 +80,7 @@ function getRowValueConnector(selectedFilterBuilderProp) { return QualityFilterBuilderRowValueConnector; case filterBuilderValueTypes.QUALITY_PROFILE: - return QualityProfileFilterBuilderRowValueConnector; + return QualityProfileFilterBuilderRowValue; case filterBuilderValueTypes.ARTIST: return ArtistFilterBuilderRowValue; @@ -220,7 +225,7 @@ class FilterBuilderRow extends Component { key: name, value: typeof label === 'function' ? label() : label }; - }).sort((a, b) => a.value.localeCompare(b.value)); + }).sort(sortByProp('value')); const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js index a7aed80b6..d1419327a 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { filterBuilderTypes } from 'Helpers/Props'; import * as filterTypes from 'Helpers/Props/filterTypes'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import FilterBuilderRowValue from './FilterBuilderRowValue'; function createTagListSelector() { @@ -38,7 +38,7 @@ function createTagListSelector() { } return acc; - }, []).sort(sortByName); + }, []).sort(sortByProp('name')); } return _.uniqBy(items, 'id'); diff --git a/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx index 45e6fc756..1b3b369be 100644 --- a/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx +++ b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx @@ -25,7 +25,7 @@ const EVENT_TYPE_OPTIONS = [ { id: 7, get name() { - return translate('ImportFailed'); + return translate('ImportCompleteFailed'); }, }, { diff --git a/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValue.tsx new file mode 100644 index 000000000..bbd9a8274 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValue.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps'; +import sortByProp from 'Utilities/Array/sortByProp'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMetadataProfilesSelector() { + return createSelector( + (state: AppState) => state.settings.metadataProfiles.items, + (metadataProfiles) => { + return metadataProfiles; + } + ); +} + +function MetadataProfileFilterBuilderRowValue( + props: FilterBuilderRowValueProps +) { + const metadataProfiles = useSelector(createMetadataProfilesSelector()); + + const tagList = metadataProfiles + .map(({ id, name }) => ({ id, name })) + .sort(sortByProp('name')); + + return ; +} + +export default MetadataProfileFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValueConnector.js deleted file mode 100644 index 89d6c06b3..000000000 --- a/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValueConnector.js +++ /dev/null @@ -1,28 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import FilterBuilderRowValue from './FilterBuilderRowValue'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.metadataProfiles, - (metadataProfiles) => { - const tagList = metadataProfiles.items.map((metadataProfile) => { - const { - id, - name - } = metadataProfile; - - return { - id, - name - }; - }); - - return { - tagList - }; - } - ); -} - -export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/MonitorNewItemsFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/MonitorNewItemsFilterBuilderRowValue.tsx new file mode 100644 index 000000000..812d8c5b1 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/MonitorNewItemsFilterBuilderRowValue.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps'; +import translate from 'Utilities/String/translate'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const options = [ + { + id: 'all', + get name() { + return translate('AllAlbums'); + }, + }, + { + id: 'new', + get name() { + return translate('New'); + }, + }, + { + id: 'none', + get name() { + return translate('None'); + }, + }, +]; + +function MonitorNewItemsFilterBuilderRowValue( + props: FilterBuilderRowValueProps +) { + return ; +} + +export default MonitorNewItemsFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx new file mode 100644 index 000000000..50036cb90 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps'; +import sortByProp from 'Utilities/Array/sortByProp'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createQualityProfilesSelector() { + return createSelector( + (state: AppState) => state.settings.qualityProfiles.items, + (qualityProfiles) => { + return qualityProfiles; + } + ); +} + +function QualityProfileFilterBuilderRowValue( + props: FilterBuilderRowValueProps +) { + const qualityProfiles = useSelector(createQualityProfilesSelector()); + + const tagList = qualityProfiles + .map(({ id, name }) => ({ id, name })) + .sort(sortByProp('name')); + + return ; +} + +export default QualityProfileFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js deleted file mode 100644 index 4a8b82283..000000000 --- a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js +++ /dev/null @@ -1,28 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import FilterBuilderRowValue from './FilterBuilderRowValue'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.qualityProfiles, - (qualityProfiles) => { - const tagList = qualityProfiles.items.map((qualityProfile) => { - const { - id, - name - } = qualityProfile; - - return { - id, - name - }; - }); - - return { - tagList - }; - } - ); -} - -export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js index 7407f729a..9f378d5a2 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js @@ -37,8 +37,8 @@ class CustomFilter extends Component { dispatchSetFilter } = this.props; - // Assume that delete and then unmounting means the delete was successful. - // Moving this check to a ancestor would be more accurate, but would have + // Assume that delete and then unmounting means the deletion was successful. + // Moving this check to an ancestor would be more accurate, but would have // more boilerplate. if (this.state.isDeleting && id === selectedFilterKey) { dispatchSetFilter({ selectedFilterKey: 'all' }); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js index cd9c07053..d70b97e44 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js @@ -5,6 +5,7 @@ import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import CustomFilter from './CustomFilter'; import styles from './CustomFiltersModalContent.css'; @@ -31,7 +32,7 @@ function CustomFiltersModalContent(props) { { customFilters - .sort((a, b) => a.label.localeCompare(b.label)) + .sort((a, b) => sortByProp(a, b, 'label')) .map((customFilter) => { return ( void; +} + +export default function ArtistTagInput(props: ArtistTagInputProps) { + const { value, onChange, ...otherProps } = props; + const isArray = Array.isArray(value); + + const handleChange = useCallback( + ({ name, value: newValue }: { name: string; value: number[] }) => { + if (isArray) { + onChange({ name, value: newValue }); + } else { + onChange({ + name, + value: newValue.length ? newValue[newValue.length - 1] : 0, + }); + } + }, + [isArray, onChange] + ); + + let finalValue: number[] = []; + + if (isArray) { + finalValue = value; + } else if (value === 0) { + finalValue = []; + } else { + finalValue = [value]; + } + + return ( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore 2786 'TagInputConnector' isn't typed yet + + ); +} diff --git a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js index c89016869..c21f0ded6 100644 --- a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js +++ b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js @@ -4,7 +4,8 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { fetchDownloadClients } from 'Store/Actions/settingsActions'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; +import translate from 'Utilities/String/translate'; import EnhancedSelectInput from './EnhancedSelectInput'; function createMapStateToProps() { @@ -22,17 +23,18 @@ function createMapStateToProps() { const filteredItems = items.filter((item) => item.protocol === protocolFilter); - const values = _.map(filteredItems.sort(sortByName), (downloadClient) => { + const values = _.map(filteredItems.sort(sortByProp('name')), (downloadClient) => { return { key: downloadClient.id, - value: downloadClient.name + value: downloadClient.name, + hint: `(${downloadClient.id})` }; }); if (includeAny) { values.unshift({ key: 0, - value: '(Any)' + value: `(${translate('Any')})` }); } diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css index 56f5564b9..defefb18e 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.css +++ b/frontend/src/Components/Form/EnhancedSelectInput.css @@ -19,7 +19,7 @@ .isDisabled { opacity: 0.7; - cursor: not-allowed; + cursor: not-allowed !important; } .dropdownArrowContainer { diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js index cc4215025..8327b9385 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -20,6 +20,8 @@ import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; import TextInput from './TextInput'; import styles from './EnhancedSelectInput.css'; +const MINIMUM_DISTANCE_FROM_EDGE = 10; + function isArrowKey(keyCode) { return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW; } @@ -137,18 +139,9 @@ class EnhancedSelectInput extends Component { // Listeners onComputeMaxHeight = (data) => { - const { - top, - bottom - } = data.offsets.reference; - const windowHeight = window.innerHeight; - if ((/^botton/).test(data.placement)) { - data.styles.maxHeight = windowHeight - bottom; - } else { - data.styles.maxHeight = top; - } + data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE; return data; }; @@ -457,6 +450,10 @@ class EnhancedSelectInput extends Component { order: 851, enabled: true, fn: this.onComputeMaxHeight + }, + preventOverflow: { + enabled: true, + boundariesElement: 'viewport' } }} > diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index 2befeaf1a..3173b493d 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -4,6 +4,7 @@ import Link from 'Components/Link/Link'; import { inputTypes, kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import AlbumReleaseSelectInputConnector from './AlbumReleaseSelectInputConnector'; +import ArtistTagInput from './ArtistTagInput'; import AutoCompleteInput from './AutoCompleteInput'; import CaptchaInputConnector from './CaptchaInputConnector'; import CheckInput from './CheckInput'; @@ -12,6 +13,7 @@ import DownloadClientSelectInputConnector from './DownloadClientSelectInputConne import EnhancedSelectInput from './EnhancedSelectInput'; import EnhancedSelectInputConnector from './EnhancedSelectInputConnector'; import FormInputHelpText from './FormInputHelpText'; +import IndexerFlagsSelectInput from './IndexerFlagsSelectInput'; import IndexerSelectInputConnector from './IndexerSelectInputConnector'; import KeyValueListInput from './KeyValueListInput'; import MetadataProfileSelectInputConnector from './MetadataProfileSelectInputConnector'; @@ -47,12 +49,12 @@ function getComponent(type) { case inputTypes.DEVICE: return DeviceInputConnector; - case inputTypes.PLAYLIST: - return PlaylistInputConnector; - case inputTypes.KEY_VALUE_LIST: return KeyValueListInput; + case inputTypes.PLAYLIST: + return PlaylistInputConnector; + case inputTypes.MONITOR_ALBUMS_SELECT: return MonitorAlbumsSelectInput; @@ -83,6 +85,9 @@ function getComponent(type) { case inputTypes.INDEXER_SELECT: return IndexerSelectInputConnector; + case inputTypes.INDEXER_FLAGS_SELECT: + return IndexerFlagsSelectInput; + case inputTypes.DOWNLOAD_CLIENT_SELECT: return DownloadClientSelectInputConnector; @@ -95,6 +100,9 @@ function getComponent(type) { case inputTypes.DYNAMIC_SELECT: return EnhancedSelectInputConnector; + case inputTypes.ARTIST_TAG: + return ArtistTagInput; + case inputTypes.SERIES_TYPE_SELECT: return SeriesTypeSelectInput; @@ -276,6 +284,7 @@ FormInputGroup.propTypes = { name: PropTypes.string.isRequired, value: PropTypes.any, values: PropTypes.arrayOf(PropTypes.any), + isDisabled: PropTypes.bool, type: PropTypes.string.isRequired, kind: PropTypes.oneOf(kinds.all), min: PropTypes.number, @@ -291,6 +300,7 @@ FormInputGroup.propTypes = { includeNoChangeDisabled: PropTypes.bool, includeNone: PropTypes.bool, selectedValueOptions: PropTypes.object, + indexerFlags: PropTypes.number, pending: PropTypes.bool, errors: PropTypes.arrayOf(PropTypes.object), warnings: PropTypes.arrayOf(PropTypes.object), diff --git a/frontend/src/Components/Form/IndexerFlagsSelectInput.tsx b/frontend/src/Components/Form/IndexerFlagsSelectInput.tsx new file mode 100644 index 000000000..8dbd27a70 --- /dev/null +++ b/frontend/src/Components/Form/IndexerFlagsSelectInput.tsx @@ -0,0 +1,62 @@ +import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import EnhancedSelectInput from './EnhancedSelectInput'; + +const selectIndexerFlagsValues = (selectedFlags: number) => + createSelector( + (state: AppState) => state.settings.indexerFlags, + (indexerFlags) => { + const value = indexerFlags.items.reduce((acc: number[], { id }) => { + // eslint-disable-next-line no-bitwise + if ((selectedFlags & id) === id) { + acc.push(id); + } + + return acc; + }, []); + + const values = indexerFlags.items.map(({ id, name }) => ({ + key: id, + value: name, + })); + + return { + value, + values, + }; + } + ); + +interface IndexerFlagsSelectInputProps { + name: string; + indexerFlags: number; + onChange(payload: object): void; +} + +function IndexerFlagsSelectInput(props: IndexerFlagsSelectInputProps) { + const { indexerFlags, onChange } = props; + + const { value, values } = useSelector(selectIndexerFlagsValues(indexerFlags)); + + const onChangeWrapper = useCallback( + ({ name, value }: { name: string; value: number[] }) => { + const indexerFlags = value.reduce((acc, flagId) => acc + flagId, 0); + + onChange({ name, value: indexerFlags }); + }, + [onChange] + ); + + return ( + + ); +} + +export default IndexerFlagsSelectInput; diff --git a/frontend/src/Components/Form/IndexerSelectInputConnector.js b/frontend/src/Components/Form/IndexerSelectInputConnector.js index cd58270eb..5f62becbb 100644 --- a/frontend/src/Components/Form/IndexerSelectInputConnector.js +++ b/frontend/src/Components/Form/IndexerSelectInputConnector.js @@ -4,7 +4,8 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { fetchIndexers } from 'Store/Actions/settingsActions'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; +import translate from 'Utilities/String/translate'; import EnhancedSelectInput from './EnhancedSelectInput'; function createMapStateToProps() { @@ -19,7 +20,7 @@ function createMapStateToProps() { items } = indexers; - const values = _.map(items.sort(sortByName), (indexer) => { + const values = _.map(items.sort(sortByProp('name')), (indexer) => { return { key: indexer.id, value: indexer.name @@ -29,7 +30,7 @@ function createMapStateToProps() { if (includeAny) { values.unshift({ key: 0, - value: '(Any)' + value: `(${translate('Any')})` }); } diff --git a/frontend/src/Components/Form/KeyValueListInput.js b/frontend/src/Components/Form/KeyValueListInput.js deleted file mode 100644 index 3e73d74f3..000000000 --- a/frontend/src/Components/Form/KeyValueListInput.js +++ /dev/null @@ -1,156 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import KeyValueListInputItem from './KeyValueListInputItem'; -import styles from './KeyValueListInput.css'; - -class KeyValueListInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isFocused: false - }; - } - - // - // Listeners - - onItemChange = (index, itemValue) => { - const { - name, - value, - onChange - } = this.props; - - const newValue = [...value]; - - if (index == null) { - newValue.push(itemValue); - } else { - newValue.splice(index, 1, itemValue); - } - - onChange({ - name, - value: newValue - }); - }; - - onRemoveItem = (index) => { - const { - name, - value, - onChange - } = this.props; - - const newValue = [...value]; - newValue.splice(index, 1); - - onChange({ - name, - value: newValue - }); - }; - - onFocus = () => { - this.setState({ - isFocused: true - }); - }; - - onBlur = () => { - this.setState({ - isFocused: false - }); - - const { - name, - value, - onChange - } = this.props; - - const newValue = value.reduce((acc, v) => { - if (v.key || v.value) { - acc.push(v); - } - - return acc; - }, []); - - if (newValue.length !== value.length) { - onChange({ - name, - value: newValue - }); - } - }; - - // - // Render - - render() { - const { - className, - value, - keyPlaceholder, - valuePlaceholder, - hasError, - hasWarning - } = this.props; - - const { isFocused } = this.state; - - return ( -
- { - [...value, { key: '', value: '' }].map((v, index) => { - return ( - - ); - }) - } -
- ); - } -} - -KeyValueListInput.propTypes = { - className: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.object).isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - keyPlaceholder: PropTypes.string, - valuePlaceholder: PropTypes.string, - onChange: PropTypes.func.isRequired -}; - -KeyValueListInput.defaultProps = { - className: styles.inputContainer, - value: [] -}; - -export default KeyValueListInput; diff --git a/frontend/src/Components/Form/KeyValueListInput.tsx b/frontend/src/Components/Form/KeyValueListInput.tsx new file mode 100644 index 000000000..f5c6ac19b --- /dev/null +++ b/frontend/src/Components/Form/KeyValueListInput.tsx @@ -0,0 +1,104 @@ +import classNames from 'classnames'; +import React, { useCallback, useState } from 'react'; +import { InputOnChange } from 'typings/inputs'; +import KeyValueListInputItem from './KeyValueListInputItem'; +import styles from './KeyValueListInput.css'; + +interface KeyValue { + key: string; + value: string; +} + +export interface KeyValueListInputProps { + className?: string; + name: string; + value: KeyValue[]; + hasError?: boolean; + hasWarning?: boolean; + keyPlaceholder?: string; + valuePlaceholder?: string; + onChange: InputOnChange; +} + +function KeyValueListInput({ + className = styles.inputContainer, + name, + value = [], + hasError = false, + hasWarning = false, + keyPlaceholder, + valuePlaceholder, + onChange, +}: KeyValueListInputProps): JSX.Element { + const [isFocused, setIsFocused] = useState(false); + + const handleItemChange = useCallback( + (index: number | null, itemValue: KeyValue) => { + const newValue = [...value]; + + if (index === null) { + newValue.push(itemValue); + } else { + newValue.splice(index, 1, itemValue); + } + + onChange({ name, value: newValue }); + }, + [value, name, onChange] + ); + + const handleRemoveItem = useCallback( + (index: number) => { + const newValue = [...value]; + newValue.splice(index, 1); + onChange({ name, value: newValue }); + }, + [value, name, onChange] + ); + + const onFocus = useCallback(() => setIsFocused(true), []); + + const onBlur = useCallback(() => { + setIsFocused(false); + + const newValue = value.reduce((acc: KeyValue[], v) => { + if (v.key || v.value) { + acc.push(v); + } + return acc; + }, []); + + if (newValue.length !== value.length) { + onChange({ name, value: newValue }); + } + }, [value, name, onChange]); + + return ( +
+ {[...value, { key: '', value: '' }].map((v, index) => ( + + ))} +
+ ); +} + +export default KeyValueListInput; diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css b/frontend/src/Components/Form/KeyValueListInputItem.css index dca2882a8..ed82db459 100644 --- a/frontend/src/Components/Form/KeyValueListInputItem.css +++ b/frontend/src/Components/Form/KeyValueListInputItem.css @@ -5,13 +5,19 @@ &:last-child { margin-bottom: 0; + border-bottom: 0; } } -.inputWrapper { +.keyInputWrapper { flex: 1 0 0; } +.valueInputWrapper { + flex: 1 0 0; + min-width: 40px; +} + .buttonWrapper { flex: 0 0 22px; } @@ -20,6 +26,10 @@ .valueInput { width: 100%; border: none; - background-color: var(--inputBackgroundColor); + background-color: transparent; color: var(--textColor); + + &::placeholder { + color: var(--helpTextColor); + } } diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts b/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts index 35baf55cd..aa0c1be13 100644 --- a/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts +++ b/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts @@ -2,10 +2,11 @@ // Please do not change this file! interface CssExports { 'buttonWrapper': string; - 'inputWrapper': string; 'itemContainer': string; 'keyInput': string; + 'keyInputWrapper': string; 'valueInput': string; + 'valueInputWrapper': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Components/Form/KeyValueListInputItem.js b/frontend/src/Components/Form/KeyValueListInputItem.js deleted file mode 100644 index 5379c2129..000000000 --- a/frontend/src/Components/Form/KeyValueListInputItem.js +++ /dev/null @@ -1,124 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import IconButton from 'Components/Link/IconButton'; -import { icons } from 'Helpers/Props'; -import TextInput from './TextInput'; -import styles from './KeyValueListInputItem.css'; - -class KeyValueListInputItem extends Component { - - // - // Listeners - - onKeyChange = ({ value: keyValue }) => { - const { - index, - value, - onChange - } = this.props; - - onChange(index, { key: keyValue, value }); - }; - - onValueChange = ({ value }) => { - // TODO: Validate here or validate at a lower level component - - const { - index, - keyValue, - onChange - } = this.props; - - onChange(index, { key: keyValue, value }); - }; - - onRemovePress = () => { - const { - index, - onRemove - } = this.props; - - onRemove(index); - }; - - onFocus = () => { - this.props.onFocus(); - }; - - onBlur = () => { - this.props.onBlur(); - }; - - // - // Render - - render() { - const { - keyValue, - value, - keyPlaceholder, - valuePlaceholder, - isNew - } = this.props; - - return ( -
-
- -
- -
- -
- -
- { - isNew ? - null : - - } -
-
- ); - } -} - -KeyValueListInputItem.propTypes = { - index: PropTypes.number, - keyValue: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - keyPlaceholder: PropTypes.string.isRequired, - valuePlaceholder: PropTypes.string.isRequired, - isNew: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - onRemove: PropTypes.func.isRequired, - onFocus: PropTypes.func.isRequired, - onBlur: PropTypes.func.isRequired -}; - -KeyValueListInputItem.defaultProps = { - keyPlaceholder: 'Key', - valuePlaceholder: 'Value' -}; - -export default KeyValueListInputItem; diff --git a/frontend/src/Components/Form/KeyValueListInputItem.tsx b/frontend/src/Components/Form/KeyValueListInputItem.tsx new file mode 100644 index 000000000..c63ad50a9 --- /dev/null +++ b/frontend/src/Components/Form/KeyValueListInputItem.tsx @@ -0,0 +1,89 @@ +import React, { useCallback } from 'react'; +import IconButton from 'Components/Link/IconButton'; +import { icons } from 'Helpers/Props'; +import TextInput from './TextInput'; +import styles from './KeyValueListInputItem.css'; + +interface KeyValueListInputItemProps { + index: number; + keyValue: string; + value: string; + keyPlaceholder?: string; + valuePlaceholder?: string; + isNew: boolean; + onChange: (index: number, itemValue: { key: string; value: string }) => void; + onRemove: (index: number) => void; + onFocus: () => void; + onBlur: () => void; +} + +function KeyValueListInputItem({ + index, + keyValue, + value, + keyPlaceholder = 'Key', + valuePlaceholder = 'Value', + isNew, + onChange, + onRemove, + onFocus, + onBlur, +}: KeyValueListInputItemProps): JSX.Element { + const handleKeyChange = useCallback( + ({ value: keyValue }: { value: string }) => { + onChange(index, { key: keyValue, value }); + }, + [index, value, onChange] + ); + + const handleValueChange = useCallback( + ({ value }: { value: string }) => { + onChange(index, { key: keyValue, value }); + }, + [index, keyValue, onChange] + ); + + const handleRemovePress = useCallback(() => { + onRemove(index); + }, [index, onRemove]); + + return ( +
+
+ +
+ +
+ +
+ +
+ {isNew ? null : ( + + )} +
+
+ ); +} + +export default KeyValueListInputItem; diff --git a/frontend/src/Components/Form/MetadataProfileSelectInputConnector.js b/frontend/src/Components/Form/MetadataProfileSelectInputConnector.js index 3d763e713..6e6aad5f9 100644 --- a/frontend/src/Components/Form/MetadataProfileSelectInputConnector.js +++ b/frontend/src/Components/Form/MetadataProfileSelectInputConnector.js @@ -5,13 +5,13 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { metadataProfileNames } from 'Helpers/Props'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import EnhancedSelectInput from './EnhancedSelectInput'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.metadataProfiles', sortByName), + createSortedSectionSelector('settings.metadataProfiles', sortByProp('name')), (state, { includeNoChange }) => includeNoChange, (state, { includeNoChangeDisabled }) => includeNoChangeDisabled, (state, { includeMixed }) => includeMixed, @@ -38,7 +38,7 @@ function createMapStateToProps() { values.unshift({ key: 'noChange', value: translate('NoChange'), - disabled: includeNoChangeDisabled + isDisabled: includeNoChangeDisabled }); } @@ -46,7 +46,7 @@ function createMapStateToProps() { values.unshift({ key: 'mixed', value: '(Mixed)', - disabled: true + isDisabled: true }); } diff --git a/frontend/src/Components/Form/MonitorAlbumsSelectInput.js b/frontend/src/Components/Form/MonitorAlbumsSelectInput.js index a10bbb776..d48284c38 100644 --- a/frontend/src/Components/Form/MonitorAlbumsSelectInput.js +++ b/frontend/src/Components/Form/MonitorAlbumsSelectInput.js @@ -18,15 +18,15 @@ function MonitorAlbumsSelectInput(props) { values.unshift({ key: 'noChange', value: translate('NoChange'), - disabled: includeNoChangeDisabled + isDisabled: includeNoChangeDisabled }); } if (includeMixed) { values.unshift({ key: 'mixed', - value: '(Mixed)', - disabled: true + value: `(${translate('Mixed')})`, + isDisabled: true }); } diff --git a/frontend/src/Components/Form/MonitorNewItemsSelectInput.js b/frontend/src/Components/Form/MonitorNewItemsSelectInput.js index f9cc07d7d..0dccc44a4 100644 --- a/frontend/src/Components/Form/MonitorNewItemsSelectInput.js +++ b/frontend/src/Components/Form/MonitorNewItemsSelectInput.js @@ -18,7 +18,7 @@ function MonitorNewItemsSelectInput(props) { values.unshift({ key: 'noChange', value: translate('NoChange'), - disabled: includeNoChangeDisabled + isDisabled: includeNoChangeDisabled }); } @@ -26,7 +26,7 @@ function MonitorNewItemsSelectInput(props) { values.unshift({ key: 'mixed', value: '(Mixed)', - disabled: true + isDisabled: true }); } diff --git a/frontend/src/Components/Form/PasswordInput.css b/frontend/src/Components/Form/PasswordInput.css deleted file mode 100644 index 6cb162784..000000000 --- a/frontend/src/Components/Form/PasswordInput.css +++ /dev/null @@ -1,5 +0,0 @@ -.input { - composes: input from '~Components/Form/TextInput.css'; - - font-family: $passwordFamily; -} diff --git a/frontend/src/Components/Form/PasswordInput.js b/frontend/src/Components/Form/PasswordInput.js index fef54fd5a..dbc4cfdb4 100644 --- a/frontend/src/Components/Form/PasswordInput.js +++ b/frontend/src/Components/Form/PasswordInput.js @@ -1,7 +1,5 @@ -import PropTypes from 'prop-types'; import React from 'react'; import TextInput from './TextInput'; -import styles from './PasswordInput.css'; // Prevent a user from copying (or cutting) the password from the input function onCopy(e) { @@ -13,17 +11,14 @@ function PasswordInput(props) { return ( ); } PasswordInput.propTypes = { - className: PropTypes.string.isRequired -}; - -PasswordInput.defaultProps = { - className: styles.input + ...TextInput.props }; export default PasswordInput; diff --git a/frontend/src/Components/Form/PlaylistInput.js b/frontend/src/Components/Form/PlaylistInput.js index 77718f4f1..0b3966f60 100644 --- a/frontend/src/Components/Form/PlaylistInput.js +++ b/frontend/src/Components/Form/PlaylistInput.js @@ -9,7 +9,6 @@ import TableBody from 'Components/Table/TableBody'; import TableRow from 'Components/Table/TableRow'; import tagShape from 'Helpers/Props/Shapes/tagShape'; import translate from 'Utilities/String/translate'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; import selectAll from 'Utilities/Table/selectAll'; import toggleSelected from 'Utilities/Table/toggleSelected'; import styles from './PlaylistInput.css'; @@ -46,7 +45,17 @@ class PlaylistInput extends Component { onChange } = this.props; - const oldSelected = getSelectedIds(prevState.selectedState, { parseIds: false }).sort(); + const oldSelected = _.reduce( + prevState.selectedState, + (result, value, id) => { + if (value) { + result.push(id); + } + + return result; + }, + [] + ).sort(); const newSelected = this.getSelectedIds().sort(); if (!_.isEqual(oldSelected, newSelected)) { @@ -61,7 +70,17 @@ class PlaylistInput extends Component { // Control getSelectedIds = () => { - return getSelectedIds(this.state.selectedState, { parseIds: false }); + return _.reduce( + this.state.selectedState, + (result, value, id) => { + if (value) { + result.push(id); + } + + return result; + }, + [] + ); }; // diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js index 9c2d124d7..311f1bbbd 100644 --- a/frontend/src/Components/Form/ProviderFieldFormGroup.js +++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js @@ -14,6 +14,8 @@ function getType({ type, selectOptionsProviderAction }) { return inputTypes.CHECK; case 'device': return inputTypes.DEVICE; + case 'keyValueList': + return inputTypes.KEY_VALUE_LIST; case 'playlist': return inputTypes.PLAYLIST; case 'password': @@ -29,6 +31,8 @@ function getType({ type, selectOptionsProviderAction }) { return inputTypes.DYNAMIC_SELECT; } return inputTypes.SELECT; + case 'artistTag': + return inputTypes.ARTIST_TAG; case 'tag': return inputTypes.TEXT_TAG; case 'tagSelect': diff --git a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js index a898de4a2..d7719969a 100644 --- a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js +++ b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js @@ -4,13 +4,13 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import EnhancedSelectInput from './EnhancedSelectInput'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.qualityProfiles', sortByName), + createSortedSectionSelector('settings.qualityProfiles', sortByProp('name')), (state, { includeNoChange }) => includeNoChange, (state, { includeNoChangeDisabled }) => includeNoChangeDisabled, (state, { includeMixed }) => includeMixed, @@ -26,7 +26,7 @@ function createMapStateToProps() { values.unshift({ key: 'noChange', value: translate('NoChange'), - disabled: includeNoChangeDisabled + isDisabled: includeNoChangeDisabled }); } @@ -34,7 +34,7 @@ function createMapStateToProps() { values.unshift({ key: 'mixed', value: '(Mixed)', - disabled: true + isDisabled: true }); } diff --git a/frontend/src/Components/Form/RootFolderSelectInputConnector.js b/frontend/src/Components/Form/RootFolderSelectInputConnector.js index a7912a7f5..dcc2b88a6 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputConnector.js +++ b/frontend/src/Components/Form/RootFolderSelectInputConnector.js @@ -2,13 +2,14 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; +import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector'; import RootFolderSelectInput from './RootFolderSelectInput'; const ADD_NEW_KEY = 'addNew'; function createMapStateToProps() { return createSelector( - (state) => state.settings.rootFolders, + createRootFoldersSelector(), (state, { value }) => value, (state, { includeMissingValue }) => includeMissingValue, (state, { includeNoChange }) => includeNoChange, diff --git a/frontend/src/Components/Form/SelectInput.js b/frontend/src/Components/Form/SelectInput.js index 553501afc..d43560134 100644 --- a/frontend/src/Components/Form/SelectInput.js +++ b/frontend/src/Components/Form/SelectInput.js @@ -52,6 +52,7 @@ class SelectInput extends Component { const { key, value: optionValue, + isDisabled: optionIsDisabled = false, ...otherOptionProps } = option; @@ -59,6 +60,7 @@ class SelectInput extends Component {
diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js deleted file mode 100644 index 5e5dda730..000000000 --- a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js +++ /dev/null @@ -1,88 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import Menu from 'Components/Menu/Menu'; -import MenuButton from 'Components/Menu/MenuButton'; -import MenuContent from 'Components/Menu/MenuContent'; -import MenuItem from 'Components/Menu/MenuItem'; -import MenuItemSeparator from 'Components/Menu/MenuItemSeparator'; -import { align, icons, kinds } from 'Helpers/Props'; -import styles from './PageHeaderActionsMenu.css'; - -function PageHeaderActionsMenu(props) { - const { - formsAuth, - onKeyboardShortcutsPress, - onRestartPress, - onShutdownPress - } = props; - - return ( -
- - - - - - - - - Keyboard Shortcuts - - - - - - - Restart - - - - - Shutdown - - - { - formsAuth && -
- } - - { - formsAuth && - - - Logout - - } - -
-
- ); -} - -PageHeaderActionsMenu.propTypes = { - formsAuth: PropTypes.bool.isRequired, - onKeyboardShortcutsPress: PropTypes.func.isRequired, - onRestartPress: PropTypes.func.isRequired, - onShutdownPress: PropTypes.func.isRequired -}; - -export default PageHeaderActionsMenu; diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx new file mode 100644 index 000000000..7a0c35c1c --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx @@ -0,0 +1,87 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Icon from 'Components/Icon'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; +import MenuItemSeparator from 'Components/Menu/MenuItemSeparator'; +import { align, icons, kinds } from 'Helpers/Props'; +import { restart, shutdown } from 'Store/Actions/systemActions'; +import translate from 'Utilities/String/translate'; +import styles from './PageHeaderActionsMenu.css'; + +interface PageHeaderActionsMenuProps { + onKeyboardShortcutsPress(): void; +} + +function PageHeaderActionsMenu(props: PageHeaderActionsMenuProps) { + const { onKeyboardShortcutsPress } = props; + + const dispatch = useDispatch(); + + const { authentication, isDocker } = useSelector( + (state: AppState) => state.system.status.item + ); + + const formsAuth = authentication === 'forms'; + + const handleRestartPress = useCallback(() => { + dispatch(restart()); + }, [dispatch]); + + const handleShutdownPress = useCallback(() => { + dispatch(shutdown()); + }, [dispatch]); + + return ( +
+ + + + + + + + + {translate('KeyboardShortcuts')} + + + {isDocker ? null : ( + <> + + + + + {translate('Restart')} + + + + + {translate('Shutdown')} + + + )} + + {formsAuth ? ( + <> + + + + + {translate('Logout')} + + + ) : null} + + +
+ ); +} + +export default PageHeaderActionsMenu; diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js deleted file mode 100644 index 3aba95065..000000000 --- a/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js +++ /dev/null @@ -1,56 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { restart, shutdown } from 'Store/Actions/systemActions'; -import PageHeaderActionsMenu from './PageHeaderActionsMenu'; - -function createMapStateToProps() { - return createSelector( - (state) => state.system.status, - (status) => { - return { - formsAuth: status.item.authentication === 'forms' - }; - } - ); -} - -const mapDispatchToProps = { - restart, - shutdown -}; - -class PageHeaderActionsMenuConnector extends Component { - - // - // Listeners - - onRestartPress = () => { - this.props.restart(); - }; - - onShutdownPress = () => { - this.props.shutdown(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -PageHeaderActionsMenuConnector.propTypes = { - restart: PropTypes.func.isRequired, - shutdown: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector); diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index 070be9b42..c84099a5e 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -6,7 +6,14 @@ import { createSelector } from 'reselect'; import { fetchTranslations, saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; import { fetchArtist } from 'Store/Actions/artistActions'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; -import { fetchImportLists, fetchLanguages, fetchMetadataProfiles, fetchQualityProfiles, fetchUISettings } from 'Store/Actions/settingsActions'; +import { + fetchImportLists, + fetchIndexerFlags, + fetchLanguages, + fetchMetadataProfiles, + fetchQualityProfiles, + fetchUISettings +} from 'Store/Actions/settingsActions'; import { fetchStatus } from 'Store/Actions/systemActions'; import { fetchTags } from 'Store/Actions/tagActions'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; @@ -44,6 +51,7 @@ const selectAppProps = createSelector( ); const selectIsPopulated = createSelector( + (state) => state.artist.isPopulated, (state) => state.customFilters.isPopulated, (state) => state.tags.isPopulated, (state) => state.settings.ui.isPopulated, @@ -51,9 +59,11 @@ const selectIsPopulated = createSelector( (state) => state.settings.qualityProfiles.isPopulated, (state) => state.settings.metadataProfiles.isPopulated, (state) => state.settings.importLists.isPopulated, + (state) => state.settings.indexerFlags.isPopulated, (state) => state.system.status.isPopulated, (state) => state.app.translations.isPopulated, ( + artistsIsPopulated, customFiltersIsPopulated, tagsIsPopulated, uiSettingsIsPopulated, @@ -61,10 +71,12 @@ const selectIsPopulated = createSelector( qualityProfilesIsPopulated, metadataProfilesIsPopulated, importListsIsPopulated, + indexerFlagsIsPopulated, systemStatusIsPopulated, translationsIsPopulated ) => { return ( + artistsIsPopulated && customFiltersIsPopulated && tagsIsPopulated && uiSettingsIsPopulated && @@ -72,6 +84,7 @@ const selectIsPopulated = createSelector( qualityProfilesIsPopulated && metadataProfilesIsPopulated && importListsIsPopulated && + indexerFlagsIsPopulated && systemStatusIsPopulated && translationsIsPopulated ); @@ -79,6 +92,7 @@ const selectIsPopulated = createSelector( ); const selectErrors = createSelector( + (state) => state.artist.error, (state) => state.customFilters.error, (state) => state.tags.error, (state) => state.settings.ui.error, @@ -86,9 +100,11 @@ const selectErrors = createSelector( (state) => state.settings.qualityProfiles.error, (state) => state.settings.metadataProfiles.error, (state) => state.settings.importLists.error, + (state) => state.settings.indexerFlags.error, (state) => state.system.status.error, (state) => state.app.translations.error, ( + artistsError, customFiltersError, tagsError, uiSettingsError, @@ -96,10 +112,12 @@ const selectErrors = createSelector( qualityProfilesError, metadataProfilesError, importListsError, + indexerFlagsError, systemStatusError, translationsError ) => { const hasError = !!( + artistsError || customFiltersError || tagsError || uiSettingsError || @@ -107,6 +125,7 @@ const selectErrors = createSelector( qualityProfilesError || metadataProfilesError || importListsError || + indexerFlagsError || systemStatusError || translationsError ); @@ -120,6 +139,7 @@ const selectErrors = createSelector( qualityProfilesError, metadataProfilesError, importListsError, + indexerFlagsError, systemStatusError, translationsError }; @@ -177,6 +197,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchImportLists() { dispatch(fetchImportLists()); }, + dispatchFetchIndexerFlags() { + dispatch(fetchIndexerFlags()); + }, dispatchFetchUISettings() { dispatch(fetchUISettings()); }, @@ -217,6 +240,7 @@ class PageConnector extends Component { this.props.dispatchFetchQualityProfiles(); this.props.dispatchFetchMetadataProfiles(); this.props.dispatchFetchImportLists(); + this.props.dispatchFetchIndexerFlags(); this.props.dispatchFetchUISettings(); this.props.dispatchFetchStatus(); this.props.dispatchFetchTranslations(); @@ -243,6 +267,7 @@ class PageConnector extends Component { dispatchFetchQualityProfiles, dispatchFetchMetadataProfiles, dispatchFetchImportLists, + dispatchFetchIndexerFlags, dispatchFetchUISettings, dispatchFetchStatus, dispatchFetchTranslations, @@ -284,6 +309,7 @@ PageConnector.propTypes = { dispatchFetchQualityProfiles: PropTypes.func.isRequired, dispatchFetchMetadataProfiles: PropTypes.func.isRequired, dispatchFetchImportLists: PropTypes.func.isRequired, + dispatchFetchIndexerFlags: PropTypes.func.isRequired, dispatchFetchUISettings: PropTypes.func.isRequired, dispatchFetchStatus: PropTypes.func.isRequired, dispatchFetchTranslations: PropTypes.func.isRequired, diff --git a/frontend/src/Components/Page/PageContentBody.tsx b/frontend/src/Components/Page/PageContentBody.tsx index 972a9bade..ce9b0e7e4 100644 --- a/frontend/src/Components/Page/PageContentBody.tsx +++ b/frontend/src/Components/Page/PageContentBody.tsx @@ -1,5 +1,5 @@ -import React, { forwardRef, ReactNode, useCallback } from 'react'; -import Scroller from 'Components/Scroller/Scroller'; +import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react'; +import Scroller, { OnScroll } from 'Components/Scroller/Scroller'; import ScrollDirection from 'Helpers/Props/ScrollDirection'; import { isLocked } from 'Utilities/scrollLock'; import styles from './PageContentBody.css'; @@ -9,14 +9,11 @@ interface PageContentBodyProps { innerClassName?: string; children: ReactNode; initialScrollTop?: number; - onScroll?: (payload) => void; + onScroll?: (payload: OnScroll) => void; } const PageContentBody = forwardRef( - ( - props: PageContentBodyProps, - ref: React.MutableRefObject - ) => { + (props: PageContentBodyProps, ref: ForwardedRef) => { const { className = styles.contentBody, innerClassName = styles.innerContentBody, @@ -26,7 +23,7 @@ const PageContentBody = forwardRef( } = props; const onScrollWrapper = useCallback( - (payload) => { + (payload: OnScroll) => { if (onScroll && !isLocked()) { onScroll(payload); } diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index 09a69b459..d6db8d612 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -129,7 +129,7 @@ const links = [ to: '/settings/general' }, { - title: () => translate('UI'), + title: () => translate('Ui'), to: '/settings/ui' } ] diff --git a/frontend/src/Components/Scroller/Scroller.tsx b/frontend/src/Components/Scroller/Scroller.tsx index 2bcb899aa..37b16eebd 100644 --- a/frontend/src/Components/Scroller/Scroller.tsx +++ b/frontend/src/Components/Scroller/Scroller.tsx @@ -1,9 +1,21 @@ import classNames from 'classnames'; import { throttle } from 'lodash'; -import React, { forwardRef, ReactNode, useEffect, useRef } from 'react'; +import React, { + ForwardedRef, + forwardRef, + MutableRefObject, + ReactNode, + useEffect, + useRef, +} from 'react'; import ScrollDirection from 'Helpers/Props/ScrollDirection'; import styles from './Scroller.css'; +export interface OnScroll { + scrollLeft: number; + scrollTop: number; +} + interface ScrollerProps { className?: string; scrollDirection?: ScrollDirection; @@ -12,11 +24,11 @@ interface ScrollerProps { scrollTop?: number; initialScrollTop?: number; children?: ReactNode; - onScroll?: (payload) => void; + onScroll?: (payload: OnScroll) => void; } const Scroller = forwardRef( - (props: ScrollerProps, ref: React.MutableRefObject) => { + (props: ScrollerProps, ref: ForwardedRef) => { const { className, autoFocus = false, @@ -30,7 +42,7 @@ const Scroller = forwardRef( } = props; const internalRef = useRef(); - const currentRef = ref ?? internalRef; + const currentRef = (ref as MutableRefObject) ?? internalRef; useEffect( () => { diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index c970589c7..365827a2b 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -172,7 +172,7 @@ class SignalRConnector extends Component { const status = resource.status; // Both successful and failed commands need to be - // completed, otherwise they spin until they timeout. + // completed, otherwise they spin until they time out. if (status === 'completed' || status === 'failed') { this.props.dispatchFinishCommand(resource); @@ -224,10 +224,58 @@ class SignalRConnector extends Component { repopulatePage('trackFileUpdated'); }; + handleDownloadclient = ({ action, resource }) => { + const section = 'settings.downloadClients'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + handleHealth = () => { this.props.dispatchFetchHealth(); }; + handleImportlist = ({ action, resource }) => { + const section = 'settings.importLists'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + + handleIndexer = ({ action, resource }) => { + const section = 'settings.indexers'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + + handleMetadata = ({ action, resource }) => { + const section = 'settings.metadata'; + + if (action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } + }; + + handleNotification = ({ action, resource }) => { + const section = 'settings.notifications'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + handleArtist = (body) => { const action = body.action; const section = 'artist'; @@ -266,7 +314,7 @@ class SignalRConnector extends Component { handleWantedCutoff = (body) => { if (body.action === 'updated') { this.props.dispatchUpdateItem({ - section: 'cutoffUnmet', + section: 'wanted.cutoffUnmet', updateOnly: true, ...body.resource }); @@ -276,7 +324,7 @@ class SignalRConnector extends Component { handleWantedMissing = (body) => { if (body.action === 'updated') { this.props.dispatchUpdateItem({ - section: 'missing', + section: 'wanted.missing', updateOnly: true, ...body.resource }); diff --git a/frontend/src/Components/Table/Cells/TableRowCell.css b/frontend/src/Components/Table/Cells/TableRowCell.css index 47ce0d22e..7e3353c25 100644 --- a/frontend/src/Components/Table/Cells/TableRowCell.css +++ b/frontend/src/Components/Table/Cells/TableRowCell.css @@ -4,7 +4,7 @@ line-height: 1.52857143; } -@media only screen and (max-width: $breakpointSmall) { +@media only screen and (max-width: $breakpointMedium) { .cell { white-space: nowrap; } diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.css b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css index 2501b7c84..f7f3b9306 100644 --- a/frontend/src/Components/Table/Cells/VirtualTableRowCell.css +++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css @@ -7,7 +7,7 @@ white-space: nowrap; } -@media only screen and (max-width: $breakpointSmall) { +@media only screen and (max-width: $breakpointMedium) { .cell { white-space: nowrap; } diff --git a/frontend/src/Components/Table/Table.css b/frontend/src/Components/Table/Table.css index bdfdec641..d0507be6b 100644 --- a/frontend/src/Components/Table/Table.css +++ b/frontend/src/Components/Table/Table.css @@ -10,7 +10,7 @@ border-collapse: collapse; } -@media only screen and (max-width: $breakpointSmall) { +@media only screen and (max-width: $breakpointMedium) { .tableContainer { min-width: 100%; width: fit-content; diff --git a/frontend/src/Components/Table/TableHeaderCell.css b/frontend/src/Components/Table/TableHeaderCell.css index c2c4f58c8..eded9c95b 100644 --- a/frontend/src/Components/Table/TableHeaderCell.css +++ b/frontend/src/Components/Table/TableHeaderCell.css @@ -9,7 +9,7 @@ margin-left: 10px; } -@media only screen and (max-width: $breakpointSmall) { +@media only screen and (max-width: $breakpointMedium) { .headerCell { white-space: nowrap; } diff --git a/frontend/src/Components/Table/TablePager.css b/frontend/src/Components/Table/TablePager.css index d73a0d0c0..6d184196e 100644 --- a/frontend/src/Components/Table/TablePager.css +++ b/frontend/src/Components/Table/TablePager.css @@ -60,7 +60,7 @@ height: 25px; } -@media only screen and (max-width: $breakpointSmall) { +@media only screen and (max-width: $breakpointMedium) { .pager { flex-wrap: wrap; } diff --git a/frontend/src/Components/Table/VirtualTable.js b/frontend/src/Components/Table/VirtualTable.js index 4a597e795..5473413cb 100644 --- a/frontend/src/Components/Table/VirtualTable.js +++ b/frontend/src/Components/Table/VirtualTable.js @@ -7,6 +7,8 @@ import { scrollDirections } from 'Helpers/Props'; import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; import styles from './VirtualTable.css'; +const ROW_HEIGHT = 38; + function overscanIndicesGetter(options) { const { cellCount, @@ -48,8 +50,7 @@ class VirtualTable extends Component { const { items, scrollIndex, - scrollTop, - onRecompute + scrollTop } = this.props; const { @@ -57,10 +58,7 @@ class VirtualTable extends Component { scrollRestored } = this.state; - if (this._grid && - (prevState.width !== width || - hasDifferentItemsOrOrder(prevProps.items, items))) { - onRecompute(width); + if (this._grid && (prevState.width !== width || hasDifferentItemsOrOrder(prevProps.items, items))) { // recomputeGridSize also forces Grid to discard its cache of rendered cells this._grid.recomputeGridSize(); } @@ -103,7 +101,6 @@ class VirtualTable extends Component { className, items, scroller, - scrollTop: ignored, header, headerHeight, rowHeight, @@ -149,6 +146,7 @@ class VirtualTable extends Component { {header}
@@ -192,16 +189,14 @@ VirtualTable.propTypes = { scroller: PropTypes.instanceOf(Element).isRequired, header: PropTypes.node.isRequired, headerHeight: PropTypes.number.isRequired, - rowHeight: PropTypes.oneOfType([PropTypes.func, PropTypes.number]).isRequired, rowRenderer: PropTypes.func.isRequired, - onRecompute: PropTypes.func.isRequired + rowHeight: PropTypes.number.isRequired }; VirtualTable.defaultProps = { className: styles.tableContainer, headerHeight: 38, - rowHeight: 38, - onRecompute: () => {} + rowHeight: ROW_HEIGHT }; export default VirtualTable; diff --git a/frontend/src/Components/Table/VirtualTableHeaderCell.css b/frontend/src/Components/Table/VirtualTableHeaderCell.css index c2c4f58c8..eded9c95b 100644 --- a/frontend/src/Components/Table/VirtualTableHeaderCell.css +++ b/frontend/src/Components/Table/VirtualTableHeaderCell.css @@ -9,7 +9,7 @@ margin-left: 10px; } -@media only screen and (max-width: $breakpointSmall) { +@media only screen and (max-width: $breakpointMedium) { .headerCell { white-space: nowrap; } diff --git a/frontend/src/Components/TagList.js b/frontend/src/Components/TagList.js index 6da96849c..fe700b8fe 100644 --- a/frontend/src/Components/TagList.js +++ b/frontend/src/Components/TagList.js @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import { kinds } from 'Helpers/Props'; +import sortByProp from 'Utilities/Array/sortByProp'; import Label from './Label'; import styles from './TagList.css'; @@ -8,7 +9,7 @@ function TagList({ tags, tagList }) { const sortedTags = tags .map((tagId) => tagList.find((tag) => tag.id === tagId)) .filter((tag) => !!tag) - .sort((a, b) => a.label.localeCompare(b.label)); + .sort(sortByProp('label')); return (
diff --git a/frontend/src/Components/withScrollPosition.tsx b/frontend/src/Components/withScrollPosition.tsx index ec13c6ab8..f688a6253 100644 --- a/frontend/src/Components/withScrollPosition.tsx +++ b/frontend/src/Components/withScrollPosition.tsx @@ -1,24 +1,30 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; import scrollPositions from 'Store/scrollPositions'; -function withScrollPosition(WrappedComponent, scrollPositionKey) { - function ScrollPosition(props) { +interface WrappedComponentProps { + initialScrollTop: number; +} + +interface ScrollPositionProps { + history: RouteComponentProps['history']; + location: RouteComponentProps['location']; + match: RouteComponentProps['match']; +} + +function withScrollPosition( + WrappedComponent: React.FC, + scrollPositionKey: string +) { + function ScrollPosition(props: ScrollPositionProps) { const { history } = props; const initialScrollTop = - history.action === 'POP' || - (history.location.state && history.location.state.restoreScrollPosition) - ? scrollPositions[scrollPositionKey] - : 0; + history.action === 'POP' ? scrollPositions[scrollPositionKey] : 0; return ; } - ScrollPosition.propTypes = { - history: PropTypes.object.isRequired, - }; - return ScrollPosition; } diff --git a/frontend/src/Content/Fonts/fonts.css b/frontend/src/Content/Fonts/fonts.css index bf31501dd..e0f1bf5dc 100644 --- a/frontend/src/Content/Fonts/fonts.css +++ b/frontend/src/Content/Fonts/fonts.css @@ -25,14 +25,3 @@ font-family: 'Ubuntu Mono'; src: url('UbuntuMono-Regular.eot?#iefix&v=1.3.0') format('embedded-opentype'), url('UbuntuMono-Regular.woff?v=1.3.0') format('woff'), url('UbuntuMono-Regular.ttf?v=1.3.0') format('truetype'); } - -/* - * text-security-disc - */ - -@font-face { - font-weight: normal; - font-style: normal; - font-family: 'text-security-disc'; - src: url('text-security-disc.woff?v=1.3.0') format('woff'), url('text-security-disc.ttf?v=1.3.0') format('truetype'); -} diff --git a/frontend/src/Content/Fonts/text-security-disc.ttf b/frontend/src/Content/Fonts/text-security-disc.ttf deleted file mode 100644 index 86038dba8..000000000 Binary files a/frontend/src/Content/Fonts/text-security-disc.ttf and /dev/null differ diff --git a/frontend/src/Content/Fonts/text-security-disc.woff b/frontend/src/Content/Fonts/text-security-disc.woff deleted file mode 100644 index bc4cc324b..000000000 Binary files a/frontend/src/Content/Fonts/text-security-disc.woff and /dev/null differ diff --git a/frontend/src/Content/Images/Icons/browserconfig.xml b/frontend/src/Content/Images/Icons/browserconfig.xml deleted file mode 100644 index 993924968..000000000 --- a/frontend/src/Content/Images/Icons/browserconfig.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - #00ccff - - - diff --git a/frontend/src/Content/Images/Icons/manifest.json b/frontend/src/Content/Images/Icons/manifest.json deleted file mode 100644 index a7ffa6777..000000000 --- a/frontend/src/Content/Images/Icons/manifest.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "Lidarr", - "icons": [ - { - "src": "android-chrome-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "android-chrome-512x512.png", - "sizes": "512x512", - "type": "image/png" - } - ], - "start_url": "../../../../", - "theme_color": "#3a3f51", - "background_color": "#3a3f51", - "display": "minimal-ui" -} diff --git a/frontend/src/Content/browserconfig.xml b/frontend/src/Content/browserconfig.xml new file mode 100644 index 000000000..646112d06 --- /dev/null +++ b/frontend/src/Content/browserconfig.xml @@ -0,0 +1,11 @@ + + + + + + + #00ccff + + + + diff --git a/frontend/src/Content/manifest.json b/frontend/src/Content/manifest.json new file mode 100644 index 000000000..5c2b3d59d --- /dev/null +++ b/frontend/src/Content/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "__INSTANCE_NAME__", + "icons": [ + { + "src": "__URL_BASE__/Content/Images/Icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "__URL_BASE__/Content/Images/Icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "start_url": "__URL_BASE__/", + "theme_color": "#3a3f51", + "background_color": "#3a3f51", + "display": "standalone" +} diff --git a/frontend/src/Helpers/Hooks/useModalOpenState.ts b/frontend/src/Helpers/Hooks/useModalOpenState.ts new file mode 100644 index 000000000..f5b5a96f0 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useModalOpenState.ts @@ -0,0 +1,17 @@ +import { useCallback, useState } from 'react'; + +export default function useModalOpenState( + initialState: boolean +): [boolean, () => void, () => void] { + const [isOpen, setOpen] = useState(initialState); + + const setModalOpen = useCallback(() => { + setOpen(true); + }, [setOpen]); + + const setModalClosed = useCallback(() => { + setOpen(false); + }, [setOpen]); + + return [isOpen, setModalOpen, setModalClosed]; +} diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js index b19d16c8a..005ea0b7a 100644 --- a/frontend/src/Helpers/Props/filterBuilderValueTypes.js +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js @@ -5,6 +5,7 @@ export const DEFAULT = 'default'; export const HISTORY_EVENT_TYPE = 'historyEventType'; export const INDEXER = 'indexer'; export const METADATA_PROFILE = 'metadataProfile'; +export const MONITOR_NEW_ITEMS = 'monitorNewItems'; export const PROTOCOL = 'protocol'; export const QUALITY = 'quality'; export const QUALITY_PROFILE = 'qualityProfile'; diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 77803e56e..aa9c23145 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -32,6 +32,7 @@ import { faBookReader as fasBookReader, faBroadcastTower as fasBroadcastTower, faBug as fasBug, + faCalculator as fasCalculator, faCalendarAlt as fasCalendarAlt, faCaretDown as fasCaretDown, faCheck as fasCheck, @@ -60,6 +61,7 @@ import { faFileImport as fasFileImport, faFileInvoice as farFileInvoice, faFilter as fasFilter, + faFlag as fasFlag, faFolderOpen as fasFolderOpen, faForward as fasForward, faHeart as fasHeart, @@ -158,6 +160,7 @@ export const FILE = farFile; export const FILE_IMPORT = fasFileImport; export const FILE_MISSING = fasFileCircleQuestion; export const FILTER = fasFilter; +export const FLAG = fasFlag; export const FOLDER = farFolder; export const FOLDER_OPEN = fasFolderOpen; export const GROUP = farObjectGroup; @@ -185,6 +188,7 @@ export const PAGE_PREVIOUS = fasBackward; export const PAGE_NEXT = fasForward; export const PAGE_LAST = fasFastForward; export const PARENT = fasLevelUpAlt; +export const PARSE = fasCalculator; export const PAUSED = fasPause; export const PENDING = farClock; export const PROFILE = fasUser; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index 8ebbd540b..44115c787 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -2,8 +2,8 @@ export const AUTO_COMPLETE = 'autoComplete'; export const CAPTCHA = 'captcha'; export const CHECK = 'check'; export const DEVICE = 'device'; -export const PLAYLIST = 'playlist'; export const KEY_VALUE_LIST = 'keyValueList'; +export const PLAYLIST = 'playlist'; export const MONITOR_ALBUMS_SELECT = 'monitorAlbumsSelect'; export const MONITOR_NEW_ITEMS_SELECT = 'monitorNewItemsSelect'; export const FLOAT = 'float'; @@ -15,10 +15,12 @@ export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; export const METADATA_PROFILE_SELECT = 'metadataProfileSelect'; export const ALBUM_RELEASE_SELECT = 'albumReleaseSelect'; export const INDEXER_SELECT = 'indexerSelect'; +export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect'; export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const SELECT = 'select'; export const SERIES_TYPE_SELECT = 'artistTypeSelect'; +export const ARTIST_TAG = 'artistTag'; export const DYNAMIC_SELECT = 'dynamicSelect'; export const TAG = 'tag'; export const TAG_SELECT = 'tagSelect'; @@ -32,8 +34,8 @@ export const all = [ CAPTCHA, CHECK, DEVICE, - PLAYLIST, KEY_VALUE_LIST, + PLAYLIST, MONITOR_ALBUMS_SELECT, MONITOR_NEW_ITEMS_SELECT, FLOAT, @@ -48,6 +50,7 @@ export const all = [ DOWNLOAD_CLIENT_SELECT, ROOT_FOLDER_SELECT, SELECT, + ARTIST_TAG, DYNAMIC_SELECT, SERIES_TYPE_SELECT, TAG, diff --git a/frontend/src/Helpers/Props/sizes.js b/frontend/src/Helpers/Props/sizes.js index d7f85df5e..6ac15f3bd 100644 --- a/frontend/src/Helpers/Props/sizes.js +++ b/frontend/src/Helpers/Props/sizes.js @@ -3,5 +3,5 @@ export const SMALL = 'small'; export const MEDIUM = 'medium'; export const LARGE = 'large'; export const EXTRA_LARGE = 'extraLarge'; - -export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE]; +export const EXTRA_EXTRA_LARGE = 'extraExtraLarge'; +export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE, EXTRA_EXTRA_LARGE]; diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js b/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js index 91c284636..f8c84e54b 100644 --- a/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js +++ b/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js @@ -11,6 +11,7 @@ import Scroller from 'Components/Scroller/Scroller'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import { scrollDirections } from 'Helpers/Props'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; import translate from 'Utilities/String/translate'; import SelectAlbumRow from './SelectAlbumRow'; import styles from './SelectAlbumModalContent.css'; @@ -19,6 +20,7 @@ const columns = [ { name: 'title', label: () => translate('AlbumTitle'), + isSortable: true, isVisible: true }, { @@ -29,6 +31,7 @@ const columns = [ { name: 'releaseDate', label: () => translate('ReleaseDate'), + isSortable: true, isVisible: true }, { @@ -55,7 +58,7 @@ class SelectAlbumModalContent extends Component { // Listeners onFilterChange = ({ value }) => { - this.setState({ filter: value.toLowerCase() }); + this.setState({ filter: value }); }; // @@ -63,14 +66,21 @@ class SelectAlbumModalContent extends Component { render() { const { - items, - onAlbumSelect, - onModalClose, isFetching, - ...otherProps + isPopulated, + error, + items, + sortKey, + sortDirection, + onSortPress, + onAlbumSelect, + onModalClose } = this.props; const filter = this.state.filter; + const filterLower = filter.toLowerCase(); + + const errorMessage = getErrorMessage(error, 'Unable to load albums'); return ( @@ -82,32 +92,34 @@ class SelectAlbumModalContent extends Component { className={styles.modalBody} scrollDirection={scrollDirections.NONE} > - { - isFetching && - - } - - - { + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + + + {isPopulated && !!items.length ? ( { items.map((item) => { - return item.title.toLowerCase().includes(filter) ? + return item.title.toLowerCase().includes(filterLower) ? (
- } + ) : null}
@@ -136,8 +148,13 @@ class SelectAlbumModalContent extends Component { } SelectAlbumModalContent.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.string, + onSortPress: PropTypes.func.isRequired, onAlbumSelect: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js index 12cd88e53..d09da0fca 100644 --- a/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js +++ b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js @@ -3,18 +3,14 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { - clearInteractiveImportAlbums, - fetchInteractiveImportAlbums, - saveInteractiveImportItem, - setInteractiveImportAlbumsSort, - updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; +import { clearAlbums, fetchAlbums, setAlbumsSort } from 'Store/Actions/albumSelectionActions'; +import { saveInteractiveImportItem, updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import SelectAlbumModalContent from './SelectAlbumModalContent'; function createMapStateToProps() { return createSelector( - createClientSideCollectionSelector('interactiveImport.albums'), + createClientSideCollectionSelector('albumSelection'), (albums) => { return albums; } @@ -22,9 +18,9 @@ function createMapStateToProps() { } const mapDispatchToProps = { - fetchInteractiveImportAlbums, - setInteractiveImportAlbumsSort, - clearInteractiveImportAlbums, + fetchAlbums, + setAlbumsSort, + clearAlbums, updateInteractiveImportItem, saveInteractiveImportItem }; @@ -39,20 +35,20 @@ class SelectAlbumModalContentConnector extends Component { artistId } = this.props; - this.props.fetchInteractiveImportAlbums({ artistId }); + this.props.fetchAlbums({ artistId }); } componentWillUnmount() { // This clears the albums for the queue and hides the queue // We'll need another place to store albums for manual import - this.props.clearInteractiveImportAlbums(); + this.props.clearAlbums(); } // // Listeners onSortPress = (sortKey, sortDirection) => { - this.props.setInteractiveImportAlbumsSort({ sortKey, sortDirection }); + this.props.setAlbumsSort({ sortKey, sortDirection }); }; onAlbumSelect = (albumId) => { @@ -82,6 +78,7 @@ class SelectAlbumModalContentConnector extends Component { return ( ); @@ -92,9 +89,9 @@ SelectAlbumModalContentConnector.propTypes = { ids: PropTypes.arrayOf(PropTypes.number).isRequired, artistId: PropTypes.number.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchInteractiveImportAlbums: PropTypes.func.isRequired, - setInteractiveImportAlbumsSort: PropTypes.func.isRequired, - clearInteractiveImportAlbums: PropTypes.func.isRequired, + fetchAlbums: PropTypes.func.isRequired, + setAlbumsSort: PropTypes.func.isRequired, + clearAlbums: PropTypes.func.isRequired, saveInteractiveImportItem: PropTypes.func.isRequired, updateInteractiveImportItem: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js index d9301cdfa..26fa7282a 100644 --- a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js @@ -1,10 +1,9 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import FormInputGroup from 'Components/Form/FormInputGroup'; +import SelectInput from 'Components/Form/SelectInput'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; -import { inputTypes } from 'Helpers/Props'; import shortenList from 'Utilities/String/shortenList'; import titleCase from 'Utilities/String/titleCase'; @@ -56,8 +55,7 @@ class SelectAlbumReleaseRow extends Component { if (name === 'release') { return ( - ({ key: r.id, diff --git a/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.js b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.js index 2dceb979e..14c42a80b 100644 --- a/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.js +++ b/frontend/src/InteractiveImport/Artist/SelectArtistModalContent.js @@ -29,7 +29,7 @@ class SelectArtistModalContent extends Component { // Listeners onFilterChange = ({ value }) => { - this.setState({ filter: value.toLowerCase() }); + this.setState({ filter: value }); }; // @@ -43,6 +43,7 @@ class SelectArtistModalContent extends Component { } = this.props; const filter = this.state.filter; + const filterLower = filter.toLowerCase(); return ( @@ -69,7 +70,7 @@ class SelectArtistModalContent extends Component { > { items.map((item) => { - return item.artistName.toLowerCase().includes(filter) ? + return item.artistName.toLowerCase().includes(filterLower) ? ( + + + ); + } +} + +SelectIndexerFlagsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectIndexerFlagsModal; diff --git a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css new file mode 100644 index 000000000..72dfb1cb6 --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css @@ -0,0 +1,7 @@ +.modalBody { + composes: modalBody from '~Components/Modal/ModalBody.css'; + + display: flex; + flex: 1 1 auto; + flex-direction: column; +} diff --git a/frontend/src/Components/Form/PasswordInput.css.d.ts b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts similarity index 87% rename from frontend/src/Components/Form/PasswordInput.css.d.ts rename to frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts index 774807ef4..3fc49a060 100644 --- a/frontend/src/Components/Form/PasswordInput.css.d.ts +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts @@ -1,7 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'input': string; + 'modalBody': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.js b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.js new file mode 100644 index 000000000..b30f76775 --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.js @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds, scrollDirections } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './SelectIndexerFlagsModalContent.css'; + +class SelectIndexerFlagsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + indexerFlags + } = props; + + this.state = { + indexerFlags + }; + } + + // + // Listeners + + onIndexerFlagsChange = ({ value }) => { + this.setState({ indexerFlags: value }); + }; + + onIndexerFlagsSelect = () => { + this.props.onIndexerFlagsSelect(this.state); + }; + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + indexerFlags + } = this.state; + + return ( + + + Manual Import - Set indexer Flags + + + + + + + {translate('IndexerFlags')} + + + + + + + + + + + + + + ); + } +} + +SelectIndexerFlagsModalContent.propTypes = { + indexerFlags: PropTypes.number.isRequired, + onIndexerFlagsSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectIndexerFlagsModalContent; diff --git a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContentConnector.js b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContentConnector.js new file mode 100644 index 000000000..7a9af7353 --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContentConnector.js @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { saveInteractiveImportItem, updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions'; +import SelectIndexerFlagsModalContent from './SelectIndexerFlagsModalContent'; + +const mapDispatchToProps = { + dispatchUpdateInteractiveImportItems: updateInteractiveImportItems, + dispatchSaveInteractiveImportItems: saveInteractiveImportItem +}; + +class SelectIndexerFlagsModalContentConnector extends Component { + + // + // Listeners + + onIndexerFlagsSelect = ({ indexerFlags }) => { + const { + ids, + dispatchUpdateInteractiveImportItems, + dispatchSaveInteractiveImportItems + } = this.props; + + dispatchUpdateInteractiveImportItems({ + ids, + indexerFlags + }); + + dispatchSaveInteractiveImportItems({ ids }); + + this.props.onModalClose(true); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +SelectIndexerFlagsModalContentConnector.propTypes = { + ids: PropTypes.arrayOf(PropTypes.number).isRequired, + dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired, + dispatchSaveInteractiveImportItems: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(null, mapDispatchToProps)(SelectIndexerFlagsModalContentConnector); diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css index 573b16667..93d815c9c 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css @@ -18,12 +18,17 @@ .leftButtons, .rightButtons { display: flex; - flex: 1 0 50%; flex-wrap: wrap; + min-width: 0; +} + +.leftButtons { + flex: 0 1 auto; } .rightButtons { justify-content: flex-end; + flex: 1 1 50%; } .importMode, @@ -31,6 +36,7 @@ composes: select from '~Components/Form/SelectInput.css'; margin-right: 10px; + max-width: 100%; width: auto; } @@ -43,10 +49,12 @@ .leftButtons, .rightButtons { flex-direction: column; + gap: 3px; } .leftButtons { align-items: flex-start; + max-width: fit-content; } .rightButtons { diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js index d1361a785..d980e77ce 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -20,6 +20,7 @@ import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal'; import SelectAlbumReleaseModal from 'InteractiveImport/AlbumRelease/SelectAlbumReleaseModal'; import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal'; import ConfirmImportModal from 'InteractiveImport/Confirmation/ConfirmImportModal'; +import SelectIndexerFlagsModal from 'InteractiveImport/IndexerFlags/SelectIndexerFlagsModal'; import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; @@ -30,7 +31,7 @@ import toggleSelected from 'Utilities/Table/toggleSelected'; import InteractiveImportRow from './InteractiveImportRow'; import styles from './InteractiveImportModalContent.css'; -const columns = [ +const COLUMNS = [ { name: 'path', label: () => translate('Path'), @@ -79,11 +80,21 @@ const columns = [ isSortable: true, isVisible: true }, + { + name: 'indexerFlags', + label: React.createElement(Icon, { + name: icons.FLAG, + title: () => translate('IndexerFlags') + }), + isSortable: true, + isVisible: true + }, { name: 'rejections', label: React.createElement(Icon, { name: icons.DANGER, - kind: kinds.DANGER + kind: kinds.DANGER, + title: () => translate('Rejections') }), isSortable: true, isVisible: true @@ -107,6 +118,7 @@ const ALBUM = 'album'; const ALBUM_RELEASE = 'albumRelease'; const RELEASE_GROUP = 'releaseGroup'; const QUALITY = 'quality'; +const INDEXER_FLAGS = 'indexerFlags'; const replaceExistingFilesOptions = { COMBINE: 'combine', @@ -301,6 +313,21 @@ class InteractiveImportModalContent extends Component { inconsistentAlbumReleases } = this.state; + const allColumns = _.cloneDeep(COLUMNS); + const columns = allColumns.map((column) => { + const showIndexerFlags = items.some((item) => item.indexerFlags); + + if (!showIndexerFlags) { + const indexerFlagsColumn = allColumns.find((c) => c.name === 'indexerFlags'); + + if (indexerFlagsColumn) { + indexerFlagsColumn.isVisible = false; + } + } + + return column; + }); + const selectedIds = this.getSelectedIds(); const selectedItem = selectedIds.length ? _.find(items, { id: selectedIds[0] }) : null; const errorMessage = getErrorMessage(error, 'Unable to load manual import items'); @@ -310,7 +337,8 @@ class InteractiveImportModalContent extends Component { { key: ALBUM, value: translate('SelectAlbum') }, { key: ALBUM_RELEASE, value: translate('SelectAlbumRelease') }, { key: QUALITY, value: translate('SelectQuality') }, - { key: RELEASE_GROUP, value: translate('SelectReleaseGroup') } + { key: RELEASE_GROUP, value: translate('SelectReleaseGroup') }, + { key: INDEXER_FLAGS, value: translate('SelectIndexerFlags') } ]; if (allowArtistChange) { @@ -433,6 +461,7 @@ class InteractiveImportModalContent extends Component { isSaving={isSaving} {...item} allowArtistChange={allowArtistChange} + columns={columns} onSelectedChange={this.onSelectedChange} onValidRowChange={this.onValidRowChange} /> @@ -547,6 +576,13 @@ class InteractiveImportModalContent extends Component { onModalClose={this.onSelectModalClose} /> + + 0 ) { this.props.onSelectedChange({ id, value: true }); } @@ -130,6 +135,10 @@ class InteractiveImportRow extends Component { this.setState({ isSelectQualityModalOpen: true }); }; + onSelectIndexerFlagsPress = () => { + this.setState({ isSelectIndexerFlagsModalOpen: true }); + }; + onSelectArtistModalClose = (changed) => { this.setState({ isSelectArtistModalOpen: false }); this.selectRowAfterChange(changed); @@ -155,6 +164,11 @@ class InteractiveImportRow extends Component { this.selectRowAfterChange(changed); }; + onSelectIndexerFlagsModalClose = (changed) => { + this.setState({ isSelectIndexerFlagsModalOpen: false }); + this.selectRowAfterChange(changed); + }; + // // Render @@ -171,7 +185,9 @@ class InteractiveImportRow extends Component { releaseGroup, size, customFormats, + indexerFlags, rejections, + columns, isReprocessing, audioTags, additionalFile, @@ -184,7 +200,8 @@ class InteractiveImportRow extends Component { isSelectAlbumModalOpen, isSelectTrackModalOpen, isSelectReleaseGroupModalOpen, - isSelectQualityModalOpen + isSelectQualityModalOpen, + isSelectIndexerFlagsModalOpen } = this.state; const artistName = artist ? artist.artistName : ''; @@ -204,6 +221,7 @@ class InteractiveImportRow extends Component { const showTrackNumbersLoading = isReprocessing && !tracks.length; const showReleaseGroupPlaceholder = isSelected && !releaseGroup; const showQualityPlaceholder = isSelected && !quality; + const showIndexerFlagsPlaceholder = isSelected && !indexerFlags; const pathCellContents = (
@@ -219,6 +237,8 @@ class InteractiveImportRow extends Component { /> ) : pathCellContents; + const isIndexerFlagsColumnVisible = columns.find((c) => c.name === 'indexerFlags')?.isVisible ?? false; + return ( + {isIndexerFlagsColumnVisible ? ( + + {showIndexerFlagsPlaceholder ? ( + + ) : ( + <> + {indexerFlags ? ( + } + title={translate('IndexerFlags')} + body={} + position={tooltipPositions.LEFT} + /> + ) : null} + + )} + + ) : null} + { rejections.length ? @@ -395,6 +437,13 @@ class InteractiveImportRow extends Component { real={quality ? quality.revision.real > 0 : false} onModalClose={this.onSelectQualityModalClose} /> + + ); } @@ -413,7 +462,9 @@ InteractiveImportRow.propTypes = { quality: PropTypes.object, size: PropTypes.number.isRequired, customFormats: PropTypes.arrayOf(PropTypes.object), + indexerFlags: PropTypes.number.isRequired, rejections: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, audioTags: PropTypes.object.isRequired, additionalFile: PropTypes.bool.isRequired, isReprocessing: PropTypes.bool, diff --git a/frontend/src/InteractiveImport/InteractiveImportModal.js b/frontend/src/InteractiveImport/InteractiveImportModal.js index dda75152f..4d0e9f2f1 100644 --- a/frontend/src/InteractiveImport/InteractiveImportModal.js +++ b/frontend/src/InteractiveImport/InteractiveImportModal.js @@ -48,7 +48,7 @@ class InteractiveImportModal extends Component { return ( diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.js b/frontend/src/InteractiveSearch/InteractiveSearch.js index 6e74695b0..64d1ce730 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearch.js +++ b/frontend/src/InteractiveSearch/InteractiveSearch.js @@ -65,6 +65,15 @@ const columns = [ isSortable: true, isVisible: true }, + { + name: 'indexerFlags', + label: React.createElement(Icon, { + name: icons.FLAG, + title: () => translate('IndexerFlags') + }), + isSortable: true, + isVisible: true + }, { name: 'rejections', label: React.createElement(Icon, { diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css index ffea82600..dad7242c8 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css @@ -35,6 +35,7 @@ } .rejected, +.indexerFlags, .download { composes: cell from '~Components/Table/Cells/TableRowCell.css'; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts index ca01c5ee6..bec6dcf78 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts @@ -5,6 +5,7 @@ interface CssExports { 'customFormatScore': string; 'download': string; 'indexer': string; + 'indexerFlags': string; 'peers': string; 'protocol': string; 'quality': string; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js index 3029d2d2f..a139f8085 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.js +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import AlbumFormats from 'Album/AlbumFormats'; +import IndexerFlags from 'Album/IndexerFlags'; import TrackQuality from 'Album/TrackQuality'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; @@ -48,12 +49,12 @@ function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { if (isGrabbing) { return ''; } else if (isGrabbed) { - return 'Added to downloaded queue'; + return translate('AddedToDownloadQueue'); } else if (grabError) { return grabError; } - return 'Add to downloaded queue'; + return translate('AddToDownloadQueue'); } class InteractiveSearchRow extends Component { @@ -129,6 +130,7 @@ class InteractiveSearchRow extends Component { quality, customFormatScore, customFormats, + indexerFlags = 0, rejections, downloadAllowed, isGrabbing, @@ -187,10 +189,21 @@ class InteractiveSearchRow extends Component { formatCustomFormatScore(customFormatScore, customFormats.length) } tooltip={} - position={tooltipPositions.BOTTOM} + position={tooltipPositions.LEFT} /> + + {indexerFlags ? ( + } + title={translate('IndexerFlags')} + body={} + position={tooltipPositions.LEFT} + /> + ) : null} + + { !!rejections.length && @@ -236,7 +249,9 @@ class InteractiveSearchRow extends Component { isOpen={this.state.isConfirmGrabModalOpen} kind={kinds.WARNING} title={translate('GrabRelease')} - message={translate('GrabReleaseMessageText', [title])} + message={translate('GrabReleaseUnknownArtistOrAlbumMessageText', { + title + })} confirmLabel={translate('Grab')} onConfirm={this.onGrabConfirm} onCancel={this.onGrabCancel} @@ -263,6 +278,7 @@ InteractiveSearchRow.propTypes = { quality: PropTypes.object.isRequired, customFormats: PropTypes.arrayOf(PropTypes.object), customFormatScore: PropTypes.number.isRequired, + indexerFlags: PropTypes.number.isRequired, rejections: PropTypes.arrayOf(PropTypes.string).isRequired, downloadAllowed: PropTypes.bool.isRequired, isGrabbing: PropTypes.bool.isRequired, @@ -275,6 +291,7 @@ InteractiveSearchRow.propTypes = { }; InteractiveSearchRow.defaultProps = { + indexerFlags: 0, rejections: [], isGrabbing: false, isGrabbed: false diff --git a/frontend/src/Parse/Parse.css b/frontend/src/Parse/Parse.css new file mode 100644 index 000000000..43536452c --- /dev/null +++ b/frontend/src/Parse/Parse.css @@ -0,0 +1,45 @@ +.inputContainer { + display: flex; + margin-bottom: 10px; +} + +.inputIconContainer { + width: 58px; + height: 46px; + border: 1px solid var(--inputBorderColor); + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: var(--inputIconContainerBackgroundColor); + text-align: center; + line-height: 46px; +} + +.input { + composes: input from '~Components/Form/TextInput.css'; + + height: 46px; + border-radius: 0; + font-size: 18px; +} + +.clearButton { + border: 1px solid var(--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; + font-weight: 300; + font-size: $largeFontSize; +} + +.helpText { + margin-bottom: 10px; + font-size: 24px; +} diff --git a/frontend/src/Parse/Parse.css.d.ts b/frontend/src/Parse/Parse.css.d.ts new file mode 100644 index 000000000..4a4def577 --- /dev/null +++ b/frontend/src/Parse/Parse.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'clearButton': string; + 'helpText': string; + 'input': string; + 'inputContainer': string; + 'inputIconContainer': string; + 'message': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Parse/Parse.tsx b/frontend/src/Parse/Parse.tsx new file mode 100644 index 000000000..15a0deb47 --- /dev/null +++ b/frontend/src/Parse/Parse.tsx @@ -0,0 +1,109 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import TextInput from 'Components/Form/TextInput'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import { icons } from 'Helpers/Props'; +import { clear, fetch } from 'Store/Actions/parseActions'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import ParseResult from './ParseResult'; +import parseStateSelector from './parseStateSelector'; +import styles from './Parse.css'; + +function Parse() { + const { isFetching, error, item } = useSelector(parseStateSelector()); + + const [title, setTitle] = useState(''); + const dispatch = useDispatch(); + + const onInputChange = useCallback( + ({ value }: { value: string }) => { + const trimmedValue = value.trim(); + + setTitle(value); + + if (trimmedValue === '') { + dispatch(clear()); + } else { + dispatch(fetch({ title: trimmedValue })); + } + }, + [setTitle, dispatch] + ); + + const onClearPress = useCallback(() => { + setTitle(''); + dispatch(clear()); + }, [setTitle, dispatch]); + + useEffect( + () => { + return () => { + dispatch(clear()); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( + + +
+
+ +
+ + + + +
+ + {isFetching ? : null} + + {!isFetching && !!error ? ( +
+
+ {translate('ParseModalErrorParsing')} +
+
{getErrorMessage(error)}
+
+ ) : null} + + {!isFetching && title && !error && !item.parsedAlbumInfo ? ( +
+ {translate('ParseModalUnableToParse')} +
+ ) : null} + + {!isFetching && !error && item.parsedAlbumInfo ? ( + + ) : null} + + {title ? null : ( +
+
+ {translate('ParseModalHelpText')} +
+
{translate('ParseModalHelpTextDetails')}
+
+ )} +
+
+ ); +} + +export default Parse; diff --git a/frontend/src/Parse/ParseModal.tsx b/frontend/src/Parse/ParseModal.tsx new file mode 100644 index 000000000..0ee455bf0 --- /dev/null +++ b/frontend/src/Parse/ParseModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ParseModalContent from './ParseModalContent'; + +interface ParseModalProps { + isOpen: boolean; + onModalClose: () => void; +} + +function ParseModal(props: ParseModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ParseModal; diff --git a/frontend/src/Parse/ParseModalContent.css b/frontend/src/Parse/ParseModalContent.css new file mode 100644 index 000000000..43536452c --- /dev/null +++ b/frontend/src/Parse/ParseModalContent.css @@ -0,0 +1,45 @@ +.inputContainer { + display: flex; + margin-bottom: 10px; +} + +.inputIconContainer { + width: 58px; + height: 46px; + border: 1px solid var(--inputBorderColor); + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: var(--inputIconContainerBackgroundColor); + text-align: center; + line-height: 46px; +} + +.input { + composes: input from '~Components/Form/TextInput.css'; + + height: 46px; + border-radius: 0; + font-size: 18px; +} + +.clearButton { + border: 1px solid var(--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; + font-weight: 300; + font-size: $largeFontSize; +} + +.helpText { + margin-bottom: 10px; + font-size: 24px; +} diff --git a/frontend/src/Parse/ParseModalContent.css.d.ts b/frontend/src/Parse/ParseModalContent.css.d.ts new file mode 100644 index 000000000..4a4def577 --- /dev/null +++ b/frontend/src/Parse/ParseModalContent.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'clearButton': string; + 'helpText': string; + 'input': string; + 'inputContainer': string; + 'inputIconContainer': string; + 'message': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Parse/ParseModalContent.tsx b/frontend/src/Parse/ParseModalContent.tsx new file mode 100644 index 000000000..d5ae93759 --- /dev/null +++ b/frontend/src/Parse/ParseModalContent.tsx @@ -0,0 +1,122 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import TextInput from 'Components/Form/TextInput'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { icons } from 'Helpers/Props'; +import { clear, fetch } from 'Store/Actions/parseActions'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import ParseResult from './ParseResult'; +import parseStateSelector from './parseStateSelector'; +import styles from './ParseModalContent.css'; + +interface ParseModalContentProps { + onModalClose: () => void; +} + +function ParseModalContent(props: ParseModalContentProps) { + const { onModalClose } = props; + const { isFetching, error, item } = useSelector(parseStateSelector()); + + const [title, setTitle] = useState(''); + const dispatch = useDispatch(); + + const onInputChange = useCallback( + ({ value }: { value: string }) => { + const trimmedValue = value.trim(); + + setTitle(value); + + if (trimmedValue === '') { + dispatch(clear()); + } else { + dispatch(fetch({ title: trimmedValue })); + } + }, + [setTitle, dispatch] + ); + + const onClearPress = useCallback(() => { + setTitle(''); + dispatch(clear()); + }, [setTitle, dispatch]); + + useEffect( + () => { + return () => { + dispatch(clear()); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( + + {translate('TestParsing')} + + +
+
+ +
+ + + + +
+ + {isFetching ? : null} + + {!isFetching && !!error ? ( +
+
+ {translate('ParseModalErrorParsing')} +
+
{getErrorMessage(error)}
+
+ ) : null} + + {!isFetching && title && !error && !item.parsedAlbumInfo ? ( +
+ {translate('ParseModalUnableToParse')} +
+ ) : null} + + {!isFetching && !error && item.parsedAlbumInfo ? ( + + ) : null} + + {title ? null : ( +
+
+ {translate('ParseModalHelpText')} +
+
{translate('ParseModalHelpTextDetails')}
+
+ )} +
+ + + + +
+ ); +} + +export default ParseModalContent; diff --git a/frontend/src/Parse/ParseResult.css b/frontend/src/Parse/ParseResult.css new file mode 100644 index 000000000..c49c4e3fa --- /dev/null +++ b/frontend/src/Parse/ParseResult.css @@ -0,0 +1,8 @@ +.container { + display: flex; + flex-wrap: wrap; +} + +.column { + flex: 0 0 50%; +} diff --git a/frontend/src/Parse/ParseResult.css.d.ts b/frontend/src/Parse/ParseResult.css.d.ts new file mode 100644 index 000000000..653368e06 --- /dev/null +++ b/frontend/src/Parse/ParseResult.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'column': string; + 'container': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Parse/ParseResult.tsx b/frontend/src/Parse/ParseResult.tsx new file mode 100644 index 000000000..7e6c40d92 --- /dev/null +++ b/frontend/src/Parse/ParseResult.tsx @@ -0,0 +1,160 @@ +import _ from 'lodash'; +import moment from 'moment'; +import React from 'react'; +import AlbumFormats from 'Album/AlbumFormats'; +import AlbumTitleLink from 'Album/AlbumTitleLink'; +import { ParseModel } from 'App/State/ParseAppState'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import FieldSet from 'Components/FieldSet'; +import translate from 'Utilities/String/translate'; +import ParseResultItem from './ParseResultItem'; +import styles from './ParseResult.css'; + +interface ParseResultProps { + item: ParseModel; +} + +function ParseResult(props: ParseResultProps) { + const { item } = props; + const { customFormats, customFormatScore, albums, parsedAlbumInfo, artist } = + item; + + const { + releaseTitle, + artistName, + albumTitle, + releaseGroup, + discography, + quality, + } = parsedAlbumInfo; + + const sortedAlbums = _.sortBy(albums, (item) => + moment(item.releaseDate).unix() + ); + + return ( +
+
+ + + + + + + +
+ +
+
+
+ +
+
+
+ +
+
+
+ + 1 && !quality.revision.isRepack + ? translate('True') + : '-' + } + /> + + +
+ +
+ 1 ? quality.revision.version : '-' + } + /> + + +
+
+
+ +
+ + ) : ( + '-' + ) + } + /> + + + {sortedAlbums.map((album) => { + return ( +
+ +
+ ); + })} +
+ ) : ( + '-' + ) + } + /> + + + ) : ( + '-' + ) + } + /> + + + +
+ ); +} + +export default ParseResult; diff --git a/frontend/src/Parse/ParseResultItem.css b/frontend/src/Parse/ParseResultItem.css new file mode 100644 index 000000000..275fe7e1f --- /dev/null +++ b/frontend/src/Parse/ParseResultItem.css @@ -0,0 +1,21 @@ +.item { + display: flex; +} + +.title { + margin-right: 20px; + width: 250px; + text-align: right; + font-weight: bold; +} + +@media (max-width: $breakpointSmall) { + .item { + display: block; + margin-bottom: 10px; + } + + .title { + text-align: left; + } +} diff --git a/frontend/src/Parse/ParseResultItem.css.d.ts b/frontend/src/Parse/ParseResultItem.css.d.ts new file mode 100644 index 000000000..bcf268e50 --- /dev/null +++ b/frontend/src/Parse/ParseResultItem.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'item': string; + 'title': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Parse/ParseResultItem.tsx b/frontend/src/Parse/ParseResultItem.tsx new file mode 100644 index 000000000..661af448d --- /dev/null +++ b/frontend/src/Parse/ParseResultItem.tsx @@ -0,0 +1,20 @@ +import React, { ReactNode } from 'react'; +import styles from './ParseResultItem.css'; + +interface ParseResultItemProps { + title: string; + data: string | number | ReactNode; +} + +function ParseResultItem(props: ParseResultItemProps) { + const { title, data } = props; + + return ( +
+
{title}
+
{data}
+
+ ); +} + +export default ParseResultItem; diff --git a/frontend/src/Parse/ParseToolbarButton.tsx b/frontend/src/Parse/ParseToolbarButton.tsx new file mode 100644 index 000000000..43b8b959f --- /dev/null +++ b/frontend/src/Parse/ParseToolbarButton.tsx @@ -0,0 +1,31 @@ +import React, { Fragment, useCallback, useState } from 'react'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import { icons } from 'Helpers/Props'; +import ParseModal from 'Parse/ParseModal'; +import translate from 'Utilities/String/translate'; + +function ParseToolbarButton() { + const [isParseModalOpen, setIsParseModalOpen] = useState(false); + + const onOpenParseModalPress = useCallback(() => { + setIsParseModalOpen(true); + }, [setIsParseModalOpen]); + + const onParseModalClose = useCallback(() => { + setIsParseModalOpen(false); + }, [setIsParseModalOpen]); + + return ( + + + + + + ); +} + +export default ParseToolbarButton; diff --git a/frontend/src/Parse/parseStateSelector.ts b/frontend/src/Parse/parseStateSelector.ts new file mode 100644 index 000000000..7abcfeca1 --- /dev/null +++ b/frontend/src/Parse/parseStateSelector.ts @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import ParseAppState from 'App/State/ParseAppState'; + +export default function parseStateSelector() { + return createSelector( + (state: AppState) => state.parse, + (parse: ParseAppState) => { + return parse; + } + ); +} diff --git a/frontend/src/Search/AddNewItem.js b/frontend/src/Search/AddNewItem.js index 5ec065149..e335ef4c2 100644 --- a/frontend/src/Search/AddNewItem.js +++ b/frontend/src/Search/AddNewItem.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import TextInput from 'Components/Form/TextInput'; import Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; @@ -130,7 +131,8 @@ class AddNewItem extends Component {
{translate('FailedLoadingSearchResults')}
-
{getErrorMessage(error)}
+ + {getErrorMessage(error)}
: null } diff --git a/frontend/src/Settings/CustomFormats/CustomFormatSettingsConnector.js b/frontend/src/Settings/CustomFormats/CustomFormatSettingsConnector.js deleted file mode 100644 index 342df29d2..000000000 --- a/frontend/src/Settings/CustomFormats/CustomFormatSettingsConnector.js +++ /dev/null @@ -1,33 +0,0 @@ -import React, { Component } from 'react'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; -import translate from 'Utilities/String/translate'; -import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector'; - -class CustomFormatSettingsConnector extends Component { - - // - // Render - - render() { - return ( - - - - - - - - - - ); - } -} - -export default CustomFormatSettingsConnector; - diff --git a/frontend/src/Settings/CustomFormats/CustomFormatSettingsPage.tsx b/frontend/src/Settings/CustomFormats/CustomFormatSettingsPage.tsx new file mode 100644 index 000000000..66c208f9a --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormatSettingsPage.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import ParseToolbarButton from 'Parse/ParseToolbarButton'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import translate from 'Utilities/String/translate'; +import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector'; +import ManageCustomFormatsToolbarButton from './CustomFormats/Manage/ManageCustomFormatsToolbarButton'; + +function CustomFormatSettingsPage() { + return ( + + + + + + + + + } + /> + + + {/* TODO: Upgrade react-dnd to get typings, we're 2 major versions behind */} + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-ignore */} + + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-ignore */} + + + + + ); +} + +export default CustomFormatSettingsPage; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormatsConnector.js b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormatsConnector.js index 8e828620b..0417d9b21 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormatsConnector.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormatsConnector.js @@ -4,12 +4,12 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { cloneCustomFormat, deleteCustomFormat, fetchCustomFormats } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import CustomFormats from './CustomFormats'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.customFormats', sortByName), + createSortedSectionSelector('settings.customFormats', sortByProp('name')), (customFormats) => customFormats ); } diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalConnector.js b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalConnector.js index 52b2f09f6..3e79425cd 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalConnector.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalConnector.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { clearPendingChanges } from 'Store/Actions/baseActions'; import EditCustomFormatModal from './EditCustomFormatModal'; +import EditCustomFormatModalContentConnector from './EditCustomFormatModalContentConnector'; function mapStateToProps() { return {}; @@ -36,6 +37,7 @@ class EditCustomFormatModalConnector extends Component { } EditCustomFormatModalConnector.propTypes = { + ...EditCustomFormatModalContentConnector.propTypes, onModalClose: PropTypes.func.isRequired, clearPendingChanges: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css index b7d3da255..24830ef42 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css @@ -25,3 +25,8 @@ border-radius: 4px; background-color: var(--cardCenterBackgroundColor); } + +.customFormats { + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css.d.ts b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css.d.ts index 1339caf02..1aab6062e 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css.d.ts +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css.d.ts @@ -3,6 +3,7 @@ interface CssExports { 'addSpecification': string; 'center': string; + 'customFormats': string; 'deleteButton': string; 'rightButtons': string; } diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js index 4d8f0fd4b..8b06deb4b 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import Card from 'Components/Card'; import FieldSet from 'Components/FieldSet'; import Form from 'Components/Form/Form'; @@ -150,6 +151,11 @@ class EditCustomFormatModalContent extends Component {
+ +
+ {translate('CustomFormatsSettingsTriggerInfo')} +
+
{ specifications.map((tag) => { diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModal.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModal.tsx new file mode 100644 index 000000000..3ff5cfa37 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModal.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageCustomFormatsEditModalContent from './ManageCustomFormatsEditModalContent'; + +interface ManageCustomFormatsEditModalProps { + isOpen: boolean; + customFormatIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function ManageCustomFormatsEditModal( + props: ManageCustomFormatsEditModalProps +) { + const { isOpen, customFormatIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageCustomFormatsEditModal; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css @@ -0,0 +1,16 @@ +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + +.selected { + font-weight: bold; +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .modalFooter { + flex-direction: column; + gap: 10px; + } +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css.d.ts b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modalFooter': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.tsx new file mode 100644 index 000000000..25a2f85c2 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.tsx @@ -0,0 +1,125 @@ +import React, { useCallback, useState } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageCustomFormatsEditModalContent.css'; + +interface SavePayload { + includeCustomFormatWhenRenaming?: boolean; +} + +interface ManageCustomFormatsEditModalContentProps { + customFormatIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const enableOptions = [ + { + key: NO_CHANGE, + get value() { + return translate('NoChange'); + }, + isDisabled: true, + }, + { + key: 'enabled', + get value() { + return translate('Enabled'); + }, + }, + { + key: 'disabled', + get value() { + return translate('Disabled'); + }, + }, +]; + +function ManageCustomFormatsEditModalContent( + props: ManageCustomFormatsEditModalContentProps +) { + const { customFormatIds, onSavePress, onModalClose } = props; + + const [includeCustomFormatWhenRenaming, setIncludeCustomFormatWhenRenaming] = + useState(NO_CHANGE); + + const save = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (includeCustomFormatWhenRenaming !== NO_CHANGE) { + hasChanges = true; + payload.includeCustomFormatWhenRenaming = + includeCustomFormatWhenRenaming === 'enabled'; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, [includeCustomFormatWhenRenaming, onSavePress, onModalClose]); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'includeCustomFormatWhenRenaming': + setIncludeCustomFormatWhenRenaming(value); + break; + default: + console.warn( + `EditCustomFormatsModalContent Unknown Input: '${name}'` + ); + } + }, + [] + ); + + const selectedCount = customFormatIds.length; + + return ( + + {translate('EditSelectedCustomFormats')} + + + + {translate('IncludeCustomFormatWhenRenaming')} + + + + + + +
+ {translate('CountCustomFormatsSelected', { + count: selectedCount, + })} +
+ +
+ + + +
+
+
+ ); +} + +export default ManageCustomFormatsEditModalContent; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModal.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModal.tsx new file mode 100644 index 000000000..dd3456437 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageCustomFormatsModalContent from './ManageCustomFormatsModalContent'; + +interface ManageCustomFormatsModalProps { + isOpen: boolean; + onModalClose(): void; +} + +function ManageCustomFormatsModal(props: ManageCustomFormatsModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageCustomFormatsModal; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css new file mode 100644 index 000000000..6ea04a0c8 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css @@ -0,0 +1,16 @@ +.leftButtons, +.rightButtons { + display: flex; + flex: 1 0 50%; + flex-wrap: wrap; +} + +.rightButtons { + justify-content: flex-end; +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: 10px; +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css.d.ts b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css.d.ts new file mode 100644 index 000000000..7b392fff9 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; + 'leftButtons': string; + 'rightButtons': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.tsx new file mode 100644 index 000000000..aabaf67c1 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.tsx @@ -0,0 +1,244 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { CustomFormatAppState } from 'App/State/SettingsAppState'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import Column from 'Components/Table/Column'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { kinds } from 'Helpers/Props'; +import { + bulkDeleteCustomFormats, + bulkEditCustomFormats, + setManageCustomFormatsSort, +} from 'Store/Actions/settingsActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { SelectStateInputProps } from 'typings/props'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import ManageCustomFormatsEditModal from './Edit/ManageCustomFormatsEditModal'; +import ManageCustomFormatsModalRow from './ManageCustomFormatsModalRow'; +import styles from './ManageCustomFormatsModalContent.css'; + +// TODO: This feels janky to do, but not sure of a better way currently +type OnSelectedChangeCallback = React.ComponentProps< + typeof ManageCustomFormatsModalRow +>['onSelectedChange']; + +const COLUMNS: Column[] = [ + { + name: 'name', + label: () => translate('Name'), + isSortable: true, + isVisible: true, + }, + { + name: 'includeCustomFormatWhenRenaming', + label: () => translate('IncludeCustomFormatWhenRenaming'), + isSortable: true, + isVisible: true, + }, + { + name: 'actions', + label: '', + isVisible: true, + }, +]; + +interface ManageCustomFormatsModalContentProps { + onModalClose(): void; +} + +function ManageCustomFormatsModalContent( + props: ManageCustomFormatsModalContentProps +) { + const { onModalClose } = props; + + const { + isFetching, + isPopulated, + isDeleting, + isSaving, + error, + items, + sortKey, + sortDirection, + }: CustomFormatAppState = useSelector( + createClientSideCollectionSelector('settings.customFormats') + ); + const dispatch = useDispatch(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const [selectState, setSelectState] = useSelectState(); + + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds: number[] = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const selectedCount = selectedIds.length; + + const onSortPress = useCallback( + (value: string) => { + dispatch(setManageCustomFormatsSort({ sortKey: value })); + }, + [dispatch] + ); + + const onDeletePress = useCallback(() => { + setIsDeleteModalOpen(true); + }, [setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback(() => { + dispatch(bulkDeleteCustomFormats({ ids: selectedIds })); + setIsDeleteModalOpen(false); + }, [selectedIds, dispatch]); + + const onSavePress = useCallback( + (payload: object) => { + setIsEditModalOpen(false); + + dispatch( + bulkEditCustomFormats({ + ids: selectedIds, + ...payload, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onSelectAllChange = useCallback( + ({ value }: SelectStateInputProps) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const onSelectedChange = useCallback( + ({ id, value, shiftKey = false }) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const errorMessage = getErrorMessage(error, 'Unable to load custom formats.'); + const anySelected = selectedCount > 0; + + return ( + + {translate('ManageCustomFormats')} + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !error && !items.length ? ( + {translate('NoCustomFormatsFound')} + ) : null} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} +
+ + +
+ + {translate('Delete')} + + + + {translate('Edit')} + +
+ + +
+ + + + +
+ ); +} + +export default ManageCustomFormatsModalContent; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css new file mode 100644 index 000000000..355c70378 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css @@ -0,0 +1,12 @@ +.name, +.includeCustomFormatWhenRenaming { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} + +.actions { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 40px; +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css.d.ts b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css.d.ts new file mode 100644 index 000000000..d1719edd8 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'actions': string; + 'includeCustomFormatWhenRenaming': string; + 'name': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx new file mode 100644 index 000000000..57bb7fda0 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx @@ -0,0 +1,126 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import { icons } from 'Helpers/Props'; +import { deleteCustomFormat } from 'Store/Actions/settingsActions'; +import { SelectStateInputProps } from 'typings/props'; +import translate from 'Utilities/String/translate'; +import EditCustomFormatModalConnector from '../EditCustomFormatModalConnector'; +import styles from './ManageCustomFormatsModalRow.css'; + +interface ManageCustomFormatsModalRowProps { + id: number; + name: string; + includeCustomFormatWhenRenaming: boolean; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; +} + +function isDeletingSelector() { + return createSelector( + (state: AppState) => state.settings.customFormats.isDeleting, + (isDeleting) => { + return isDeleting; + } + ); +} + +function ManageCustomFormatsModalRow(props: ManageCustomFormatsModalRowProps) { + const { + id, + isSelected, + name, + includeCustomFormatWhenRenaming, + onSelectedChange, + } = props; + + const dispatch = useDispatch(); + const isDeleting = useSelector(isDeletingSelector()); + + const [isEditCustomFormatModalOpen, setIsEditCustomFormatModalOpen] = + useState(false); + + const [isDeleteCustomFormatModalOpen, setIsDeleteCustomFormatModalOpen] = + useState(false); + + const handlelectedChange = useCallback( + (result: SelectStateInputProps) => { + onSelectedChange({ + ...result, + }); + }, + [onSelectedChange] + ); + + const handleEditCustomFormatModalOpen = useCallback(() => { + setIsEditCustomFormatModalOpen(true); + }, [setIsEditCustomFormatModalOpen]); + + const handleEditCustomFormatModalClose = useCallback(() => { + setIsEditCustomFormatModalOpen(false); + }, [setIsEditCustomFormatModalOpen]); + + const handleDeleteCustomFormatPress = useCallback(() => { + setIsEditCustomFormatModalOpen(false); + setIsDeleteCustomFormatModalOpen(true); + }, [setIsEditCustomFormatModalOpen, setIsDeleteCustomFormatModalOpen]); + + const handleDeleteCustomFormatModalClose = useCallback(() => { + setIsDeleteCustomFormatModalOpen(false); + }, [setIsDeleteCustomFormatModalOpen]); + + const handleConfirmDeleteCustomFormat = useCallback(() => { + dispatch(deleteCustomFormat({ id })); + }, [id, dispatch]); + + return ( + + + + {name} + + + {includeCustomFormatWhenRenaming ? translate('Yes') : translate('No')} + + + + + + + + + + + ); +} + +export default ManageCustomFormatsModalRow; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsToolbarButton.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsToolbarButton.tsx new file mode 100644 index 000000000..91f41dc44 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsToolbarButton.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; +import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import ManageCustomFormatsModal from './ManageCustomFormatsModal'; + +function ManageCustomFormatsToolbarButton() { + const [isManageModalOpen, openManageModal, closeManageModal] = + useModalOpenState(false); + + return ( + <> + + + + + ); +} + +export default ManageCustomFormatsToolbarButton; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.js b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.js index d55bbcdf9..19aae4694 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.js @@ -7,8 +7,8 @@ import FormInputGroup from 'Components/Form/FormInputGroup'; import FormLabel from 'Components/Form/FormLabel'; import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; import Button from 'Components/Link/Button'; -import Link from 'Components/Link/Link'; import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; @@ -49,15 +49,16 @@ function EditSpecificationModalContent(props) { {...otherProps} > { - fields && fields.some((x) => x.label === 'Regular Expression') && + fields && fields.some((x) => x.label === translate('CustomFormatsSpecificationRegularExpression')) &&
-
\\^$.|?*+()[{ have special meanings and need escaping with a \\' }} /> - {'More details'} {'Here'} +
- {'Regular expressions can be tested '} - Here + +
+
+
} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js index d9e543469..0dc410fcb 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js @@ -5,12 +5,12 @@ import { createSelector } from 'reselect'; import { deleteDownloadClient, fetchDownloadClients } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import DownloadClients from './DownloadClients'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.downloadClients', sortByName), + createSortedSectionSelector('settings.downloadClients', sortByProp('name')), createTagsSelector(), (downloadClients, tagList) => { return { diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js index d3d51c7fc..8d7d994f7 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js @@ -15,6 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton'; import translate from 'Utilities/String/translate'; import styles from './EditDownloadClientModalContent.css'; @@ -37,6 +38,7 @@ class EditDownloadClientModalContent extends Component { onModalClose, onSavePress, onTestPress, + onAdvancedSettingsPress, onDeleteDownloadClientPress, ...otherProps } = this.props; @@ -206,6 +208,12 @@ class EditDownloadClientModalContent extends Component { } + + { + this.props.toggleAdvancedSettings(); + }; + // // Render @@ -65,6 +76,7 @@ class EditDownloadClientModalContentConnector extends Component { {...this.props} onSavePress={this.onSavePress} onTestPress={this.onTestPress} + onAdvancedSettingsPress={this.onAdvancedSettingsPress} onInputChange={this.onInputChange} onFieldChange={this.onFieldChange} /> @@ -82,6 +94,7 @@ EditDownloadClientModalContentConnector.propTypes = { setDownloadClientFieldValue: PropTypes.func.isRequired, saveDownloadClient: PropTypes.func.isRequired, testDownloadClient: PropTypes.func.isRequired, + toggleAdvancedSettings: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx index 18ae5170a..7599cb9b0 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx @@ -32,7 +32,7 @@ const enableOptions = [ get value() { return translate('NoChange'); }, - disabled: true, + isDisabled: true, }, { key: 'enabled', diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css index c106388ab..6ea04a0c8 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css @@ -13,4 +13,4 @@ composes: button from '~Components/Link/Button.css'; margin-right: 10px; -} \ No newline at end of file +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx index 2722f02fa..b2c1208cb 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx @@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import Column from 'Components/Table/Column'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import useSelectState from 'Helpers/Hooks/useSelectState'; import { kinds } from 'Helpers/Props'; -import SortDirection from 'Helpers/Props/SortDirection'; import { bulkDeleteDownloadClients, bulkEditDownloadClients, @@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps< typeof ManageDownloadClientsModalRow >['onSelectedChange']; -const COLUMNS = [ +const COLUMNS: Column[] = [ { name: 'name', label: () => translate('Name'), @@ -82,8 +82,6 @@ const COLUMNS = [ interface ManageDownloadClientsModalContentProps { onModalClose(): void; - sortKey?: string; - sortDirection?: SortDirection; } function ManageDownloadClientsModalContent( @@ -220,9 +218,9 @@ function ManageDownloadClientsModalContent( {error ?
{errorMessage}
: null} - {isPopulated && !error && !items.length && ( + {isPopulated && !error && !items.length ? ( {translate('NoDownloadClientsFound')} - )} + ) : null} {isPopulated && !!items.length && !isFetching && !isFetching ? ( diff --git a/frontend/src/Settings/General/LoggingSettings.js b/frontend/src/Settings/General/LoggingSettings.js index 93918a3d2..043867853 100644 --- a/frontend/src/Settings/General/LoggingSettings.js +++ b/frontend/src/Settings/General/LoggingSettings.js @@ -15,12 +15,14 @@ const logLevelOptions = [ function LoggingSettings(props) { const { + advancedSettings, settings, onInputChange } = props; const { - logLevel + logLevel, + logSizeLimit } = settings; return ( @@ -39,11 +41,30 @@ function LoggingSettings(props) { {...logLevel} /> + + + {translate('LogSizeLimit')} + + + ); } LoggingSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, settings: PropTypes.object.isRequired, onInputChange: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/General/UpdateSettings.js b/frontend/src/Settings/General/UpdateSettings.js index 081f5dda2..a151423e5 100644 --- a/frontend/src/Settings/General/UpdateSettings.js +++ b/frontend/src/Settings/General/UpdateSettings.js @@ -18,7 +18,6 @@ function UpdateSettings(props) { const { advancedSettings, settings, - isWindows, packageUpdateMechanism, onInputChange } = props; @@ -44,10 +43,10 @@ function UpdateSettings(props) { value: titleCase(packageUpdateMechanism) }); } else { - updateOptions.push({ key: 'builtIn', value: 'Built-In' }); + updateOptions.push({ key: 'builtIn', value: translate('BuiltIn') }); } - updateOptions.push({ key: 'script', value: 'Script' }); + updateOptions.push({ key: 'script', value: translate('Script') }); return (
@@ -69,62 +68,59 @@ function UpdateSettings(props) { /> - { - !isWindows && -
- - {translate('Automatic')} +
+ + {translate('Automatic')} - - + + + + {translate('Mechanism')} + + + + + { + updateMechanism.value === 'script' && - {translate('Mechanism')} + {translate('ScriptPath')} - - { - updateMechanism.value === 'script' && - - {translate('ScriptPath')} - - - - } -
- } + } +
); } diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js index ed9582e91..d50fb2385 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js @@ -20,6 +20,7 @@ import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import Popover from 'Components/Tooltip/Popover'; import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; +import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton'; import formatShortTimeSpan from 'Utilities/Date/formatShortTimeSpan'; import translate from 'Utilities/String/translate'; import styles from './EditImportListModalContent.css'; @@ -66,6 +67,7 @@ function EditImportListModalContent(props) { onModalClose, onSavePress, onTestPress, + onAdvancedSettingsPress, onDeleteImportListPress, showMetadataProfile, ...otherProps @@ -290,7 +292,7 @@ function EditImportListModalContent(props) { @@ -333,6 +335,12 @@ function EditImportListModalContent(props) { } + + { + this.props.toggleAdvancedSettings(); + }; + // // Render @@ -67,6 +78,7 @@ class EditImportListModalContentConnector extends Component { {...this.props} onSavePress={this.onSavePress} onTestPress={this.onTestPress} + onAdvancedSettingsPress={this.onAdvancedSettingsPress} onInputChange={this.onInputChange} onFieldChange={this.onFieldChange} /> @@ -84,6 +96,7 @@ EditImportListModalContentConnector.propTypes = { setImportListFieldValue: PropTypes.func.isRequired, saveImportList: PropTypes.func.isRequired, testImportList: PropTypes.func.isRequired, + toggleAdvancedSettings: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js b/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js index 5c6bad8e7..5eb47068d 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js @@ -4,12 +4,12 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { deleteImportList, fetchImportLists, fetchRootFolders } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import ImportLists from './ImportLists'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.importLists', sortByName), + createSortedSectionSelector('settings.importLists', sortByProp('name')), (importLists) => importLists ); } diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx index 5a651ba28..82f7d309c 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx @@ -31,7 +31,7 @@ const autoAddOptions = [ get value() { return translate('NoChange'); }, - disabled: true, + isDisabled: true, }, { key: 'enabled', diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css index c106388ab..6ea04a0c8 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css @@ -13,4 +13,4 @@ composes: button from '~Components/Link/Button.css'; margin-right: 10px; -} \ No newline at end of file +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx index 60619c662..4fee485c9 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx @@ -198,9 +198,9 @@ function ManageImportListsModalContent( {error ?
{errorMessage}
: null} - {isPopulated && !error && !items.length && ( + {isPopulated && !error && !items.length ? ( {translate('NoImportListsFound')} - )} + ) : null} {isPopulated && !!items.length && !isFetching && !isFetching ? (
- {qualityProfile?.name ?? 'None'} + {qualityProfile?.name ?? translate('None')} @@ -71,7 +72,7 @@ function ManageImportListsModalRow(props: ManageImportListsModalRowProps) { - {enableAutomaticAdd ? 'Yes' : 'No'} + {enableAutomaticAdd ? translate('Yes') : translate('No')} diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js index 55235c9da..bda52ac42 100644 --- a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js @@ -160,7 +160,7 @@ function EditIndexerModalContent(props) { { return { diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx index f9b051986..69ad5a988 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx @@ -32,7 +32,7 @@ const enableOptions = [ get value() { return translate('NoChange'); }, - disabled: true, + isDisabled: true, }, { key: 'enabled', diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css index c106388ab..6ea04a0c8 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css @@ -13,4 +13,4 @@ composes: button from '~Components/Link/Button.css'; margin-right: 10px; -} \ No newline at end of file +} diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx index 8137111f5..997d1b566 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx @@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import Column from 'Components/Table/Column'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import useSelectState from 'Helpers/Hooks/useSelectState'; import { kinds } from 'Helpers/Props'; -import SortDirection from 'Helpers/Props/SortDirection'; import { bulkDeleteIndexers, bulkEditIndexers, @@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps< typeof ManageIndexersModalRow >['onSelectedChange']; -const COLUMNS = [ +const COLUMNS: Column[] = [ { name: 'name', label: () => translate('Name'), @@ -82,8 +82,6 @@ const COLUMNS = [ interface ManageIndexersModalContentProps { onModalClose(): void; - sortKey?: string; - sortDirection?: SortDirection; } function ManageIndexersModalContent(props: ManageIndexersModalContentProps) { @@ -215,9 +213,9 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) { {error ?
{errorMessage}
: null} - {isPopulated && !error && !items.length && ( + {isPopulated && !error && !items.length ? ( {translate('NoIndexersFound')} - )} + ) : null} {isPopulated && !!items.length && !isFetching && !isFetching ? (
- { - !isWindows && - - - {translate('SkipFreeSpaceCheck')} - + + {translate('SkipFreeSpaceCheck')} - - - } + + state.settings.advancedSettings, (state) => state.settings.namingExamples, createSettingsSectionSelector(SECTION), - (advancedSettings, examples, sectionSettings) => { + (advancedSettings, namingExamples, sectionSettings) => { return { advancedSettings, - examples: examples.item, - examplesPopulated: !_.isEmpty(examples.item), + examples: namingExamples.item, + examplesPopulated: namingExamples.isPopulated, ...sectionSettings }; } diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js index 35244e64e..dec15893f 100644 --- a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js @@ -15,16 +15,51 @@ import NamingOption from './NamingOption'; import styles from './NamingModal.css'; const separatorOptions = [ - { key: ' ', value: 'Space ( )' }, - { key: '.', value: 'Period (.)' }, - { key: '_', value: 'Underscore (_)' }, - { key: '-', value: 'Dash (-)' } + { + key: ' ', + get value() { + return `${translate('Space')} ( )`; + } + }, + { + key: '.', + get value() { + return `${translate('Period')} (.)`; + } + }, + { + key: '_', + get value() { + return `${translate('Underscore')} (_)`; + } + }, + { + key: '-', + get value() { + return `${translate('Dash')} (-)`; + } + } ]; const caseOptions = [ - { key: 'title', value: 'Default Case' }, - { key: 'lower', value: 'Lowercase' }, - { key: 'upper', value: 'Uppercase' } + { + key: 'title', + get value() { + return translate('DefaultCase'); + } + }, + { + key: 'lower', + get value() { + return translate('Lowercase'); + } + }, + { + key: 'upper', + get value() { + return translate('Uppercase'); + } + } ]; const fileNameTokens = [ @@ -40,33 +75,23 @@ const fileNameTokens = [ const artistTokens = [ { token: '{Artist Name}', example: 'Artist Name' }, - - { token: '{Artist NameThe}', example: 'Artist Name, The' }, - - { token: '{Artist NameFirstCharacter}', example: 'A' }, - { token: '{Artist CleanName}', example: 'Artist Name' }, - + { token: '{Artist NameThe}', example: 'Artist Name, The' }, + { token: '{Artist CleanNameThe}', example: 'Artist Name, The' }, + { token: '{Artist NameFirstCharacter}', example: 'A' }, { token: '{Artist Disambiguation}', example: 'Disambiguation' }, - { token: '{Artist Genre}', example: 'Pop' }, - { token: '{Artist MbId}', example: 'db92a151-1ac2-438b-bc43-b82e149ddd50' } ]; const albumTokens = [ { token: '{Album Title}', example: 'Album Title' }, - - { token: '{Album TitleThe}', example: 'Album Title, The' }, - { token: '{Album CleanTitle}', example: 'Album Title' }, - + { token: '{Album TitleThe}', example: 'Album Title, The' }, + { token: '{Album CleanTitleThe}', example: 'Album Title, The' }, { token: '{Album Type}', example: 'Album Type' }, - { token: '{Album Disambiguation}', example: 'Disambiguation' }, - { token: '{Album Genre}', example: 'Rock' }, - { token: '{Album MbId}', example: '082c6aff-a7cc-36e0-a960-35a578ecd937' } ]; @@ -96,8 +121,9 @@ const trackTitleTokens = [ const trackArtistTokens = [ { token: '{Track ArtistName}', example: 'Artist Name' }, - { token: '{Track ArtistNameThe}', example: 'Artist Name, The' }, { token: '{Track ArtistCleanName}', example: 'Artist Name' }, + { token: '{Track ArtistNameThe}', example: 'Artist Name, The' }, + { token: '{Track ArtistCleanNameThe}', example: 'Artist Name, The' }, { token: '{Track ArtistMbId}', example: 'db92a151-1ac2-438b-bc43-b82e149ddd50' } ]; @@ -213,7 +239,7 @@ class NamingModal extends Component { > - File Name Tokens + {translate('FileNameTokens')} @@ -552,7 +578,7 @@ class NamingModal extends Component { onSelectionChange={this.onInputSelectionChange} /> diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css index b692362fb..204c93d0e 100644 --- a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css +++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css @@ -1,6 +1,6 @@ .option { display: flex; - align-items: center; + align-items: stretch; flex-wrap: wrap; margin: 3px; border: 1px solid var(--borderColor); @@ -17,7 +17,7 @@ } .small { - width: 460px; + width: 490px; } .large { @@ -26,7 +26,7 @@ .token { flex: 0 0 50%; - padding: 6px 16px; + padding: 6px; background-color: var(--popoverTitleBackgroundColor); font-family: $monoSpaceFontFamily; } @@ -34,9 +34,9 @@ .example { display: flex; align-items: center; - align-self: stretch; + justify-content: space-between; flex: 0 0 50%; - padding: 6px 16px; + padding: 6px; background-color: var(--popoverBodyBackgroundColor); .footNote { diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js index 20cefc53f..dc91e4622 100644 --- a/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js +++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js @@ -75,12 +75,12 @@ class RootFolder extends Component { {path} - + { + settings.writeAudioTags.value !== 'no' && + + + {translate('EmbedCoverArtInAudioFiles')} + + + + + } + {translate('ScrubExistingTags')} diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js index cf42e4c2a..ee51799f2 100644 --- a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js @@ -14,6 +14,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds } from 'Helpers/Props'; +import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton'; import translate from 'Utilities/String/translate'; import NotificationEventItems from './NotificationEventItems'; import styles from './EditNotificationModalContent.css'; @@ -32,6 +33,7 @@ function EditNotificationModalContent(props) { onModalClose, onSavePress, onTestPress, + onAdvancedSettingsPress, onDeleteNotificationPress, ...otherProps } = props; @@ -103,7 +105,7 @@ function EditNotificationModalContent(props) { @@ -140,6 +142,12 @@ function EditNotificationModalContent(props) { } + + { + this.props.toggleAdvancedSettings(); + }; + // // Render @@ -65,6 +76,7 @@ class EditNotificationModalContentConnector extends Component { {...this.props} onSavePress={this.onSavePress} onTestPress={this.onTestPress} + onAdvancedSettingsPress={this.onAdvancedSettingsPress} onInputChange={this.onInputChange} onFieldChange={this.onFieldChange} /> @@ -82,6 +94,7 @@ EditNotificationModalContentConnector.propTypes = { setNotificationFieldValue: PropTypes.func.isRequired, saveNotification: PropTypes.func.isRequired, testNotification: PropTypes.func.isRequired, + toggleAdvancedSettings: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/Notifications/Notifications/Notification.js b/frontend/src/Settings/Notifications/Notifications/Notification.js index 6e9b01f57..21f0e5ca8 100644 --- a/frontend/src/Settings/Notifications/Notifications/Notification.js +++ b/frontend/src/Settings/Notifications/Notifications/Notification.js @@ -60,8 +60,9 @@ class Notification extends Component { onReleaseImport, onUpgrade, onRename, - onAlbumDelete, + onArtistAdd, onArtistDelete, + onAlbumDelete, onHealthIssue, onHealthRestored, onDownloadFailure, @@ -72,8 +73,9 @@ class Notification extends Component { supportsOnReleaseImport, supportsOnUpgrade, supportsOnRename, - supportsOnAlbumDelete, + supportsOnArtistAdd, supportsOnArtistDelete, + supportsOnAlbumDelete, supportsOnHealthIssue, supportsOnHealthRestored, supportsOnDownloadFailure, @@ -95,59 +97,75 @@ class Notification extends Component { { - supportsOnGrab && onGrab && + supportsOnGrab && onGrab ? + : + null } { - supportsOnReleaseImport && onReleaseImport && + supportsOnReleaseImport && onReleaseImport ? + : + null } { - supportsOnUpgrade && onReleaseImport && onUpgrade && + supportsOnUpgrade && onReleaseImport && onUpgrade ? + : + null } { - supportsOnRename && onRename && + supportsOnRename && onRename ? + : + null } { - supportsOnTrackRetag && onTrackRetag && + supportsOnTrackRetag && onTrackRetag ? + : + null } { - supportsOnAlbumDelete && onAlbumDelete && + supportsOnArtistAdd && onArtistAdd ? + {translate('OnArtistAdd')} + : + null } { - supportsOnArtistDelete && onArtistDelete && + supportsOnArtistDelete && onArtistDelete ? + : + null } { - supportsOnHealthIssue && onHealthIssue && + supportsOnAlbumDelete && onAlbumDelete ? + : + null + } + + { + supportsOnHealthIssue && onHealthIssue ? + : + null } { @@ -159,35 +177,38 @@ class Notification extends Component { } { - supportsOnDownloadFailure && onDownloadFailure && + supportsOnDownloadFailure && onDownloadFailure ? + : + null } { - supportsOnImportFailure && onImportFailure && + supportsOnImportFailure && onImportFailure ? + : + null } { - supportsOnApplicationUpdate && onApplicationUpdate && + supportsOnApplicationUpdate && onApplicationUpdate ? + : + null } { - !onGrab && !onReleaseImport && !onRename && !onTrackRetag && !onAlbumDelete && !onArtistDelete && - !onHealthIssue && !onHealthRestored && !onDownloadFailure && !onImportFailure && !onApplicationUpdate && - + !onGrab && !onReleaseImport && !onRename && !onTrackRetag && !onArtistAdd && !onArtistDelete && !onAlbumDelete && !onHealthIssue && !onHealthRestored && !onDownloadFailure && !onImportFailure && !onApplicationUpdate ? + : + null } @@ -148,18 +150,29 @@ function NotificationEventItems(props) { +
+ +
+
{ return { diff --git a/frontend/src/Settings/PendingChangesModal.js b/frontend/src/Settings/PendingChangesModal.js index 478c7cafa..900022ca1 100644 --- a/frontend/src/Settings/PendingChangesModal.js +++ b/frontend/src/Settings/PendingChangesModal.js @@ -14,12 +14,17 @@ function PendingChangesModal(props) { isOpen, onConfirm, onCancel, - bindShortcut + bindShortcut, + unbindShortcut } = props; useEffect(() => { - bindShortcut('enter', onConfirm); - }, [bindShortcut, onConfirm]); + if (isOpen) { + bindShortcut('enter', onConfirm); + + return () => unbindShortcut('enter', onConfirm); + } + }, [bindShortcut, unbindShortcut, isOpen, onConfirm]); return ( - {translate('UnableToAddANewQualityProfilePleaseTryAgain')} -
: + + {translate('AddDelayProfileError')} + : null } @@ -186,7 +186,7 @@ function EditDelayProfileModalContent(props) { { id === 1 ? - {translate('DefaultDelayProfileHelpText')} + {translate('DefaultDelayProfileArtist')} : @@ -196,7 +196,7 @@ function EditDelayProfileModalContent(props) { type={inputTypes.TAG} name="tags" {...tags} - helpText={translate('TagsHelpText')} + helpText={translate('DelayProfileArtistTagsHelpText')} onChange={onInputChange} /> diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js index d39303ecc..5e719517f 100644 --- a/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js +++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js @@ -5,7 +5,7 @@ import FieldSet from 'Components/FieldSet'; import Icon from 'Components/Icon'; import PageSectionContent from 'Components/Page/PageSectionContent'; import { icons, metadataProfileNames } from 'Helpers/Props'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import EditMetadataProfileModalConnector from './EditMetadataProfileModalConnector'; import MetadataProfile from './MetadataProfile'; @@ -59,17 +59,20 @@ class MetadataProfiles extends Component { >
{ - items.filter((item) => item.name !== metadataProfileNames.NONE).sort(sortByName).map((item) => { - return ( - - ); - }) + items + .filter((item) => item.name !== metadataProfileNames.NONE) + .sort(sortByProp('name')) + .map((item) => { + return ( + + ); + }) } b.name ? 1 : -1; + + return a.name.localeCompare(b.name, undefined, { numeric: true }); }).map((x) => items[x.format]); } diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js index 581882ffd..4cb318463 100644 --- a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js +++ b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js @@ -4,12 +4,12 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { cloneQualityProfile, deleteQualityProfile, fetchQualityProfiles } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import QualityProfiles from './QualityProfiles'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.qualityProfiles', sortByName), + createSortedSectionSelector('settings.qualityProfiles', sortByProp('name')), (qualityProfiles) => qualityProfiles ); } diff --git a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js index c6c297c81..e1c695c42 100644 --- a/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js +++ b/frontend/src/Settings/Profiles/Release/EditReleaseProfileModalContent.js @@ -119,7 +119,7 @@ function EditReleaseProfileModalContent(props) { diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css b/frontend/src/Settings/Quality/Definition/QualityDefinition.css index e090428a1..860333725 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinition.css +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css @@ -24,19 +24,19 @@ height: 20px; } -.bar { +.track { top: 9px; margin: 0 5px; height: 3px; background-color: var(--sliderAccentColor); box-shadow: 0 0 0 #000; - &:nth-child(3n+1) { + &:nth-child(3n + 1) { background-color: #ddd; } } -.handle { +.thumb { top: 1px; z-index: 0 !important; width: 18px; diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css.d.ts b/frontend/src/Settings/Quality/Definition/QualityDefinition.css.d.ts index 2b92fb212..9c9e8393a 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinition.css.d.ts +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css.d.ts @@ -1,8 +1,6 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'bar': string; - 'handle': string; 'kilobitsPerSecond': string; 'quality': string; 'qualityDefinition': string; @@ -10,7 +8,9 @@ interface CssExports { 'sizeLimit': string; 'sizes': string; 'slider': string; + 'thumb': string; 'title': string; + 'track': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.js b/frontend/src/Settings/Quality/Definition/QualityDefinition.js index 7d8a78737..48251abfb 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinition.js +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js @@ -55,6 +55,27 @@ class QualityDefinition extends Component { }; } + // + // Control + + trackRenderer(props, state) { + return ( +
+ ); + } + + thumbRenderer(props, state) { + return ( +
+ ); + } + // // Listeners @@ -174,6 +195,7 @@ class QualityDefinition extends Component {
diff --git a/frontend/src/Settings/Settings.js b/frontend/src/Settings/Settings.js index 36f47d82e..d2a86adc6 100644 --- a/frontend/src/Settings/Settings.js +++ b/frontend/src/Settings/Settings.js @@ -18,132 +18,132 @@ function Settings() { className={styles.link} to="/settings/mediamanagement" > - Media Management + {translate('MediaManagement')}
- Naming, file management settings and root folders + {translate('MediaManagementSettingsSummary')}
- Profiles + {translate('Profiles')}
- Quality, Metadata, Delay, and Release profiles + {translate('ProfilesSettingsArtistSummary')}
- Quality + {translate('Quality')}
- Quality sizes and naming + {translate('QualitySettingsSummary')}
- Custom Formats + {translate('CustomFormats')}
- Custom Formats and Settings + {translate('CustomFormatsSettingsSummary')}
- Indexers + {translate('Indexers')}
- Indexers and indexer options + {translate('IndexersSettingsSummary')}
- Download Clients + {translate('DownloadClients')}
- Download clients, download handling and remote path mappings + {translate('DownloadClientsSettingsSummary')}
- Import Lists + {translate('ImportLists')}
- Import Lists + {translate('ImportListsSettingsSummary')}
- Connect + {translate('Connect')}
- Notifications, connections to media servers/players and custom scripts + {translate('ConnectSettingsSummary')}
- Metadata + {translate('Metadata')}
- Create metadata files when tracks are imported or artist are refreshed + {translate('MetadataSettingsArtistSummary')}
- Tags + {translate('Tags')}
- Manage artist, profile, restriction, and notification tags + {translate('TagsSettingsSummary')}
- General + {translate('General')}
- Port, SSL, username/password, proxy, analytics and updates + {translate('GeneralSettingsSummary')}
- UI + {translate('Ui')}
- Calendar, date and color impaired options + {translate('UiSettingsSummary')}
diff --git a/frontend/src/Settings/SettingsToolbarConnector.js b/frontend/src/Settings/SettingsToolbarConnector.js index 1e6f7a589..65d937ab8 100644 --- a/frontend/src/Settings/SettingsToolbarConnector.js +++ b/frontend/src/Settings/SettingsToolbarConnector.js @@ -134,6 +134,7 @@ const historyShape = { }; SettingsToolbarConnector.propTypes = { + showSave: PropTypes.bool, hasPendingChanges: PropTypes.bool.isRequired, history: PropTypes.shape(historyShape).isRequired, onSavePress: PropTypes.func, diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js index 0d86721af..005547bb7 100644 --- a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js @@ -13,7 +13,7 @@ import { } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import AutoTagging from './AutoTagging'; import EditAutoTaggingModal from './EditAutoTaggingModal'; @@ -27,7 +27,7 @@ export default function AutoTaggings() { isFetching, isPopulated } = useSelector( - createSortedSectionSelector('settings.autoTaggings', sortByName) + createSortedSectionSelector('settings.autoTaggings', sortByProp('name')) ); const tagList = useSelector(createTagsSelector()); diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css index a197dbcd4..d503b0af3 100644 --- a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css @@ -25,3 +25,8 @@ border-radius: 4px; background-color: var(--cardCenterBackgroundColor); } + +.autoTaggings { + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts index 1339caf02..2a7f6b41e 100644 --- a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts @@ -2,6 +2,7 @@ // Please do not change this file! interface CssExports { 'addSpecification': string; + 'autoTaggings': string; 'center': string; 'deleteButton': string; 'rightButtons': string; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js index 2ab1e4a1c..04302729b 100644 --- a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js @@ -86,10 +86,10 @@ function EditSpecificationModalContent(props) {
- +
- +
} diff --git a/frontend/src/Settings/Tags/TagInUse.js b/frontend/src/Settings/Tags/TagInUse.js index 9fb57d230..27228fa2e 100644 --- a/frontend/src/Settings/Tags/TagInUse.js +++ b/frontend/src/Settings/Tags/TagInUse.js @@ -12,7 +12,7 @@ export default function TagInUse(props) { return null; } - if (count > 1 && labelPlural ) { + if (count > 1 && labelPlural) { return (
{count} {labelPlural.toLowerCase()} diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js index 770dc4720..15f31d3c5 100644 --- a/frontend/src/Settings/Tags/TagsConnector.js +++ b/frontend/src/Settings/Tags/TagsConnector.js @@ -3,12 +3,14 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { fetchDelayProfiles, fetchDownloadClients, fetchImportLists, fetchIndexers, fetchNotifications, fetchReleaseProfiles } from 'Store/Actions/settingsActions'; -import { fetchTagDetails } from 'Store/Actions/tagActions'; +import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import sortByProp from 'Utilities/Array/sortByProp'; import Tags from './Tags'; function createMapStateToProps() { return createSelector( - (state) => state.tags, + createSortedSectionSelector('tags', sortByProp('label')), (tags) => { const isFetching = tags.isFetching || tags.details.isFetching; const error = tags.error || tags.details.error; @@ -25,6 +27,7 @@ function createMapStateToProps() { } const mapDispatchToProps = { + dispatchFetchTags: fetchTags, dispatchFetchTagDetails: fetchTagDetails, dispatchFetchDelayProfiles: fetchDelayProfiles, dispatchFetchImportLists: fetchImportLists, @@ -41,6 +44,7 @@ class MetadatasConnector extends Component { componentDidMount() { const { + dispatchFetchTags, dispatchFetchTagDetails, dispatchFetchDelayProfiles, dispatchFetchImportLists, @@ -50,6 +54,7 @@ class MetadatasConnector extends Component { dispatchFetchDownloadClients } = this.props; + dispatchFetchTags(); dispatchFetchTagDetails(); dispatchFetchDelayProfiles(); dispatchFetchImportLists(); @@ -72,6 +77,7 @@ class MetadatasConnector extends Component { } MetadatasConnector.propTypes = { + dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchTagDetails: PropTypes.func.isRequired, dispatchFetchDelayProfiles: PropTypes.func.isRequired, dispatchFetchImportLists: PropTypes.func.isRequired, diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js index 902b922f9..cc27829df 100644 --- a/frontend/src/Settings/UI/UISettings.js +++ b/frontend/src/Settings/UI/UISettings.js @@ -22,19 +22,19 @@ export const firstDayOfWeekOptions = [ ]; export const weekColumnOptions = [ - { key: 'ddd M/D', value: 'Tue 3/25' }, - { key: 'ddd MM/DD', value: 'Tue 03/25' }, - { key: 'ddd D/M', value: 'Tue 25/3' }, - { key: 'ddd DD/MM', value: 'Tue 25/03' } + { key: 'ddd M/D', value: 'Tue 3/25', hint: 'ddd M/D' }, + { key: 'ddd MM/DD', value: 'Tue 03/25', hint: 'ddd MM/DD' }, + { key: 'ddd D/M', value: 'Tue 25/3', hint: 'ddd D/M' }, + { key: 'ddd DD/MM', value: 'Tue 25/03', hint: 'ddd DD/MM' } ]; const shortDateFormatOptions = [ - { key: 'MMM D YYYY', value: 'Mar 25 2014' }, - { key: 'DD MMM YYYY', value: '25 Mar 2014' }, - { key: 'MM/D/YYYY', value: '03/25/2014' }, - { key: 'MM/DD/YYYY', value: '03/25/2014' }, - { key: 'DD/MM/YYYY', value: '25/03/2014' }, - { key: 'YYYY-MM-DD', value: '2014-03-25' } + { key: 'MMM D YYYY', value: 'Mar 25 2014', hint: 'MMM D YYYY' }, + { key: 'DD MMM YYYY', value: '25 Mar 2014', hint: 'DD MMM YYYY' }, + { key: 'MM/D/YYYY', value: '03/25/2014', hint: 'MM/D/YYYY' }, + { key: 'MM/DD/YYYY', value: '03/25/2014', hint: 'MM/DD/YYYY' }, + { key: 'DD/MM/YYYY', value: '25/03/2014', hint: 'DD/MM/YYYY' }, + { key: 'YYYY-MM-DD', value: '2014-03-25', hint: 'YYYY-MM-DD' } ]; const longDateFormatOptions = [ @@ -69,7 +69,7 @@ class UISettings extends Component { .map((theme) => ({ key: theme, value: titleCase(theme) })); return ( - +
- {translate('UILanguage')} + {translate('UiLanguage')} { + lastTestData = null; + dispatch(set({ section, isTesting: false, diff --git a/frontend/src/Store/Actions/Settings/customFormats.js b/frontend/src/Store/Actions/Settings/customFormats.js index 4a175abea..3b8a209f9 100644 --- a/frontend/src/Store/Actions/Settings/customFormats.js +++ b/frontend/src/Store/Actions/Settings/customFormats.js @@ -1,7 +1,12 @@ import { createAction } from 'redux-actions'; +import { sortDirections } from 'Helpers/Props'; +import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; +import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createSetClientSideCollectionSortReducer + from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import { createThunk } from 'Store/thunks'; import getSectionState from 'Utilities/State/getSectionState'; @@ -21,6 +26,9 @@ export const SAVE_CUSTOM_FORMAT = 'settings/customFormats/saveCustomFormat'; export const DELETE_CUSTOM_FORMAT = 'settings/customFormats/deleteCustomFormat'; export const SET_CUSTOM_FORMAT_VALUE = 'settings/customFormats/setCustomFormatValue'; export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat'; +export const BULK_EDIT_CUSTOM_FORMATS = 'settings/downloadClients/bulkEditCustomFormats'; +export const BULK_DELETE_CUSTOM_FORMATS = 'settings/downloadClients/bulkDeleteCustomFormats'; +export const SET_MANAGE_CUSTOM_FORMATS_SORT = 'settings/downloadClients/setManageCustomFormatsSort'; // // Action Creators @@ -28,6 +36,9 @@ export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat'; export const fetchCustomFormats = createThunk(FETCH_CUSTOM_FORMATS); export const saveCustomFormat = createThunk(SAVE_CUSTOM_FORMAT); export const deleteCustomFormat = createThunk(DELETE_CUSTOM_FORMAT); +export const bulkEditCustomFormats = createThunk(BULK_EDIT_CUSTOM_FORMATS); +export const bulkDeleteCustomFormats = createThunk(BULK_DELETE_CUSTOM_FORMATS); +export const setManageCustomFormatsSort = createAction(SET_MANAGE_CUSTOM_FORMATS_SORT); export const setCustomFormatValue = createAction(SET_CUSTOM_FORMAT_VALUE, (payload) => { return { @@ -47,20 +58,30 @@ export default { // State defaultState: { - isSchemaFetching: false, - isSchemaPopulated: false, isFetching: false, isPopulated: false, + error: null, + isSaving: false, + saveError: null, + isDeleting: false, + deleteError: null, + items: [], + pendingChanges: {}, + + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, schema: { includeCustomFormatWhenRenaming: false }, - error: null, - isDeleting: false, - deleteError: null, - isSaving: false, - saveError: null, - items: [], - pendingChanges: {} + + sortKey: 'name', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + name: ({ name }) => { + return name.toLocaleLowerCase(); + } + } }, // @@ -82,7 +103,10 @@ export default { })); createSaveProviderHandler(section, '/customformat')(getState, payload, dispatch); - } + }, + + [BULK_EDIT_CUSTOM_FORMATS]: createBulkEditItemHandler(section, '/customformat/bulk'), + [BULK_DELETE_CUSTOM_FORMATS]: createBulkRemoveItemHandler(section, '/customformat/bulk') }, // @@ -102,7 +126,9 @@ export default { newState.pendingChanges = pendingChanges; return updateSectionState(state, section, newState); - } + }, + + [SET_MANAGE_CUSTOM_FORMATS_SORT]: createSetClientSideCollectionSortReducer(section) } }; diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js index 1f4765121..1113e7daf 100644 --- a/frontend/src/Store/Actions/Settings/downloadClients.js +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -94,7 +94,12 @@ export default { items: [], pendingChanges: {}, sortKey: 'name', - sortDirection: sortDirections.DESCENDING + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + name: ({ name }) => { + return name.toLocaleLowerCase(); + } + } }, // diff --git a/frontend/src/Store/Actions/Settings/indexerFlags.js b/frontend/src/Store/Actions/Settings/indexerFlags.js new file mode 100644 index 000000000..a53fe1c61 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/indexerFlags.js @@ -0,0 +1,48 @@ +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import { createThunk } from 'Store/thunks'; + +// +// Variables + +const section = 'settings.indexerFlags'; + +// +// Actions Types + +export const FETCH_INDEXER_FLAGS = 'settings/indexerFlags/fetchIndexerFlags'; + +// +// Action Creators + +export const fetchIndexerFlags = createThunk(FETCH_INDEXER_FLAGS); + +// +// Details + +export default { + + // + // State + + defaultState: { + isFetching: false, + isPopulated: false, + error: null, + items: [] + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_INDEXER_FLAGS]: createFetchHandler(section, '/indexerFlag') + }, + + // + // Reducers + + reducers: { + + } + +}; diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js index d8bd85ebe..511a2e475 100644 --- a/frontend/src/Store/Actions/Settings/indexers.js +++ b/frontend/src/Store/Actions/Settings/indexers.js @@ -98,7 +98,12 @@ export default { items: [], pendingChanges: {}, sortKey: 'name', - sortDirection: sortDirections.DESCENDING + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + name: ({ name }) => { + return name.toLocaleLowerCase(); + } + } }, // diff --git a/frontend/src/Store/Actions/Settings/notifications.js b/frontend/src/Store/Actions/Settings/notifications.js index 15943255c..4e557db87 100644 --- a/frontend/src/Store/Actions/Settings/notifications.js +++ b/frontend/src/Store/Actions/Settings/notifications.js @@ -107,6 +107,8 @@ export default { selectedSchema.onReleaseImport = selectedSchema.supportsOnReleaseImport; selectedSchema.onUpgrade = selectedSchema.supportsOnUpgrade; selectedSchema.onRename = selectedSchema.supportsOnRename; + selectedSchema.onArtistAdd = selectedSchema.supportsOnArtistAdd; + selectedSchema.onArtistDelete = selectedSchema.supportsOnArtistDelete; selectedSchema.onHealthIssue = selectedSchema.supportsOnHealthIssue; selectedSchema.onDownloadFailure = selectedSchema.supportsOnDownloadFailure; selectedSchema.onImportFailure = selectedSchema.supportsOnImportFailure; diff --git a/frontend/src/Store/Actions/albumActions.js b/frontend/src/Store/Actions/albumActions.js index d1ac2c348..4ac39a0aa 100644 --- a/frontend/src/Store/Actions/albumActions.js +++ b/frontend/src/Store/Actions/albumActions.js @@ -36,6 +36,11 @@ export const defaultState = { sortPredicates: { rating: function(item) { return item.ratings.value; + }, + size: function(item) { + const { statistics = {} } = item; + + return statistics.sizeOnDisk || 0; } }, @@ -81,6 +86,12 @@ export const defaultState = { isSortable: true, isVisible: false }, + { + name: 'size', + label: () => translate('Size'), + isSortable: true, + isVisible: false + }, { name: 'rating', label: () => translate('Rating'), diff --git a/frontend/src/Store/Actions/albumSelectionActions.js b/frontend/src/Store/Actions/albumSelectionActions.js new file mode 100644 index 000000000..f19f5b691 --- /dev/null +++ b/frontend/src/Store/Actions/albumSelectionActions.js @@ -0,0 +1,86 @@ +import moment from 'moment'; +import { createAction } from 'redux-actions'; +import { sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; + +// +// Variables + +export const section = 'albumSelection'; + +// +// State + +export const defaultState = { + isFetching: false, + isReprocessing: false, + isPopulated: false, + error: null, + sortKey: 'title', + sortDirection: sortDirections.ASCENDING, + items: [], + sortPredicates: { + title: ({ title }) => { + return title.toLocaleLowerCase(); + }, + + releaseDate: function({ releaseDate }, direction) { + if (releaseDate) { + return moment(releaseDate).unix(); + } + + if (direction === sortDirections.DESCENDING) { + return 0; + } + + return Number.MAX_VALUE; + } + } +}; + +export const persistState = [ + 'albumSelection.sortKey', + 'albumSelection.sortDirection' +]; + +// +// Actions Types + +export const FETCH_ALBUMS = 'albumSelection/fetchAlbums'; +export const SET_ALBUMS_SORT = 'albumSelection/setAlbumsSort'; +export const CLEAR_ALBUMS = 'albumSelection/clearAlbums'; + +// +// Action Creators + +export const fetchAlbums = createThunk(FETCH_ALBUMS); +export const setAlbumsSort = createAction(SET_ALBUMS_SORT); +export const clearAlbums = createAction(CLEAR_ALBUMS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_ALBUMS]: createFetchHandler(section, '/album') +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_ALBUMS_SORT]: createSetClientSideCollectionSortReducer(section), + + [CLEAR_ALBUMS]: (state) => { + return updateSectionState(state, section, { + ...defaultState, + sortKey: state.sortKey, + sortDirection: state.sortDirection + }); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js index 756a6ee25..736502460 100644 --- a/frontend/src/Store/Actions/artistIndexActions.js +++ b/frontend/src/Store/Actions/artistIndexActions.js @@ -1,6 +1,6 @@ import { createAction } from 'redux-actions'; import { filterBuilderTypes, filterBuilderValueTypes, filterTypePredicates, sortDirections } from 'Helpers/Props'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import { filterPredicates, filters, sortPredicates } from './artistActions'; import createHandleActions from './Creators/createHandleActions'; @@ -94,6 +94,12 @@ export const defaultState = { isSortable: true, isVisible: false }, + { + name: 'monitorNewItems', + label: () => translate('MonitorNewItems'), + isSortable: true, + isVisible: false + }, { name: 'nextAlbum', label: () => translate('NextAlbum'), @@ -145,7 +151,7 @@ export const defaultState = { { name: 'genres', label: () => translate('Genres'), - isSortable: false, + isSortable: true, isVisible: false }, { @@ -267,6 +273,12 @@ export const defaultState = { type: filterBuilderTypes.EXACT, valueType: filterBuilderValueTypes.METADATA_PROFILE }, + { + name: 'monitorNewItems', + label: () => translate('MonitorNewItems'), + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.MONITOR_NEW_ITEMS + }, { name: 'nextAlbum', label: () => translate('NextAlbum'), @@ -322,7 +334,7 @@ export const defaultState = { return acc; }, []); - return tagList.sort(sortByName); + return tagList.sort(sortByProp('name')); } }, { diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js index d473f1368..e13ff4672 100644 --- a/frontend/src/Store/Actions/calendarActions.js +++ b/frontend/src/Store/Actions/calendarActions.js @@ -4,9 +4,10 @@ import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; import * as calendarViews from 'Calendar/calendarViews'; import * as commandNames from 'Commands/commandNames'; -import { filterTypes } from 'Helpers/Props'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypes } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; import translate from 'Utilities/String/translate'; import { set, update } from './baseActions'; import { executeCommandHelper } from './commandActions'; @@ -54,8 +55,8 @@ export const defaultState = { label: () => translate('All'), filters: [ { - key: 'monitored', - value: false, + key: 'unmonitored', + value: [true], type: filterTypes.EQUAL } ] @@ -65,19 +66,35 @@ export const defaultState = { label: () => translate('MonitoredOnly'), filters: [ { - key: 'monitored', - value: true, + key: 'unmonitored', + value: [false], type: filterTypes.EQUAL } ] } + ], + + filterBuilderProps: [ + { + name: 'unmonitored', + label: () => translate('IncludeUnmonitored'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'tags', + label: () => translate('Tags'), + type: filterBuilderTypes.CONTAINS, + valueType: filterBuilderValueTypes.TAG + } ] }; export const persistState = [ 'calendar.view', 'calendar.selectedFilterKey', - 'calendar.options' + 'calendar.options', + 'calendar.customFilters' ]; // @@ -189,6 +206,10 @@ function isRangePopulated(start, end, state) { return false; } +function getCustomFilters(state, type) { + return state.customFilters.items.filter((customFilter) => customFilter.type === type); +} + // // Action Creators @@ -210,7 +231,8 @@ export const actionHandlers = handleThunks({ [FETCH_CALENDAR]: function(getState, payload, dispatch) { const state = getState(); const calendar = state.calendar; - const unmonitored = calendar.selectedFilterKey === 'all'; + const customFilters = getCustomFilters(state, section); + const selectedFilters = findSelectedFilters(calendar.selectedFilterKey, calendar.filters, customFilters); const { time = calendar.time, @@ -237,13 +259,26 @@ export const actionHandlers = handleThunks({ dispatch(set(attrs)); + const requestParams = { + start, + end + }; + + selectedFilters.forEach((selectedFilter) => { + if (selectedFilter.key === 'unmonitored') { + requestParams.unmonitored = selectedFilter.value.includes(true); + } + + if (selectedFilter.key === 'tags') { + requestParams.tags = selectedFilter.value.join(','); + } + }); + + requestParams.unmonitored = requestParams.unmonitored ?? false; + const promise = createAjaxRequest({ url: '/calendar', - data: { - unmonitored, - start, - end - } + data: requestParams }).request; promise.done((data) => { diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js index 225698229..9d16d29c4 100644 --- a/frontend/src/Store/Actions/historyActions.js +++ b/frontend/src/Store/Actions/historyActions.js @@ -150,7 +150,7 @@ export const defaultState = { }, { key: 'importFailed', - label: () => translate('ImportFailed'), + label: () => translate('ImportCompleteFailed'), filters: [ { key: 'eventType', diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 95b02d089..85fda482b 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -1,5 +1,6 @@ import * as albums from './albumActions'; import * as albumHistory from './albumHistoryActions'; +import * as albumSelection from './albumSelectionActions'; import * as app from './appActions'; import * as artist from './artistActions'; import * as artistHistory from './artistHistoryActions'; @@ -13,6 +14,7 @@ import * as history from './historyActions'; import * as interactiveImportActions from './interactiveImportActions'; import * as oAuth from './oAuthActions'; import * as organizePreview from './organizePreviewActions'; +import * as parse from './parseActions'; import * as paths from './pathActions'; import * as providerOptions from './providerOptionActions'; import * as queue from './queueActions'; @@ -28,26 +30,28 @@ import * as wanted from './wantedActions'; export default [ app, + albums, + albumHistory, + albumSelection, + artist, + artistHistory, + artistIndex, blocklist, captcha, calendar, commands, customFilters, - albums, trackFiles, - albumHistory, history, interactiveImportActions, oAuth, organizePreview, retagPreview, + parse, paths, providerOptions, queue, releases, - artist, - artistHistory, - artistIndex, search, settings, system, diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js index e0e295568..a250292c5 100644 --- a/frontend/src/Store/Actions/interactiveImportActions.js +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -16,7 +16,6 @@ import createSetClientSideCollectionSortReducer from './Creators/Reducers/create export const section = 'interactiveImport'; -const albumsSection = `${section}.albums`; const trackFilesSection = `${section}.trackFiles`; let abortCurrentFetchRequest = null; let abortCurrentRequest = null; @@ -58,15 +57,6 @@ export const defaultState = { } }, - albums: { - isFetching: false, - isPopulated: false, - error: null, - sortKey: 'albumTitle', - sortDirection: sortDirections.ASCENDING, - items: [] - }, - trackFiles: { isFetching: false, isPopulated: false, @@ -97,10 +87,6 @@ export const ADD_RECENT_FOLDER = 'interactiveImport/addRecentFolder'; export const REMOVE_RECENT_FOLDER = 'interactiveImport/removeRecentFolder'; export const SET_INTERACTIVE_IMPORT_MODE = 'interactiveImport/setInteractiveImportMode'; -export const FETCH_INTERACTIVE_IMPORT_ALBUMS = 'interactiveImport/fetchInteractiveImportAlbums'; -export const SET_INTERACTIVE_IMPORT_ALBUMS_SORT = 'interactiveImport/clearInteractiveImportAlbumsSort'; -export const CLEAR_INTERACTIVE_IMPORT_ALBUMS = 'interactiveImport/clearInteractiveImportAlbums'; - export const FETCH_INTERACTIVE_IMPORT_TRACKFILES = 'interactiveImport/fetchInteractiveImportTrackFiles'; export const CLEAR_INTERACTIVE_IMPORT_TRACKFILES = 'interactiveImport/clearInteractiveImportTrackFiles'; @@ -117,10 +103,6 @@ export const addRecentFolder = createAction(ADD_RECENT_FOLDER); export const removeRecentFolder = createAction(REMOVE_RECENT_FOLDER); export const setInteractiveImportMode = createAction(SET_INTERACTIVE_IMPORT_MODE); -export const fetchInteractiveImportAlbums = createThunk(FETCH_INTERACTIVE_IMPORT_ALBUMS); -export const setInteractiveImportAlbumsSort = createAction(SET_INTERACTIVE_IMPORT_ALBUMS_SORT); -export const clearInteractiveImportAlbums = createAction(CLEAR_INTERACTIVE_IMPORT_ALBUMS); - export const fetchInteractiveImportTrackFiles = createThunk(FETCH_INTERACTIVE_IMPORT_TRACKFILES); export const clearInteractiveImportTrackFiles = createAction(CLEAR_INTERACTIVE_IMPORT_TRACKFILES); @@ -208,6 +190,7 @@ export const actionHandlers = handleThunks({ trackIds: (item.tracks || []).map((e) => e.id), quality: item.quality, releaseGroup: item.releaseGroup, + indexerFlags: item.indexerFlags, downloadId: item.downloadId, additionalFile: item.additionalFile, replaceExistingFiles: item.replaceExistingFiles, @@ -252,8 +235,6 @@ export const actionHandlers = handleThunks({ }); }, - [FETCH_INTERACTIVE_IMPORT_ALBUMS]: createFetchHandler(albumsSection, '/album'), - [FETCH_INTERACTIVE_IMPORT_TRACKFILES]: createFetchHandler(trackFilesSection, '/trackFile') }); @@ -335,14 +316,6 @@ export const reducers = createHandleActions({ return Object.assign({}, state, { importMode: payload.importMode }); }, - [SET_INTERACTIVE_IMPORT_ALBUMS_SORT]: createSetClientSideCollectionSortReducer(albumsSection), - - [CLEAR_INTERACTIVE_IMPORT_ALBUMS]: (state) => { - return updateSectionState(state, albumsSection, { - ...defaultState.albums - }); - }, - [CLEAR_INTERACTIVE_IMPORT_TRACKFILES]: (state) => { return updateSectionState(state, trackFilesSection, { ...defaultState.trackFiles diff --git a/frontend/src/Store/Actions/parseActions.ts b/frontend/src/Store/Actions/parseActions.ts new file mode 100644 index 000000000..d4b6e9bcb --- /dev/null +++ b/frontend/src/Store/Actions/parseActions.ts @@ -0,0 +1,111 @@ +import { Dispatch } from 'redux'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import AppState from 'App/State/AppState'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, update } from './baseActions'; +import createHandleActions from './Creators/createHandleActions'; +import createClearReducer from './Creators/Reducers/createClearReducer'; + +interface FetchPayload { + title: string; +} + +// +// Variables + +export const section = 'parse'; +let parseTimeout: number | null = null; +let abortCurrentRequest: (() => void) | null = null; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + item: {}, +}; + +// +// Actions Types + +export const FETCH = 'parse/fetch'; +export const CLEAR = 'parse/clear'; + +// +// Action Creators + +export const fetch = createThunk(FETCH); +export const clear = createAction(CLEAR); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH]: function ( + _getState: () => AppState, + payload: FetchPayload, + dispatch: Dispatch + ) { + if (parseTimeout) { + clearTimeout(parseTimeout); + } + + parseTimeout = window.setTimeout(async () => { + dispatch(set({ section, isFetching: true })); + + if (abortCurrentRequest) { + abortCurrentRequest(); + } + + const { request, abortRequest } = createAjaxRequest({ + url: '/parse', + data: { + title: payload.title, + }, + }); + + try { + const data = await request; + + dispatch( + batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null, + }), + ]) + ); + } catch (error) { + dispatch( + set({ + section, + isAdding: false, + isAdded: false, + addError: error, + }) + ); + } + + abortCurrentRequest = abortRequest; + }, 300); + }, +}); + +// +// Reducers + +export const reducers = createHandleActions( + { + [CLEAR]: createClearReducer(section, defaultState), + }, + defaultState, + section +); diff --git a/frontend/src/Store/Actions/queueActions.js b/frontend/src/Store/Actions/queueActions.js index bdf84818d..4bf200a5c 100644 --- a/frontend/src/Store/Actions/queueActions.js +++ b/frontend/src/Store/Actions/queueActions.js @@ -412,13 +412,14 @@ export const actionHandlers = handleThunks({ id, removeFromClient, blocklist, - skipRedownload + skipRedownload, + changeCategory } = payload; dispatch(updateItem({ section: paged, id, isRemoving: true })); const promise = createAjaxRequest({ - url: `/queue/${id}?removeFromClient=${removeFromClient}&blocklist=${blocklist}&skipRedownload=${skipRedownload}`, + url: `/queue/${id}?removeFromClient=${removeFromClient}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`, method: 'DELETE' }).request; @@ -436,7 +437,8 @@ export const actionHandlers = handleThunks({ ids, removeFromClient, blocklist, - skipRedownload + skipRedownload, + changeCategory } = payload; dispatch(batchActions([ @@ -452,7 +454,7 @@ export const actionHandlers = handleThunks({ ])); const promise = createAjaxRequest({ - url: `/queue/bulk?removeFromClient=${removeFromClient}&blocklist=${blocklist}&skipRedownload=${skipRedownload}`, + url: `/queue/bulk?removeFromClient=${removeFromClient}&blocklist=${blocklist}&skipRedownload=${skipRedownload}&changeCategory=${changeCategory}`, method: 'DELETE', dataType: 'json', contentType: 'application/json', diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js index 1c9b6f5ef..c4955c915 100644 --- a/frontend/src/Store/Actions/releaseActions.js +++ b/frontend/src/Store/Actions/releaseActions.js @@ -219,8 +219,9 @@ export const defaultState = { }; export const persistState = [ - 'releases.selectedFilterKey', + 'releases.album.selectedFilterKey', 'releases.album.customFilters', + 'releases.artist.selectedFilterKey', 'releases.artist.customFilters' ]; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index b787110c1..54b059083 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -11,6 +11,7 @@ import downloadClients from './Settings/downloadClients'; import general from './Settings/general'; import importListExclusions from './Settings/importListExclusions'; import importLists from './Settings/importLists'; +import indexerFlags from './Settings/indexerFlags'; import indexerOptions from './Settings/indexerOptions'; import indexers from './Settings/indexers'; import languages from './Settings/languages'; @@ -38,6 +39,7 @@ export * from './Settings/downloadClientOptions'; export * from './Settings/general'; export * from './Settings/importLists'; export * from './Settings/importListExclusions'; +export * from './Settings/indexerFlags'; export * from './Settings/indexerOptions'; export * from './Settings/indexers'; export * from './Settings/languages'; @@ -73,6 +75,7 @@ export const defaultState = { downloadClients: downloadClients.defaultState, downloadClientOptions: downloadClientOptions.defaultState, general: general.defaultState, + indexerFlags: indexerFlags.defaultState, indexerOptions: indexerOptions.defaultState, indexers: indexers.defaultState, importLists: importLists.defaultState, @@ -119,6 +122,7 @@ export const actionHandlers = handleThunks({ ...downloadClients.actionHandlers, ...downloadClientOptions.actionHandlers, ...general.actionHandlers, + ...indexerFlags.actionHandlers, ...indexerOptions.actionHandlers, ...indexers.actionHandlers, ...importLists.actionHandlers, @@ -156,6 +160,7 @@ export const reducers = createHandleActions({ ...downloadClients.reducers, ...downloadClientOptions.reducers, ...general.reducers, + ...indexerFlags.reducers, ...indexerOptions.reducers, ...indexers.reducers, ...importLists.reducers, diff --git a/frontend/src/Store/Actions/trackActions.js b/frontend/src/Store/Actions/trackActions.js index bd1f472c3..a71388c88 100644 --- a/frontend/src/Store/Actions/trackActions.js +++ b/frontend/src/Store/Actions/trackActions.js @@ -77,6 +77,15 @@ export const defaultState = { }), isVisible: false }, + { + name: 'indexerFlags', + columnLabel: () => translate('IndexerFlags'), + label: React.createElement(Icon, { + name: icons.FLAG, + title: () => translate('IndexerFlags') + }), + isVisible: false + }, { name: 'status', label: () => translate('Status'), diff --git a/frontend/src/Store/Actions/wantedActions.js b/frontend/src/Store/Actions/wantedActions.js index 35aa162d4..61d6f7752 100644 --- a/frontend/src/Store/Actions/wantedActions.js +++ b/frontend/src/Store/Actions/wantedActions.js @@ -52,6 +52,12 @@ export const defaultState = { isSortable: true, isVisible: true }, + { + name: 'albums.lastSearchTime', + label: () => translate('LastSearched'), + isSortable: true, + isVisible: false + }, // { // name: 'status', // label: 'Status', @@ -131,6 +137,12 @@ export const defaultState = { // label: 'Status', // isVisible: true // }, + { + name: 'albums.lastSearchTime', + label: () => translate('LastSearched'), + isSortable: true, + isVisible: false + }, { name: 'actions', columnLabel: () => translate('Actions'), diff --git a/frontend/src/Store/Selectors/createAllArtistSelector.js b/frontend/src/Store/Selectors/createAllArtistSelector.ts similarity index 71% rename from frontend/src/Store/Selectors/createAllArtistSelector.js rename to frontend/src/Store/Selectors/createAllArtistSelector.ts index 38b1bcef1..6b6010429 100644 --- a/frontend/src/Store/Selectors/createAllArtistSelector.js +++ b/frontend/src/Store/Selectors/createAllArtistSelector.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createAllArtistSelector() { return createSelector( - (state) => state.artist, + (state: AppState) => state.artist, (artist) => { return artist.items; } diff --git a/frontend/src/Store/Selectors/createArtistAlbumsSelector.ts b/frontend/src/Store/Selectors/createArtistAlbumsSelector.ts index 2ae54a10c..414a451f5 100644 --- a/frontend/src/Store/Selectors/createArtistAlbumsSelector.ts +++ b/frontend/src/Store/Selectors/createArtistAlbumsSelector.ts @@ -1,4 +1,5 @@ import { createSelector } from 'reselect'; +import AlbumAppState from 'App/State/AlbumAppState'; import AppState from 'App/State/AppState'; import Artist from 'Artist/Artist'; import { createArtistSelectorForHook } from './createArtistSelector'; @@ -7,11 +8,11 @@ function createArtistAlbumsSelector(artistId: number) { return createSelector( (state: AppState) => state.albums, createArtistSelectorForHook(artistId), - (albums, artist = {} as Artist) => { + (albums: AlbumAppState, artist = {} as Artist) => { const { isFetching, isPopulated, error, items } = albums; const filteredAlbums = items.filter( - (album) => album.artist.artistMetadataId === artist.artistMetadataId + (album) => album.artistId === artist.id ); return { diff --git a/frontend/src/Store/Selectors/createArtistCountSelector.js b/frontend/src/Store/Selectors/createArtistCountSelector.ts similarity index 65% rename from frontend/src/Store/Selectors/createArtistCountSelector.js rename to frontend/src/Store/Selectors/createArtistCountSelector.ts index 31e0a39fc..b432d64a7 100644 --- a/frontend/src/Store/Selectors/createArtistCountSelector.js +++ b/frontend/src/Store/Selectors/createArtistCountSelector.ts @@ -1,18 +1,19 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; import createAllArtistSelector from './createAllArtistSelector'; function createArtistCountSelector() { return createSelector( createAllArtistSelector(), - (state) => state.artist.error, - (state) => state.artist.isFetching, - (state) => state.artist.isPopulated, + (state: AppState) => state.artist.error, + (state: AppState) => state.artist.isFetching, + (state: AppState) => state.artist.isPopulated, (artists, error, isFetching, isPopulated) => { return { count: artists.length, error, isFetching, - isPopulated + isPopulated, }; } ); diff --git a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts index 0acbd3997..fa60d936d 100644 --- a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts +++ b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts @@ -1,13 +1,14 @@ import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; import Artist from 'Artist/Artist'; +import MetadataProfile from 'typings/MetadataProfile'; import { createArtistSelectorForHook } from './createArtistSelector'; function createArtistMetadataProfileSelector(artistId: number) { return createSelector( (state: AppState) => state.settings.metadataProfiles.items, createArtistSelectorForHook(artistId), - (metadataProfiles, artist = {} as Artist) => { + (metadataProfiles: MetadataProfile[], artist = {} as Artist) => { return metadataProfiles.find((profile) => { return profile.id === artist.metadataProfileId; }); diff --git a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts index 99325276f..67639919b 100644 --- a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts +++ b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts @@ -1,13 +1,14 @@ import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; import Artist from 'Artist/Artist'; +import QualityProfile from 'typings/QualityProfile'; import { createArtistSelectorForHook } from './createArtistSelector'; function createArtistQualityProfileSelector(artistId: number) { return createSelector( (state: AppState) => state.settings.qualityProfiles.items, createArtistSelectorForHook(artistId), - (qualityProfiles, artist = {} as Artist) => { + (qualityProfiles: QualityProfile[], artist = {} as Artist) => { return qualityProfiles.find( (profile) => profile.id === artist.qualityProfileId ); diff --git a/frontend/src/Store/Selectors/createCommandExecutingSelector.js b/frontend/src/Store/Selectors/createCommandExecutingSelector.ts similarity index 50% rename from frontend/src/Store/Selectors/createCommandExecutingSelector.js rename to frontend/src/Store/Selectors/createCommandExecutingSelector.ts index 6037d5820..6a80e172b 100644 --- a/frontend/src/Store/Selectors/createCommandExecutingSelector.js +++ b/frontend/src/Store/Selectors/createCommandExecutingSelector.ts @@ -2,13 +2,10 @@ import { createSelector } from 'reselect'; import { isCommandExecuting } from 'Utilities/Command'; import createCommandSelector from './createCommandSelector'; -function createCommandExecutingSelector(name, contraints = {}) { - return createSelector( - createCommandSelector(name, contraints), - (command) => { - return isCommandExecuting(command); - } - ); +function createCommandExecutingSelector(name: string, contraints = {}) { + return createSelector(createCommandSelector(name, contraints), (command) => { + return isCommandExecuting(command); + }); } export default createCommandExecutingSelector; diff --git a/frontend/src/Store/Selectors/createCommandSelector.js b/frontend/src/Store/Selectors/createCommandSelector.js deleted file mode 100644 index 709dfebaf..000000000 --- a/frontend/src/Store/Selectors/createCommandSelector.js +++ /dev/null @@ -1,14 +0,0 @@ -import { createSelector } from 'reselect'; -import { findCommand } from 'Utilities/Command'; -import createCommandsSelector from './createCommandsSelector'; - -function createCommandSelector(name, contraints = {}) { - return createSelector( - createCommandsSelector(), - (commands) => { - return findCommand(commands, { name, ...contraints }); - } - ); -} - -export default createCommandSelector; diff --git a/frontend/src/Store/Selectors/createCommandSelector.ts b/frontend/src/Store/Selectors/createCommandSelector.ts new file mode 100644 index 000000000..cced7b186 --- /dev/null +++ b/frontend/src/Store/Selectors/createCommandSelector.ts @@ -0,0 +1,11 @@ +import { createSelector } from 'reselect'; +import { findCommand } from 'Utilities/Command'; +import createCommandsSelector from './createCommandsSelector'; + +function createCommandSelector(name: string, contraints = {}) { + return createSelector(createCommandsSelector(), (commands) => { + return findCommand(commands, { name, ...contraints }); + }); +} + +export default createCommandSelector; diff --git a/frontend/src/Store/Selectors/createCommandsSelector.js b/frontend/src/Store/Selectors/createCommandsSelector.ts similarity index 71% rename from frontend/src/Store/Selectors/createCommandsSelector.js rename to frontend/src/Store/Selectors/createCommandsSelector.ts index 7b9edffd9..2dd5d24a2 100644 --- a/frontend/src/Store/Selectors/createCommandsSelector.js +++ b/frontend/src/Store/Selectors/createCommandsSelector.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createCommandsSelector() { return createSelector( - (state) => state.commands, + (state: AppState) => state.commands, (commands) => { return commands.items; } diff --git a/frontend/src/Store/Selectors/createDeepEqualSelector.js b/frontend/src/Store/Selectors/createDeepEqualSelector.js deleted file mode 100644 index 85562f28b..000000000 --- a/frontend/src/Store/Selectors/createDeepEqualSelector.js +++ /dev/null @@ -1,9 +0,0 @@ -import _ from 'lodash'; -import { createSelectorCreator, defaultMemoize } from 'reselect'; - -const createDeepEqualSelector = createSelectorCreator( - defaultMemoize, - _.isEqual -); - -export default createDeepEqualSelector; diff --git a/frontend/src/Store/Selectors/createDeepEqualSelector.ts b/frontend/src/Store/Selectors/createDeepEqualSelector.ts new file mode 100644 index 000000000..9d4a63d2e --- /dev/null +++ b/frontend/src/Store/Selectors/createDeepEqualSelector.ts @@ -0,0 +1,6 @@ +import { isEqual } from 'lodash'; +import { createSelectorCreator, defaultMemoize } from 'reselect'; + +const createDeepEqualSelector = createSelectorCreator(defaultMemoize, isEqual); + +export default createDeepEqualSelector; diff --git a/frontend/src/Store/Selectors/createExecutingCommandsSelector.js b/frontend/src/Store/Selectors/createExecutingCommandsSelector.ts similarity index 78% rename from frontend/src/Store/Selectors/createExecutingCommandsSelector.js rename to frontend/src/Store/Selectors/createExecutingCommandsSelector.ts index 266865a8a..dd16571fc 100644 --- a/frontend/src/Store/Selectors/createExecutingCommandsSelector.js +++ b/frontend/src/Store/Selectors/createExecutingCommandsSelector.ts @@ -1,9 +1,10 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; import { isCommandExecuting } from 'Utilities/Command'; function createExecutingCommandsSelector() { return createSelector( - (state) => state.commands.items, + (state: AppState) => state.commands.items, (commands) => { return commands.filter((command) => isCommandExecuting(command)); } diff --git a/frontend/src/Store/Selectors/createExistingArtistSelector.js b/frontend/src/Store/Selectors/createExistingArtistSelector.ts similarity index 58% rename from frontend/src/Store/Selectors/createExistingArtistSelector.js rename to frontend/src/Store/Selectors/createExistingArtistSelector.ts index 4811f2034..91b5bc4d6 100644 --- a/frontend/src/Store/Selectors/createExistingArtistSelector.js +++ b/frontend/src/Store/Selectors/createExistingArtistSelector.ts @@ -1,13 +1,15 @@ -import _ from 'lodash'; +import { some } from 'lodash'; import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; import createAllArtistSelector from './createAllArtistSelector'; function createExistingArtistSelector() { return createSelector( - (state, { foreignArtistId }) => foreignArtistId, + (_: AppState, { foreignArtistId }: { foreignArtistId: string }) => + foreignArtistId, createAllArtistSelector(), (foreignArtistId, artist) => { - return _.some(artist, { foreignArtistId }); + return some(artist, { foreignArtistId }); } ); } diff --git a/frontend/src/Store/Selectors/createIndexerFlagsSelector.ts b/frontend/src/Store/Selectors/createIndexerFlagsSelector.ts new file mode 100644 index 000000000..90587639c --- /dev/null +++ b/frontend/src/Store/Selectors/createIndexerFlagsSelector.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const createIndexerFlagsSelector = createSelector( + (state: AppState) => state.settings.indexerFlags, + (indexerFlags) => indexerFlags +); + +export default createIndexerFlagsSelector; diff --git a/frontend/src/Store/Selectors/createMetadataProfileSelector.js b/frontend/src/Store/Selectors/createMetadataProfileSelector.js deleted file mode 100644 index bdd0d0636..000000000 --- a/frontend/src/Store/Selectors/createMetadataProfileSelector.js +++ /dev/null @@ -1,15 +0,0 @@ -import { createSelector } from 'reselect'; - -function createMetadataProfileSelector() { - return createSelector( - (state, { metadataProfileId }) => metadataProfileId, - (state) => state.settings.metadataProfiles.items, - (metadataProfileId, metadataProfiles) => { - return metadataProfiles.find((profile) => { - return profile.id === metadataProfileId; - }); - } - ); -} - -export default createMetadataProfileSelector; diff --git a/frontend/src/Store/Selectors/createMetadataProfileSelector.ts b/frontend/src/Store/Selectors/createMetadataProfileSelector.ts new file mode 100644 index 000000000..ae4c061db --- /dev/null +++ b/frontend/src/Store/Selectors/createMetadataProfileSelector.ts @@ -0,0 +1,17 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +function createMetadataProfileSelector() { + return createSelector( + (_: AppState, { metadataProfileId }: { metadataProfileId: number }) => + metadataProfileId, + (state: AppState) => state.settings.metadataProfiles.items, + (metadataProfileId, metadataProfiles) => { + return metadataProfiles.find( + (profile) => profile.id === metadataProfileId + ); + } + ); +} + +export default createMetadataProfileSelector; diff --git a/frontend/src/Store/Selectors/createMultiArtistsSelector.ts b/frontend/src/Store/Selectors/createMultiArtistsSelector.ts new file mode 100644 index 000000000..d8f7ea92b --- /dev/null +++ b/frontend/src/Store/Selectors/createMultiArtistsSelector.ts @@ -0,0 +1,23 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import Artist from 'Artist/Artist'; + +function createMultiArtistsSelector(artistIds: number[]) { + return createSelector( + (state: AppState) => state.artist.itemMap, + (state: AppState) => state.artist.items, + (itemMap, allArtists) => { + return artistIds.reduce((acc: Artist[], artistId) => { + const artist = allArtists[itemMap[artistId]]; + + if (artist) { + acc.push(artist); + } + + return acc; + }, []); + } + ); +} + +export default createMultiArtistsSelector; diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.js b/frontend/src/Store/Selectors/createProfileInUseSelector.js deleted file mode 100644 index 84fefb83e..000000000 --- a/frontend/src/Store/Selectors/createProfileInUseSelector.js +++ /dev/null @@ -1,24 +0,0 @@ -import _ from 'lodash'; -import { createSelector } from 'reselect'; -import createAllArtistSelector from './createAllArtistSelector'; - -function createProfileInUseSelector(profileProp) { - return createSelector( - (state, { id }) => id, - createAllArtistSelector(), - (state) => state.settings.importLists.items, - (id, artist, lists) => { - if (!id) { - return false; - } - - if (_.some(artist, { [profileProp]: id }) || _.some(lists, { [profileProp]: id })) { - return true; - } - - return false; - } - ); -} - -export default createProfileInUseSelector; diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.ts b/frontend/src/Store/Selectors/createProfileInUseSelector.ts new file mode 100644 index 000000000..85f0c3211 --- /dev/null +++ b/frontend/src/Store/Selectors/createProfileInUseSelector.ts @@ -0,0 +1,25 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import Artist from 'Artist/Artist'; +import ImportList from 'typings/ImportList'; +import createAllArtistSelector from './createAllArtistSelector'; + +function createProfileInUseSelector(profileProp: string) { + return createSelector( + (_: AppState, { id }: { id: number }) => id, + createAllArtistSelector(), + (state: AppState) => state.settings.importLists.items, + (id, artists, lists) => { + if (!id) { + return false; + } + + return ( + artists.some((a) => a[profileProp as keyof Artist] === id) || + lists.some((list) => list[profileProp as keyof ImportList] === id) + ); + } + ); +} + +export default createProfileInUseSelector; diff --git a/frontend/src/Store/Selectors/createQualityProfileSelector.js b/frontend/src/Store/Selectors/createQualityProfileSelector.js deleted file mode 100644 index 611dfc903..000000000 --- a/frontend/src/Store/Selectors/createQualityProfileSelector.js +++ /dev/null @@ -1,26 +0,0 @@ -import { createSelector } from 'reselect'; - -export function createQualityProfileSelectorForHook(qualityProfileId) { - return createSelector( - (state) => state.settings.qualityProfiles.items, - (qualityProfiles) => { - return qualityProfiles.find((profile) => { - return profile.id === qualityProfileId; - }); - } - ); -} - -function createQualityProfileSelector() { - return createSelector( - (state, { qualityProfileId }) => qualityProfileId, - (state) => state.settings.qualityProfiles.items, - (qualityProfileId, qualityProfiles) => { - return qualityProfiles.find((profile) => { - return profile.id === qualityProfileId; - }); - } - ); -} - -export default createQualityProfileSelector; diff --git a/frontend/src/Store/Selectors/createQualityProfileSelector.ts b/frontend/src/Store/Selectors/createQualityProfileSelector.ts new file mode 100644 index 000000000..b913e0c46 --- /dev/null +++ b/frontend/src/Store/Selectors/createQualityProfileSelector.ts @@ -0,0 +1,24 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +export function createQualityProfileSelectorForHook(qualityProfileId: number) { + return createSelector( + (state: AppState) => state.settings.qualityProfiles.items, + (qualityProfiles) => { + return qualityProfiles.find((profile) => profile.id === qualityProfileId); + } + ); +} + +function createQualityProfileSelector() { + return createSelector( + (_: AppState, { qualityProfileId }: { qualityProfileId: number }) => + qualityProfileId, + (state: AppState) => state.settings.qualityProfiles.items, + (qualityProfileId, qualityProfiles) => { + return qualityProfiles.find((profile) => profile.id === qualityProfileId); + } + ); +} + +export default createQualityProfileSelector; diff --git a/frontend/src/Store/Selectors/createQueueItemSelector.js b/frontend/src/Store/Selectors/createQueueItemSelector.ts similarity index 52% rename from frontend/src/Store/Selectors/createQueueItemSelector.js rename to frontend/src/Store/Selectors/createQueueItemSelector.ts index c85d7ed82..54951a724 100644 --- a/frontend/src/Store/Selectors/createQueueItemSelector.js +++ b/frontend/src/Store/Selectors/createQueueItemSelector.ts @@ -1,21 +1,16 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createQueueItemSelector() { return createSelector( - (state, { albumId }) => albumId, - (state) => state.queue.details.items, + (_: AppState, { albumId }: { albumId: number }) => albumId, + (state: AppState) => state.queue.details.items, (albumId, details) => { if (!albumId || !details) { return null; } - return details.find((item) => { - if (item.album) { - return item.album.id === albumId; - } - - return false; - }); + return details.find((item) => item.albumId === albumId); } ); } diff --git a/frontend/src/Store/Selectors/createRootFoldersSelector.ts b/frontend/src/Store/Selectors/createRootFoldersSelector.ts new file mode 100644 index 000000000..432f9056d --- /dev/null +++ b/frontend/src/Store/Selectors/createRootFoldersSelector.ts @@ -0,0 +1,15 @@ +import { createSelector } from 'reselect'; +import { RootFolderAppState } from 'App/State/SettingsAppState'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import RootFolder from 'typings/RootFolder'; +import sortByProp from 'Utilities/Array/sortByProp'; + +export default function createRootFoldersSelector() { + return createSelector( + createSortedSectionSelector( + 'settings.rootFolders', + sortByProp('name') + ), + (rootFolders: RootFolderAppState) => rootFolders + ); +} diff --git a/frontend/src/Store/Selectors/createSortedSectionSelector.js b/frontend/src/Store/Selectors/createSortedSectionSelector.ts similarity index 68% rename from frontend/src/Store/Selectors/createSortedSectionSelector.js rename to frontend/src/Store/Selectors/createSortedSectionSelector.ts index 331d890c9..abee01f75 100644 --- a/frontend/src/Store/Selectors/createSortedSectionSelector.js +++ b/frontend/src/Store/Selectors/createSortedSectionSelector.ts @@ -1,14 +1,18 @@ import { createSelector } from 'reselect'; import getSectionState from 'Utilities/State/getSectionState'; -function createSortedSectionSelector(section, comparer) { +function createSortedSectionSelector( + section: string, + comparer: (a: T, b: T) => number +) { return createSelector( (state) => state, (state) => { const sectionState = getSectionState(state, section, true); + return { ...sectionState, - items: [...sectionState.items].sort(comparer) + items: [...sectionState.items].sort(comparer), }; } ); diff --git a/frontend/src/Store/Selectors/createSystemStatusSelector.js b/frontend/src/Store/Selectors/createSystemStatusSelector.ts similarity index 70% rename from frontend/src/Store/Selectors/createSystemStatusSelector.js rename to frontend/src/Store/Selectors/createSystemStatusSelector.ts index df586bbb9..f5e276069 100644 --- a/frontend/src/Store/Selectors/createSystemStatusSelector.js +++ b/frontend/src/Store/Selectors/createSystemStatusSelector.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createSystemStatusSelector() { return createSelector( - (state) => state.system.status, + (state: AppState) => state.system.status, (status) => { return status.item; } diff --git a/frontend/src/Store/Selectors/createTagDetailsSelector.js b/frontend/src/Store/Selectors/createTagDetailsSelector.ts similarity index 62% rename from frontend/src/Store/Selectors/createTagDetailsSelector.js rename to frontend/src/Store/Selectors/createTagDetailsSelector.ts index dd178944c..2a271cafe 100644 --- a/frontend/src/Store/Selectors/createTagDetailsSelector.js +++ b/frontend/src/Store/Selectors/createTagDetailsSelector.ts @@ -1,9 +1,10 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createTagDetailsSelector() { return createSelector( - (state, { id }) => id, - (state) => state.tags.details.items, + (_: AppState, { id }: { id: number }) => id, + (state: AppState) => state.tags.details.items, (id, tagDetails) => { return tagDetails.find((t) => t.id === id); } diff --git a/frontend/src/Store/Selectors/createTagsSelector.js b/frontend/src/Store/Selectors/createTagsSelector.ts similarity index 68% rename from frontend/src/Store/Selectors/createTagsSelector.js rename to frontend/src/Store/Selectors/createTagsSelector.ts index fbfd91cdb..f653ff6e3 100644 --- a/frontend/src/Store/Selectors/createTagsSelector.js +++ b/frontend/src/Store/Selectors/createTagsSelector.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createTagsSelector() { return createSelector( - (state) => state.tags.items, + (state: AppState) => state.tags.items, (tags) => { return tags; } diff --git a/frontend/src/Store/Selectors/createTrackFileSelector.js b/frontend/src/Store/Selectors/createTrackFileSelector.ts similarity index 66% rename from frontend/src/Store/Selectors/createTrackFileSelector.js rename to frontend/src/Store/Selectors/createTrackFileSelector.ts index bcfc5cb0b..a162df1fa 100644 --- a/frontend/src/Store/Selectors/createTrackFileSelector.js +++ b/frontend/src/Store/Selectors/createTrackFileSelector.ts @@ -1,9 +1,10 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createTrackFileSelector() { return createSelector( - (state, { trackFileId }) => trackFileId, - (state) => state.trackFiles, + (_: AppState, { trackFileId }: { trackFileId: number }) => trackFileId, + (state: AppState) => state.trackFiles, (trackFileId, trackFiles) => { if (!trackFileId) { return; diff --git a/frontend/src/Store/Selectors/createUISettingsSelector.js b/frontend/src/Store/Selectors/createUISettingsSelector.ts similarity index 69% rename from frontend/src/Store/Selectors/createUISettingsSelector.js rename to frontend/src/Store/Selectors/createUISettingsSelector.ts index b256d0e98..ff539679b 100644 --- a/frontend/src/Store/Selectors/createUISettingsSelector.js +++ b/frontend/src/Store/Selectors/createUISettingsSelector.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createUISettingsSelector() { return createSelector( - (state) => state.settings.ui, + (state: AppState) => state.settings.ui, (ui) => { return ui.item; } diff --git a/frontend/src/Store/scrollPositions.js b/frontend/src/Store/scrollPositions.js deleted file mode 100644 index 287a58593..000000000 --- a/frontend/src/Store/scrollPositions.js +++ /dev/null @@ -1,5 +0,0 @@ -const scrollPositions = { - artistIndex: 0 -}; - -export default scrollPositions; diff --git a/frontend/src/Store/scrollPositions.ts b/frontend/src/Store/scrollPositions.ts new file mode 100644 index 000000000..199bfa84c --- /dev/null +++ b/frontend/src/Store/scrollPositions.ts @@ -0,0 +1,5 @@ +const scrollPositions: Record = { + artistIndex: 0, +}; + +export default scrollPositions; diff --git a/frontend/src/Store/thunks.js b/frontend/src/Store/thunks.js deleted file mode 100644 index 6daa843f4..000000000 --- a/frontend/src/Store/thunks.js +++ /dev/null @@ -1,27 +0,0 @@ -const thunks = {}; - -function identity(payload) { - return payload; -} - -export function createThunk(type, identityFunction = identity) { - return function(payload = {}) { - return function(dispatch, getState) { - const thunk = thunks[type]; - - if (thunk) { - return thunk(getState, identityFunction(payload), dispatch); - } - - throw Error(`Thunk handler has not been registered for ${type}`); - }; - }; -} - -export function handleThunks(handlers) { - const types = Object.keys(handlers); - - types.forEach((type) => { - thunks[type] = handlers[type]; - }); -} diff --git a/frontend/src/Store/thunks.ts b/frontend/src/Store/thunks.ts new file mode 100644 index 000000000..fd277211e --- /dev/null +++ b/frontend/src/Store/thunks.ts @@ -0,0 +1,39 @@ +import { Dispatch } from 'redux'; +import AppState from 'App/State/AppState'; + +type GetState = () => AppState; +type Thunk = ( + getState: GetState, + identityFn: never, + dispatch: Dispatch +) => unknown; + +const thunks: Record = {}; + +function identity(payload: T): TResult { + return payload as unknown as TResult; +} + +export function createThunk(type: string, identityFunction = identity) { + return function (payload?: T) { + return function (dispatch: Dispatch, getState: GetState) { + const thunk = thunks[type]; + + if (thunk) { + const finalPayload = payload ?? {}; + + return thunk(getState, identityFunction(finalPayload), dispatch); + } + + throw Error(`Thunk handler has not been registered for ${type}`); + }; + }; +} + +export function handleThunks(handlers: Record) { + const types = Object.keys(handlers); + + types.forEach((type) => { + thunks[type] = handlers[type]; + }); +} diff --git a/frontend/src/Styles/Themes/index.js b/frontend/src/Styles/Themes/index.js index d93c5dd8c..4dec39164 100644 --- a/frontend/src/Styles/Themes/index.js +++ b/frontend/src/Styles/Themes/index.js @@ -2,7 +2,7 @@ import * as dark from './dark'; import * as light from './light'; const defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches; -const auto = defaultDark ? { ...dark } : { ...light }; +const auto = defaultDark ? dark : light; export default { auto, diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js index 3b0077c5a..def48f28e 100644 --- a/frontend/src/Styles/Variables/fonts.js +++ b/frontend/src/Styles/Variables/fonts.js @@ -2,7 +2,6 @@ module.exports = { // Families defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif', monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;', - passwordFamily: 'text-security-disc', // Sizes extraSmallFontSize: '11px', diff --git a/frontend/src/System/Logs/Files/LogFiles.js b/frontend/src/System/Logs/Files/LogFiles.js index 83736c617..5339a8590 100644 --- a/frontend/src/System/Logs/Files/LogFiles.js +++ b/frontend/src/System/Logs/Files/LogFiles.js @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import Alert from 'Components/Alert'; -import Link from 'Components/Link/Link'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; @@ -77,15 +77,16 @@ class LogFiles extends Component {
- Log files are located in: {location} + {translate('LogFilesLocation', { + location + })}
- { - currentLogView === 'Log Files' && -
- The log level defaults to 'Info' and can be changed in General Settings -
- } + {currentLogView === 'Log Files' ? ( +
+ +
+ ) : null}
{ diff --git a/frontend/src/System/Status/About/About.js b/frontend/src/System/Status/About/About.js index 4635fef68..8af5b4717 100644 --- a/frontend/src/System/Status/About/About.js +++ b/frontend/src/System/Status/About/About.js @@ -22,9 +22,9 @@ class About extends Component { isNetCore, isDocker, runtimeVersion, - migrationVersion, databaseVersion, databaseType, + migrationVersion, appData, startupPath, mode, @@ -66,13 +66,13 @@ class About extends Component { } { - this.setState(getFormattedDates(this.props)); - this.setUpdateTimer(); - }, 30000); - } - - // - // Listeners - - onCancelPress = () => { - this.setState({ - isCancelConfirmModalOpen: true - }); - }; - - onAbortCancel = () => { - this.setState({ - isCancelConfirmModalOpen: false - }); - }; - - // - // Render - - render() { - const { - trigger, - commandName, - queued, - started, - ended, - status, - duration, - message, - clientUserAgent, - longDateFormat, - timeFormat, - onCancelPress - } = this.props; - - const { - queuedAt, - startedAt, - endedAt, - isCancelConfirmModalOpen - } = this.state; - - let triggerIcon = icons.QUICK; - - if (trigger === 'manual') { - triggerIcon = icons.INTERACTIVE; - } else if (trigger === 'scheduled') { - triggerIcon = icons.SCHEDULED; - } - - return ( - - - - - - - - - - - - {commandName} - - { - clientUserAgent ? - - from: {clientUserAgent} - : - null - } - - - - {queuedAt} - - - - {startedAt} - - - - {endedAt} - - - - {formatTimeSpan(duration)} - - - - { - status === 'queued' && - - } - - - - - ); - } -} - -QueuedTaskRow.propTypes = { - trigger: PropTypes.string.isRequired, - commandName: PropTypes.string.isRequired, - queued: PropTypes.string.isRequired, - started: PropTypes.string, - ended: PropTypes.string, - status: PropTypes.string.isRequired, - duration: PropTypes.string, - message: PropTypes.string, - clientUserAgent: PropTypes.string, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onCancelPress: PropTypes.func.isRequired -}; - -export default QueuedTaskRow; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx b/frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx new file mode 100644 index 000000000..4511bcbf4 --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRow.tsx @@ -0,0 +1,238 @@ +import moment from 'moment'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { CommandBody } from 'Commands/Command'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRow from 'Components/Table/TableRow'; +import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; +import { icons, kinds } from 'Helpers/Props'; +import { cancelCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import titleCase from 'Utilities/String/titleCase'; +import translate from 'Utilities/String/translate'; +import QueuedTaskRowNameCell from './QueuedTaskRowNameCell'; +import styles from './QueuedTaskRow.css'; + +function getStatusIconProps(status: string, message: string | undefined) { + const title = titleCase(status); + + switch (status) { + case 'queued': + return { + name: icons.PENDING, + title, + }; + + case 'started': + return { + name: icons.REFRESH, + isSpinning: true, + title, + }; + + case 'completed': + return { + name: icons.CHECK, + kind: kinds.SUCCESS, + title: message === 'Completed' ? title : `${title}: ${message}`, + }; + + case 'failed': + return { + name: icons.FATAL, + kind: kinds.DANGER, + title: `${title}: ${message}`, + }; + + default: + return { + name: icons.UNKNOWN, + title, + }; + } +} + +function getFormattedDates( + queued: string, + started: string | undefined, + ended: string | undefined, + showRelativeDates: boolean, + shortDateFormat: string +) { + if (showRelativeDates) { + return { + queuedAt: moment(queued).fromNow(), + startedAt: started ? moment(started).fromNow() : '-', + endedAt: ended ? moment(ended).fromNow() : '-', + }; + } + + return { + queuedAt: formatDate(queued, shortDateFormat), + startedAt: started ? formatDate(started, shortDateFormat) : '-', + endedAt: ended ? formatDate(ended, shortDateFormat) : '-', + }; +} + +interface QueuedTimes { + queuedAt: string; + startedAt: string; + endedAt: string; +} + +export interface QueuedTaskRowProps { + id: number; + trigger: string; + commandName: string; + queued: string; + started?: string; + ended?: string; + status: string; + duration?: string; + message?: string; + body: CommandBody; + clientUserAgent?: string; +} + +export default function QueuedTaskRow(props: QueuedTaskRowProps) { + const { + id, + trigger, + commandName, + queued, + started, + ended, + status, + duration, + message, + body, + clientUserAgent, + } = props; + + const dispatch = useDispatch(); + const { longDateFormat, shortDateFormat, showRelativeDates, timeFormat } = + useSelector(createUISettingsSelector()); + + const updateTimeTimeoutId = useRef | null>( + null + ); + const [times, setTimes] = useState( + getFormattedDates( + queued, + started, + ended, + showRelativeDates, + shortDateFormat + ) + ); + + const [ + isCancelConfirmModalOpen, + openCancelConfirmModal, + closeCancelConfirmModal, + ] = useModalOpenState(false); + + const handleCancelPress = useCallback(() => { + dispatch(cancelCommand({ id })); + }, [id, dispatch]); + + useEffect(() => { + updateTimeTimeoutId.current = setTimeout(() => { + setTimes( + getFormattedDates( + queued, + started, + ended, + showRelativeDates, + shortDateFormat + ) + ); + }, 30000); + + return () => { + if (updateTimeTimeoutId.current) { + clearTimeout(updateTimeTimeoutId.current); + } + }; + }, [queued, started, ended, showRelativeDates, shortDateFormat, setTimes]); + + const { queuedAt, startedAt, endedAt } = times; + + let triggerIcon = icons.QUICK; + + if (trigger === 'manual') { + triggerIcon = icons.INTERACTIVE; + } else if (trigger === 'scheduled') { + triggerIcon = icons.SCHEDULED; + } + + return ( + + + + + + + + + + + + + {queuedAt} + + + + {startedAt} + + + + {endedAt} + + + + {formatTimeSpan(duration)} + + + + {status === 'queued' && ( + + )} + + + + + ); +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js b/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js deleted file mode 100644 index f55ab985a..000000000 --- a/frontend/src/System/Tasks/Queued/QueuedTaskRowConnector.js +++ /dev/null @@ -1,31 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { cancelCommand } from 'Store/Actions/commandActions'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import QueuedTaskRow from './QueuedTaskRow'; - -function createMapStateToProps() { - return createSelector( - createUISettingsSelector(), - (uiSettings) => { - return { - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onCancelPress() { - dispatch(cancelCommand({ - id: props.id - })); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(QueuedTaskRow); diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css new file mode 100644 index 000000000..41acb33f8 --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css @@ -0,0 +1,8 @@ +.commandName { + display: inline-block; + min-width: 220px; +} + +.userAgent { + color: #b0b0b0; +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css.d.ts b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css.d.ts new file mode 100644 index 000000000..fc9081492 --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'commandName': string; + 'userAgent': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx new file mode 100644 index 000000000..41a307d5f --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTaskRowNameCell.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { CommandBody } from 'Commands/Command'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import createMultiArtistsSelector from 'Store/Selectors/createMultiArtistsSelector'; +import sortByProp from 'Utilities/Array/sortByProp'; +import translate from 'Utilities/String/translate'; +import styles from './QueuedTaskRowNameCell.css'; + +function formatTitles(titles: string[]) { + if (!titles) { + return null; + } + + if (titles.length > 11) { + return ( + + {titles.slice(0, 10).join(', ')}, {titles.length - 10} more + + ); + } + + return {titles.join(', ')}; +} + +export interface QueuedTaskRowNameCellProps { + commandName: string; + body: CommandBody; + clientUserAgent?: string; +} + +export default function QueuedTaskRowNameCell( + props: QueuedTaskRowNameCellProps +) { + const { commandName, body, clientUserAgent } = props; + const movieIds = [...(body.artistIds ?? [])]; + + if (body.artistId) { + movieIds.push(body.artistId); + } + + const artists = useSelector(createMultiArtistsSelector(movieIds)); + const sortedArtists = artists.sort(sortByProp('sortName')); + + return ( + + + {commandName} + {sortedArtists.length ? ( + - {formatTitles(sortedArtists.map((a) => a.artistName))} + ) : null} + + + {clientUserAgent ? ( + + {translate('From')}: {clientUserAgent} + + ) : null} + + ); +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.js b/frontend/src/System/Tasks/Queued/QueuedTasks.js deleted file mode 100644 index dac38f1d4..000000000 --- a/frontend/src/System/Tasks/Queued/QueuedTasks.js +++ /dev/null @@ -1,90 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import FieldSet from 'Components/FieldSet'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import translate from 'Utilities/String/translate'; -import QueuedTaskRowConnector from './QueuedTaskRowConnector'; - -const columns = [ - { - name: 'trigger', - label: '', - isVisible: true - }, - { - name: 'commandName', - label: () => translate('Name'), - isVisible: true - }, - { - name: 'queued', - label: () => translate('Queued'), - isVisible: true - }, - { - name: 'started', - label: () => translate('Started'), - isVisible: true - }, - { - name: 'ended', - label: () => translate('Ended'), - isVisible: true - }, - { - name: 'duration', - label: () => translate('Duration'), - isVisible: true - }, - { - name: 'actions', - isVisible: true - } -]; - -function QueuedTasks(props) { - const { - isFetching, - isPopulated, - items - } = props; - - return ( -
- { - isFetching && !isPopulated && - - } - - { - isPopulated && -
- - { - items.map((item) => { - return ( - - ); - }) - } - -
- } -
- ); -} - -QueuedTasks.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - items: PropTypes.array.isRequired -}; - -export default QueuedTasks; diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.tsx b/frontend/src/System/Tasks/Queued/QueuedTasks.tsx new file mode 100644 index 000000000..e79deed7c --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTasks.tsx @@ -0,0 +1,74 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import { fetchCommands } from 'Store/Actions/commandActions'; +import translate from 'Utilities/String/translate'; +import QueuedTaskRow from './QueuedTaskRow'; + +const columns = [ + { + name: 'trigger', + label: '', + isVisible: true, + }, + { + name: 'commandName', + label: () => translate('Name'), + isVisible: true, + }, + { + name: 'queued', + label: () => translate('Queued'), + isVisible: true, + }, + { + name: 'started', + label: () => translate('Started'), + isVisible: true, + }, + { + name: 'ended', + label: () => translate('Ended'), + isVisible: true, + }, + { + name: 'duration', + label: () => translate('Duration'), + isVisible: true, + }, + { + name: 'actions', + isVisible: true, + }, +]; + +export default function QueuedTasks() { + const dispatch = useDispatch(); + const { isFetching, isPopulated, items } = useSelector( + (state: AppState) => state.commands + ); + + useEffect(() => { + dispatch(fetchCommands()); + }, [dispatch]); + + return ( +
+ {isFetching && !isPopulated && } + + {isPopulated && ( + + + {items.map((item) => { + return ; + })} + +
+ )} +
+ ); +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js deleted file mode 100644 index 5fa4d9ead..000000000 --- a/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchCommands } from 'Store/Actions/commandActions'; -import QueuedTasks from './QueuedTasks'; - -function createMapStateToProps() { - return createSelector( - (state) => state.commands, - (commands) => { - return commands; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchCommands: fetchCommands -}; - -class QueuedTasksConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchCommands(); - } - - // - // Render - - render() { - return ( - - ); - } -} - -QueuedTasksConnector.propTypes = { - dispatchFetchCommands: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector); diff --git a/frontend/src/System/Tasks/Tasks.js b/frontend/src/System/Tasks/Tasks.js index 032dbede8..03a3b6ce4 100644 --- a/frontend/src/System/Tasks/Tasks.js +++ b/frontend/src/System/Tasks/Tasks.js @@ -2,7 +2,7 @@ import React from 'react'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import translate from 'Utilities/String/translate'; -import QueuedTasksConnector from './Queued/QueuedTasksConnector'; +import QueuedTasks from './Queued/QueuedTasks'; import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector'; function Tasks() { @@ -10,7 +10,7 @@ function Tasks() { - + ); diff --git a/frontend/src/System/Updates/UpdateChanges.js b/frontend/src/System/Updates/UpdateChanges.js deleted file mode 100644 index 3588069a0..000000000 --- a/frontend/src/System/Updates/UpdateChanges.js +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; -import styles from './UpdateChanges.css'; - -class UpdateChanges extends Component { - - // - // Render - - render() { - const { - title, - changes - } = this.props; - - if (changes.length === 0) { - return null; - } - - return ( -
-
{title}
-
    - { - changes.map((change, index) => { - return ( -
  • - -
  • - ); - }) - } -
-
- ); - } - -} - -UpdateChanges.propTypes = { - title: PropTypes.string.isRequired, - changes: PropTypes.arrayOf(PropTypes.string) -}; - -export default UpdateChanges; diff --git a/frontend/src/System/Updates/UpdateChanges.tsx b/frontend/src/System/Updates/UpdateChanges.tsx new file mode 100644 index 000000000..3e5ba1c9b --- /dev/null +++ b/frontend/src/System/Updates/UpdateChanges.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +import styles from './UpdateChanges.css'; + +interface UpdateChangesProps { + title: string; + changes: string[]; +} + +function UpdateChanges(props: UpdateChangesProps) { + const { title, changes } = props; + + if (changes.length === 0) { + return null; + } + + const uniqueChanges = [...new Set(changes)]; + + return ( +
+
{title}
+
    + {uniqueChanges.map((change, index) => { + const checkChange = change.replace( + /#\d{4,5}\b/g, + (match) => + `[${match}](https://github.com/Lidarr/Lidarr/issues/${match.substring( + 1 + )})` + ); + + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + +export default UpdateChanges; diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js deleted file mode 100644 index 528441cbe..000000000 --- a/frontend/src/System/Updates/Updates.js +++ /dev/null @@ -1,249 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; -import Alert from 'Components/Alert'; -import Icon from 'Components/Icon'; -import Label from 'Components/Label'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import { icons, kinds } from 'Helpers/Props'; -import formatDate from 'Utilities/Date/formatDate'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import translate from 'Utilities/String/translate'; -import UpdateChanges from './UpdateChanges'; -import styles from './Updates.css'; - -class Updates extends Component { - - // - // Render - - render() { - const { - currentVersion, - isFetching, - isPopulated, - updatesError, - generalSettingsError, - items, - isInstallingUpdate, - updateMechanism, - updateMechanismMessage, - shortDateFormat, - longDateFormat, - timeFormat, - onInstallLatestPress - } = this.props; - - const hasError = !!(updatesError || generalSettingsError); - const hasUpdates = isPopulated && !hasError && items.length > 0; - const noUpdates = isPopulated && !hasError && !items.length; - const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true }); - const noUpdateToInstall = hasUpdates && !hasUpdateToInstall; - - const externalUpdaterPrefix = 'Unable to update Lidarr directly,'; - const externalUpdaterMessages = { - external: 'Lidarr is configured to use an external update mechanism', - apt: 'use apt to install the update', - docker: 'update the docker container to receive the update' - }; - - return ( - - - { - !isPopulated && !hasError && - - } - - { - noUpdates && - - {translate('NoUpdatesAreAvailable')} - - } - - { - hasUpdateToInstall && -
- { - updateMechanism === 'builtIn' || updateMechanism === 'script' ? - - Install Latest - : - - - - -
- {externalUpdaterPrefix} -
-
- } - - { - isFetching && - - } -
- } - - { - noUpdateToInstall && -
- -
- The latest version of Lidarr is already installed -
- - { - isFetching && - - } -
- } - - { - hasUpdates && -
- { - items.map((update) => { - const hasChanges = !!update.changes; - - return ( -
-
-
{update.version}
-
-
- {formatDate(update.releaseDate, shortDateFormat)} -
- - { - update.branch === 'master' ? - null : - - } - - { - update.version === currentVersion ? - : - null - } - - { - update.version !== currentVersion && update.installedOn ? - : - null - } -
- - { - !hasChanges && -
- {translate('MaintenanceRelease')} -
- } - - { - hasChanges && -
- - - -
- } -
- ); - }) - } -
- } - - { - !!updatesError && -
- Failed to fetch updates -
- } - - { - !!generalSettingsError && -
- Failed to update settings -
- } -
-
- ); - } - -} - -Updates.propTypes = { - currentVersion: PropTypes.string.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - updatesError: PropTypes.object, - generalSettingsError: PropTypes.object, - items: PropTypes.array.isRequired, - isInstallingUpdate: PropTypes.bool.isRequired, - updateMechanism: PropTypes.string, - updateMechanismMessage: PropTypes.string, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onInstallLatestPress: PropTypes.func.isRequired -}; - -export default Updates; diff --git a/frontend/src/System/Updates/Updates.tsx b/frontend/src/System/Updates/Updates.tsx new file mode 100644 index 000000000..300ab1f99 --- /dev/null +++ b/frontend/src/System/Updates/Updates.tsx @@ -0,0 +1,303 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import { icons, kinds } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchGeneralSettings } from 'Store/Actions/settingsActions'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { UpdateMechanism } from 'typings/Settings/General'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import translate from 'Utilities/String/translate'; +import UpdateChanges from './UpdateChanges'; +import styles from './Updates.css'; + +const VERSION_REGEX = /\d+\.\d+\.\d+\.\d+/i; + +function createUpdatesSelector() { + return createSelector( + (state: AppState) => state.system.updates, + (state: AppState) => state.settings.general, + (updates, generalSettings) => { + const { error: updatesError, items } = updates; + + const isFetching = updates.isFetching || generalSettings.isFetching; + const isPopulated = updates.isPopulated && generalSettings.isPopulated; + + return { + isFetching, + isPopulated, + updatesError, + generalSettingsError: generalSettings.error, + items, + updateMechanism: generalSettings.item.updateMechanism, + }; + } + ); +} + +function Updates() { + const currentVersion = useSelector((state: AppState) => state.app.version); + const { packageUpdateMechanismMessage } = useSelector( + createSystemStatusSelector() + ); + const { shortDateFormat, longDateFormat, timeFormat } = useSelector( + createUISettingsSelector() + ); + const isInstallingUpdate = useSelector( + createCommandExecutingSelector(commandNames.APPLICATION_UPDATE) + ); + + const { + isFetching, + isPopulated, + updatesError, + generalSettingsError, + items, + updateMechanism, + } = useSelector(createUpdatesSelector()); + + const dispatch = useDispatch(); + const [isMajorUpdateModalOpen, setIsMajorUpdateModalOpen] = useState(false); + const hasError = !!(updatesError || generalSettingsError); + const hasUpdates = isPopulated && !hasError && items.length > 0; + const noUpdates = isPopulated && !hasError && !items.length; + + const externalUpdaterPrefix = translate('UpdateAppDirectlyLoadError'); + const externalUpdaterMessages: Partial> = { + external: translate('ExternalUpdater'), + apt: translate('AptUpdater'), + docker: translate('DockerUpdater'), + }; + + const { isMajorUpdate, hasUpdateToInstall } = useMemo(() => { + const majorVersion = parseInt( + currentVersion.match(VERSION_REGEX)?.[0] ?? '0' + ); + + const latestVersion = items[0]?.version; + const latestMajorVersion = parseInt( + latestVersion?.match(VERSION_REGEX)?.[0] ?? '0' + ); + + return { + isMajorUpdate: latestMajorVersion > majorVersion, + hasUpdateToInstall: items.some( + (update) => update.installable && update.latest + ), + }; + }, [currentVersion, items]); + + const noUpdateToInstall = hasUpdates && !hasUpdateToInstall; + + const handleInstallLatestPress = useCallback(() => { + if (isMajorUpdate) { + setIsMajorUpdateModalOpen(true); + } else { + dispatch(executeCommand({ name: commandNames.APPLICATION_UPDATE })); + } + }, [isMajorUpdate, setIsMajorUpdateModalOpen, dispatch]); + + const handleInstallLatestMajorVersionPress = useCallback(() => { + setIsMajorUpdateModalOpen(false); + + dispatch( + executeCommand({ + name: commandNames.APPLICATION_UPDATE, + installMajorUpdate: true, + }) + ); + }, [setIsMajorUpdateModalOpen, dispatch]); + + const handleCancelMajorVersionPress = useCallback(() => { + setIsMajorUpdateModalOpen(false); + }, [setIsMajorUpdateModalOpen]); + + useEffect(() => { + dispatch(fetchUpdates()); + dispatch(fetchGeneralSettings()); + }, [dispatch]); + + return ( + + + {isPopulated || hasError ? null : } + + {noUpdates ? ( + {translate('NoUpdatesAreAvailable')} + ) : null} + + {hasUpdateToInstall ? ( +
+ {updateMechanism === 'builtIn' || updateMechanism === 'script' ? ( + + {translate('InstallLatest')} + + ) : ( + <> + + +
+ {externalUpdaterPrefix}{' '} + +
+ + )} + + {isFetching ? ( + + ) : null} +
+ ) : null} + + {noUpdateToInstall && ( +
+ +
{translate('OnLatestVersion')}
+ + {isFetching && ( + + )} +
+ )} + + {hasUpdates && ( +
+ {items.map((update) => { + return ( +
+
+
{update.version}
+
+
+ {formatDate(update.releaseDate, shortDateFormat)} +
+ + {update.branch === 'master' ? null : ( + + )} + + {update.version === currentVersion ? ( + + ) : null} + + {update.version !== currentVersion && update.installedOn ? ( + + ) : null} +
+ + {update.changes ? ( +
+ + + +
+ ) : ( +
{translate('MaintenanceRelease')}
+ )} +
+ ); + })} +
+ )} + + {updatesError ? ( + + {translate('FailedToFetchUpdates')} + + ) : null} + + {generalSettingsError ? ( + + {translate('FailedToFetchSettings')} + + ) : null} + + +
{translate('InstallMajorVersionUpdateMessage')}
+
+ +
+ + } + confirmLabel={translate('Install')} + onConfirm={handleInstallLatestMajorVersionPress} + onCancel={handleCancelMajorVersionPress} + /> +
+
+ ); +} + +export default Updates; diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js deleted file mode 100644 index 77d75dbda..000000000 --- a/frontend/src/System/Updates/UpdatesConnector.js +++ /dev/null @@ -1,98 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { fetchGeneralSettings } from 'Store/Actions/settingsActions'; -import { fetchUpdates } from 'Store/Actions/systemActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import Updates from './Updates'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app.version, - createSystemStatusSelector(), - (state) => state.system.updates, - (state) => state.settings.general, - createUISettingsSelector(), - createCommandExecutingSelector(commandNames.APPLICATION_UPDATE), - ( - currentVersion, - status, - updates, - generalSettings, - uiSettings, - isInstallingUpdate - ) => { - const { - error: updatesError, - items - } = updates; - - const isFetching = updates.isFetching || generalSettings.isFetching; - const isPopulated = updates.isPopulated && generalSettings.isPopulated; - - return { - currentVersion, - isFetching, - isPopulated, - updatesError, - generalSettingsError: generalSettings.error, - items, - isInstallingUpdate, - updateMechanism: generalSettings.item.updateMechanism, - updateMechanismMessage: status.packageUpdateMechanismMessage, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchUpdates: fetchUpdates, - dispatchFetchGeneralSettings: fetchGeneralSettings, - dispatchExecuteCommand: executeCommand -}; - -class UpdatesConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchUpdates(); - this.props.dispatchFetchGeneralSettings(); - } - - // - // Listeners - - onInstallLatestPress = () => { - this.props.dispatchExecuteCommand({ name: commandNames.APPLICATION_UPDATE }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -UpdatesConnector.propTypes = { - dispatchFetchUpdates: PropTypes.func.isRequired, - dispatchFetchGeneralSettings: PropTypes.func.isRequired, - dispatchExecuteCommand: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector); diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js index aa59e866f..0e387f39f 100644 --- a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js +++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js @@ -146,7 +146,7 @@ class TrackFileEditorModalContent extends Component { }); return acc; - }, [{ key: 'selectQuality', value: 'Select Quality', disabled: true }]); + }, [{ key: 'selectQuality', value: translate('SelectQuality'), isDisabled: true }]); const hasSelectedFiles = this.getSelectedIds().length > 0; diff --git a/frontend/src/TrackFile/TrackFile.ts b/frontend/src/TrackFile/TrackFile.ts index ce9379816..ef4dc65f3 100644 --- a/frontend/src/TrackFile/TrackFile.ts +++ b/frontend/src/TrackFile/TrackFile.ts @@ -13,6 +13,7 @@ export interface TrackFile extends ModelBase { releaseGroup: string; quality: QualityModel; customFormats: CustomFormat[]; + indexerFlags: number; mediaInfo: MediaInfo; qualityCutoffNotMet: boolean; } diff --git a/frontend/src/Utilities/Array/sortByName.js b/frontend/src/Utilities/Array/sortByName.js deleted file mode 100644 index 1956d3bac..000000000 --- a/frontend/src/Utilities/Array/sortByName.js +++ /dev/null @@ -1,5 +0,0 @@ -function sortByName(a, b) { - return a.name.localeCompare(b.name); -} - -export default sortByName; diff --git a/frontend/src/Utilities/Array/sortByProp.ts b/frontend/src/Utilities/Array/sortByProp.ts new file mode 100644 index 000000000..8fbde08c9 --- /dev/null +++ b/frontend/src/Utilities/Array/sortByProp.ts @@ -0,0 +1,13 @@ +import { StringKey } from 'typings/Helpers/KeysMatching'; + +export function sortByProp< + // eslint-disable-next-line no-use-before-define + T extends Record, + K extends StringKey +>(sortKey: K) { + return (a: T, b: T) => { + return a[sortKey].localeCompare(b[sortKey], undefined, { numeric: true }); + }; +} + +export default sortByProp; diff --git a/frontend/src/Utilities/Artist/getProgressBarKind.js b/frontend/src/Utilities/Artist/getProgressBarKind.ts similarity index 58% rename from frontend/src/Utilities/Artist/getProgressBarKind.js rename to frontend/src/Utilities/Artist/getProgressBarKind.ts index eb3b2dd6e..f45387024 100644 --- a/frontend/src/Utilities/Artist/getProgressBarKind.js +++ b/frontend/src/Utilities/Artist/getProgressBarKind.ts @@ -1,6 +1,15 @@ import { kinds } from 'Helpers/Props'; -function getProgressBarKind(status, monitored, progress) { +function getProgressBarKind( + status: string, + monitored: boolean, + progress: number, + isDownloading: boolean +) { + if (isDownloading) { + return kinds.PURPLE; + } + if (progress === 100) { return status === 'ended' ? kinds.SUCCESS : kinds.PRIMARY; } diff --git a/frontend/src/Utilities/Artist/monitorNewItemsOptions.js b/frontend/src/Utilities/Artist/monitorNewItemsOptions.js index 2f352be3a..f45095b6e 100644 --- a/frontend/src/Utilities/Artist/monitorNewItemsOptions.js +++ b/frontend/src/Utilities/Artist/monitorNewItemsOptions.js @@ -4,19 +4,19 @@ const monitorNewItemsOptions = [ { key: 'all', get value() { - return translate('AllAlbums'); + return translate('MonitorAllAlbums'); } }, { key: 'none', get value() { - return translate('None'); + return translate('MonitorNoNewAlbums'); } }, { key: 'new', get value() { - return translate('New'); + return translate('MonitorNewAlbums'); } } ]; diff --git a/frontend/src/Utilities/Artist/monitorOptions.js b/frontend/src/Utilities/Artist/monitorOptions.js index b5e942ae6..a06a79a96 100644 --- a/frontend/src/Utilities/Artist/monitorOptions.js +++ b/frontend/src/Utilities/Artist/monitorOptions.js @@ -1,11 +1,48 @@ +import translate from 'Utilities/String/translate'; + const monitorOptions = [ - { key: 'all', value: 'All Albums' }, - { key: 'future', value: 'Future Albums' }, - { key: 'missing', value: 'Missing Albums' }, - { key: 'existing', value: 'Existing Albums' }, - { key: 'first', value: 'Only First Album' }, - { key: 'latest', value: 'Only Latest Album' }, - { key: 'none', value: 'None' } + { + key: 'all', + get value() { + return translate('MonitorAllAlbums'); + } + }, + { + key: 'future', + get value() { + return translate('MonitorFutureAlbums'); + } + }, + { + key: 'missing', + get value() { + return translate('MonitorMissingAlbums'); + } + }, + { + key: 'existing', + get value() { + return translate('MonitorExistingAlbums'); + } + }, + { + key: 'first', + get value() { + return translate('MonitorFirstAlbum'); + } + }, + { + key: 'latest', + get value() { + return translate('MonitorLastestAlbum'); + } + }, + { + key: 'none', + get value() { + return translate('MonitorNoAlbums'); + } + } ]; export default monitorOptions; diff --git a/frontend/src/Utilities/Date/formatDateTime.js b/frontend/src/Utilities/Date/formatDateTime.js index f36f4f3e0..fb50230e1 100644 --- a/frontend/src/Utilities/Date/formatDateTime.js +++ b/frontend/src/Utilities/Date/formatDateTime.js @@ -1,4 +1,5 @@ import moment from 'moment'; +import translate from 'Utilities/String/translate'; import formatTime from './formatTime'; import isToday from './isToday'; import isTomorrow from './isTomorrow'; @@ -10,15 +11,15 @@ function getRelativeDay(date, includeRelativeDate) { } if (isYesterday(date)) { - return 'Yesterday, '; + return translate('Yesterday'); } if (isToday(date)) { - return 'Today, '; + return translate('Today'); } if (isTomorrow(date)) { - return 'Tomorrow, '; + return translate('Tomorrow'); } return ''; @@ -33,7 +34,10 @@ function formatDateTime(date, dateFormat, timeFormat, { includeSeconds = false, const formattedDate = moment(date).format(dateFormat); const formattedTime = formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds }); - return `${relativeDay}${formattedDate} ${formattedTime}`; + if (relativeDay) { + return translate('FormatDateTimeRelative', { relativeDay, formattedDate, formattedTime }); + } + return translate('FormatDateTime', { formattedDate, formattedTime }); } export default formatDateTime; diff --git a/frontend/src/Utilities/Date/formatShortTimeSpan.js b/frontend/src/Utilities/Date/formatShortTimeSpan.js index c14251e68..148dc2627 100644 --- a/frontend/src/Utilities/Date/formatShortTimeSpan.js +++ b/frontend/src/Utilities/Date/formatShortTimeSpan.js @@ -1,4 +1,5 @@ import moment from 'moment'; +import translate from 'Utilities/String/translate'; function formatShortTimeSpan(timeSpan) { if (!timeSpan) { @@ -12,14 +13,14 @@ function formatShortTimeSpan(timeSpan) { const seconds = Math.floor(duration.asSeconds()); if (hours > 0) { - return `${hours} hour(s)`; + return translate('FormatShortTimeSpanHours', { hours }); } if (minutes > 0) { - return `${minutes} minute(s)`; + return translate('FormatShortTimeSpanMinutes', { minutes }); } - return `${seconds} second(s)`; + return translate('FormatShortTimeSpanSeconds', { seconds }); } export default formatShortTimeSpan; diff --git a/frontend/src/Utilities/Date/formatTimeSpan.js b/frontend/src/Utilities/Date/formatTimeSpan.js index 1ebe6b9e3..2422e19d5 100644 --- a/frontend/src/Utilities/Date/formatTimeSpan.js +++ b/frontend/src/Utilities/Date/formatTimeSpan.js @@ -1,5 +1,6 @@ import moment from 'moment'; import padNumber from 'Utilities/Number/padNumber'; +import translate from 'Utilities/String/translate'; function formatTimeSpan(timeSpan) { if (!timeSpan) { @@ -16,7 +17,7 @@ function formatTimeSpan(timeSpan) { const time = `${hours}:${minutes}:${seconds}`; if (days > 0) { - return `${days}d ${time}`; + return translate('FormatTimeSpanDays', { days, time }); } return time; diff --git a/frontend/src/Utilities/Date/getRelativeDate.js b/frontend/src/Utilities/Date/getRelativeDate.ts similarity index 54% rename from frontend/src/Utilities/Date/getRelativeDate.js rename to frontend/src/Utilities/Date/getRelativeDate.ts index 0a60135ce..178d14fb7 100644 --- a/frontend/src/Utilities/Date/getRelativeDate.js +++ b/frontend/src/Utilities/Date/getRelativeDate.ts @@ -4,16 +4,35 @@ import isInNextWeek from 'Utilities/Date/isInNextWeek'; import isToday from 'Utilities/Date/isToday'; import isTomorrow from 'Utilities/Date/isTomorrow'; import isYesterday from 'Utilities/Date/isYesterday'; +import translate from 'Utilities/String/translate'; -function getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds = false, timeForToday = false } = {}) { +interface GetRelativeDateOptions { + timeFormat?: string; + includeSeconds?: boolean; + timeForToday?: boolean; +} + +function getRelativeDate( + date: string | undefined, + shortDateFormat: string, + showRelativeDates: boolean, + { + timeFormat, + includeSeconds = false, + timeForToday = false, + }: GetRelativeDateOptions = {} +) { if (!date) { - return null; + return ''; } const isTodayDate = isToday(date); if (isTodayDate && timeForToday && timeFormat) { - return formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds }); + return formatTime(date, timeFormat, { + includeMinuteZero: true, + includeSeconds, + }); } if (!showRelativeDates) { @@ -21,15 +40,15 @@ function getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, } if (isYesterday(date)) { - return 'Yesterday'; + return translate('Yesterday'); } if (isTodayDate) { - return 'Today'; + return translate('Today'); } if (isTomorrow(date)) { - return 'Tomorrow'; + return translate('Tomorrow'); } if (isInNextWeek(date)) { diff --git a/frontend/src/Utilities/Number/formatAge.js b/frontend/src/Utilities/Number/formatAge.js index b8a4aacc5..a8f0e9f65 100644 --- a/frontend/src/Utilities/Number/formatAge.js +++ b/frontend/src/Utilities/Number/formatAge.js @@ -1,3 +1,5 @@ +import translate from 'Utilities/String/translate'; + function formatAge(age, ageHours, ageMinutes) { age = Math.round(age); ageHours = parseFloat(ageHours); @@ -5,13 +7,13 @@ function formatAge(age, ageHours, ageMinutes) { if (age < 2 && ageHours) { if (ageHours < 2 && !!ageMinutes) { - return `${ageMinutes.toFixed(0)} ${ageHours === 1 ? 'minute' : 'minutes'}`; + return `${ageMinutes.toFixed(0)} ${ageHours === 1 ? translate('FormatAgeMinute') : translate('FormatAgeMinutes')}`; } - return `${ageHours.toFixed(1)} ${ageHours === 1 ? 'hour' : 'hours'}`; + return `${ageHours.toFixed(1)} ${ageHours === 1 ? translate('FormatAgeHour') : translate('FormatAgeHours')}`; } - return `${age} ${age === 1 ? 'day' : 'days'}`; + return `${age} ${age === 1 ? translate('FormatAgeDay') : translate('FormatAgeDays')}`; } export default formatAge; diff --git a/frontend/src/Utilities/String/firstCharToUpper.js b/frontend/src/Utilities/String/firstCharToUpper.js new file mode 100644 index 000000000..1ce64831c --- /dev/null +++ b/frontend/src/Utilities/String/firstCharToUpper.js @@ -0,0 +1,9 @@ +function firstCharToUpper(input) { + if (!input) { + return ''; + } + + return [].map.call(input, (char, i) => (i ? char : char.toUpperCase())).join(''); +} + +export default firstCharToUpper; diff --git a/frontend/src/Utilities/String/translate.ts b/frontend/src/Utilities/String/translate.ts index 98a0418ad..5571ef6b0 100644 --- a/frontend/src/Utilities/String/translate.ts +++ b/frontend/src/Utilities/String/translate.ts @@ -17,7 +17,7 @@ export async function fetchTranslations(): Promise { translations = data.strings; resolve(true); - } catch (error) { + } catch { resolve(false); } }); @@ -27,6 +27,12 @@ export default function translate( key: string, tokens: Record = {} ) { + const { isProduction = true } = window.Lidarr; + + if (!isProduction && !(key in translations)) { + console.warn(`Missing translation for key: ${key}`); + } + const translation = translations[key] || key; tokens.appName = 'Lidarr'; diff --git a/frontend/src/Utilities/Table/getSelectedIds.js b/frontend/src/Utilities/Table/getSelectedIds.js deleted file mode 100644 index 705f13a5d..000000000 --- a/frontend/src/Utilities/Table/getSelectedIds.js +++ /dev/null @@ -1,15 +0,0 @@ -import _ from 'lodash'; - -function getSelectedIds(selectedState, { parseIds = true } = {}) { - return _.reduce(selectedState, (result, value, id) => { - if (value) { - const parsedId = parseIds ? parseInt(id) : id; - - result.push(parsedId); - } - - return result; - }, []); -} - -export default getSelectedIds; diff --git a/frontend/src/Utilities/Table/getSelectedIds.ts b/frontend/src/Utilities/Table/getSelectedIds.ts new file mode 100644 index 000000000..b84db6245 --- /dev/null +++ b/frontend/src/Utilities/Table/getSelectedIds.ts @@ -0,0 +1,18 @@ +import { reduce } from 'lodash'; +import { SelectedState } from 'Helpers/Hooks/useSelectState'; + +function getSelectedIds(selectedState: SelectedState): number[] { + return reduce( + selectedState, + (result: number[], value, id) => { + if (value) { + result.push(parseInt(id)); + } + + return result; + }, + [] + ); +} + +export default getSelectedIds; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js index 7e3b971ad..6710118b1 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js @@ -12,6 +12,7 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import TablePager from 'Components/Table/TablePager'; import { align, icons, kinds } from 'Helpers/Props'; import getFilterValue from 'Utilities/Filter/getFilterValue'; @@ -173,6 +174,16 @@ class CutoffUnmet extends Component { + + + +
- {translate('MassAlbumsCutoffUnmetWarning', [totalRecords])} + {translate('SearchForAllCutoffUnmetAlbumsConfirmationCount', { totalRecords })}
- {translate('ThisCannotBeCancelled')} + {translate('MassSearchCancelWarning')}
} diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js index dbb4f2235..1dd9870d1 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js @@ -131,13 +131,15 @@ class CutoffUnmetConnector extends Component { onSearchSelectedPress = (selected) => { this.props.executeCommand({ name: commandNames.ALBUM_SEARCH, - albumIds: selected + albumIds: selected, + commandFinished: this.repopulate }); }; onSearchAllCutoffUnmetPress = () => { this.props.executeCommand({ - name: commandNames.CUTOFF_UNMET_ALBUM_SEARCH + name: commandNames.CUTOFF_UNMET_ALBUM_SEARCH, + commandFinished: this.repopulate }); }; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js index 785b9b1c1..452e2947a 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js @@ -20,6 +20,7 @@ function CutoffUnmetRow(props) { foreignAlbumId, albumType, title, + lastSearchTime, disambiguation, isSelected, columns, @@ -89,6 +90,15 @@ function CutoffUnmetRow(props) { ); } + if (name === 'albums.lastSearchTime') { + return ( + + ); + } + if (name === 'status') { return ( + + + +
- {translate('MassAlbumsSearchWarning', [totalRecords])} + {translate('SearchForAllMissingAlbumsConfirmationCount', { totalRecords })}
- {translate('ThisCannotBeCancelled')} + {translate('MassSearchCancelWarning')}
} diff --git a/frontend/src/Wanted/Missing/MissingConnector.js b/frontend/src/Wanted/Missing/MissingConnector.js index 3d0abfa03..008f1a149 100644 --- a/frontend/src/Wanted/Missing/MissingConnector.js +++ b/frontend/src/Wanted/Missing/MissingConnector.js @@ -121,13 +121,15 @@ class MissingConnector extends Component { onSearchSelectedPress = (selected) => { this.props.executeCommand({ name: commandNames.ALBUM_SEARCH, - albumIds: selected + albumIds: selected, + commandFinished: this.repopulate }); }; onSearchAllMissingPress = () => { this.props.executeCommand({ - name: commandNames.MISSING_ALBUM_SEARCH + name: commandNames.MISSING_ALBUM_SEARCH, + commandFinished: this.repopulate }); }; diff --git a/frontend/src/Wanted/Missing/MissingRow.js b/frontend/src/Wanted/Missing/MissingRow.js index 0eb1a0452..6c0b5a0c6 100644 --- a/frontend/src/Wanted/Missing/MissingRow.js +++ b/frontend/src/Wanted/Missing/MissingRow.js @@ -17,6 +17,7 @@ function MissingRow(props) { albumType, foreignAlbumId, title, + lastSearchTime, disambiguation, isSelected, columns, @@ -86,6 +87,15 @@ function MissingRow(props) { ); } + if (name === 'albums.lastSearchTime') { + return ( + + ); + } + if (name === 'actions') { return ( , - document.getElementById('root') - ); + const root = createRoot(container!); // createRoot(container!) if you use TypeScript + root.render(); } diff --git a/frontend/src/index.ejs b/frontend/src/index.ejs index b99a39a0d..a893149d5 100644 --- a/frontend/src/index.ejs +++ b/frontend/src/index.ejs @@ -3,13 +3,16 @@ - - + + + + + @@ -30,7 +33,7 @@ sizes="16x16" href="/Content/Images/Icons/favicon-16x16.png" /> - + diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 36aed4c4b..37e780919 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -14,6 +14,32 @@ window.Lidarr = await response.json(); __webpack_public_path__ = `${window.Lidarr.urlBase}/`; /* eslint-enable no-undef, @typescript-eslint/ban-ts-comment */ +const error = console.error; + +// Monkey patch console.error to filter out some warnings from React +// TODO: Remove this after the great TypeScript migration + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function logError(...parameters: any[]) { + const filter = parameters.find((parameter) => { + return ( + typeof parameter === 'string' && + (parameter.includes( + 'Support for defaultProps will be removed from function components in a future major release' + ) || + parameter.includes( + 'findDOMNode is deprecated and will be removed in the next major release' + )) + ); + }); + + if (!filter) { + error(...parameters); + } +} + +console.error = logError; + const { bootstrap } = await import('./bootstrap'); await bootstrap(); diff --git a/frontend/src/login.html b/frontend/src/login.html index a65106664..24d086959 100644 --- a/frontend/src/login.html +++ b/frontend/src/login.html @@ -3,13 +3,19 @@ - - + + + + + @@ -30,7 +36,11 @@ sizes="16x16" href="/Content/Images/Icons/favicon-16x16.png" /> - + - + @@ -54,9 +61,9 @@