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/label-actions.yml b/.github/label-actions.yml new file mode 100644 index 000000000..3979401b1 --- /dev/null +++ b/.github/label-actions.yml @@ -0,0 +1,16 @@ +# Configuration for Label Actions - https://github.com/dessant/label-actions + +'Type: Support': + comment: > + :wave: @{issue-author}, we use the issue tracker exclusively + for bug reports and feature requests. However, this issue appears + to be a support request. Please hop over onto our [Discord](https://lidarr.audio/discord). + close: true + close-reason: 'not planned' + +'Status: Logs Needed': + comment: > + :wave: @{issue-author}, In order to help you further we'll need to see logs. + You'll need to enable trace logging and replicate the problem that you encountered. + Guidance on how to enable trace logging can be found in + our [troubleshooting guide](https://wiki.servarr.com/lidarr/troubleshooting#logging-and-log-files). diff --git a/.github/workflows/label-actions.yml b/.github/workflows/label-actions.yml new file mode 100644 index 000000000..a6246a6b3 --- /dev/null +++ b/.github/workflows/label-actions.yml @@ -0,0 +1,17 @@ +name: 'Label Actions' + +on: + issues: + types: [labeled, unlabeled] + +permissions: + contents: read + issues: write + +jobs: + action: + runs-on: ubuntu-latest + steps: + - 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/.github/workflows/support.yml b/.github/workflows/support.yml deleted file mode 100644 index cdc757378..000000000 --- a/.github/workflows/support.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: 'Support requests' - -on: - issues: - types: [labeled, unlabeled, reopened] - -jobs: - support: - runs-on: ubuntu-latest - steps: - - uses: dessant/support-requests@v3 - with: - github-token: ${{ github.token }} - support-label: 'Type: Support' - issue-comment: > - :wave: @{issue-author}, we use the issue tracker exclusively - for bug reports and feature requests. However, this issue appears - to be a support request. Please hop over onto our [Discord](https://lidarr.audio/discord). - close-issue: true - close-reason: 'not planned' - lock-issue: false - - uses: dessant/support-requests@v3 - with: - github-token: ${{ github.token }} - support-label: 'Status: Logs Needed' - issue-comment: > - :wave: @{issue-author}, In order to help you further we'll need to see logs. - You'll need to enable trace logging and replicate the problem that you encountered. - Guidance on how to enable trace logging can be found in - our [troubleshooting guide](https://wiki.servarr.com/lidarr/troubleshooting#logging-and-log-files). - close-issue: false - lock-issue: false 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 64b91de8d..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: '1.4.3' + 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.413' - 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/Blocklist/Blocklist.js b/frontend/src/Activity/Blocklist/Blocklist.js index 6668bee46..ab43c106d 100644 --- a/frontend/src/Activity/Blocklist/Blocklist.js +++ b/frontend/src/Activity/Blocklist/Blocklist.js @@ -36,6 +36,7 @@ class Blocklist extends Component { lastToggled: null, selectedState: {}, isConfirmRemoveModalOpen: false, + isConfirmClearModalOpen: false, items: props.items }; } @@ -90,6 +91,19 @@ class Blocklist extends Component { this.setState({ isConfirmRemoveModalOpen: false }); }; + onClearBlocklistPress = () => { + this.setState({ isConfirmClearModalOpen: true }); + }; + + onClearBlocklistConfirmed = () => { + this.props.onClearBlocklistPress(); + this.setState({ isConfirmClearModalOpen: false }); + }; + + onConfirmClearModalClose = () => { + this.setState({ isConfirmClearModalOpen: false }); + }; + // // Render @@ -105,7 +119,6 @@ class Blocklist extends Component { totalRecords, isRemoving, isClearingBlocklistExecuting, - onClearBlocklistPress, ...otherProps } = this.props; @@ -116,7 +129,8 @@ class Blocklist extends Component { allSelected, allUnselected, selectedState, - isConfirmRemoveModalOpen + isConfirmRemoveModalOpen, + isConfirmClearModalOpen } = this.state; const selectedIds = this.getSelectedIds(); @@ -136,8 +150,9 @@ class Blocklist extends Component { @@ -220,6 +235,16 @@ class Blocklist extends Component { onConfirm={this.onRemoveSelectedConfirmed} onCancel={this.onConfirmRemoveModalClose} /> + + ); } diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js index cc0e69fd5..84aa3e0f2 100644 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -60,6 +60,7 @@ function HistoryDetails(props) { eventType, sourceTitle, data, + downloadId, shortDateFormat, timeFormat } = props; @@ -72,7 +73,6 @@ function HistoryDetails(props) { nzbInfoUrl, downloadClient, downloadClientName, - downloadId, age, ageHours, ageMinutes, @@ -90,20 +90,22 @@ function HistoryDetails(props) { /> { - !!indexer && + indexer ? + /> : + null } { - !!releaseGroup && + releaseGroup ? + /> : + null } { @@ -119,7 +121,7 @@ function HistoryDetails(props) { nzbInfoUrl ? - Info URL + {translate('InfoUrl')} @@ -139,27 +141,30 @@ function HistoryDetails(props) { } { - !!downloadId && + downloadId ? + /> : + null } { - !!indexer && + age || ageHours || ageMinutes ? + /> : + null } { - !!publishedDate && + publishedDate ? + /> : + null } ); @@ -167,7 +172,8 @@ function HistoryDetails(props) { if (eventType === 'downloadFailed') { const { - message + message, + indexer } = data; return ( @@ -179,11 +185,29 @@ function HistoryDetails(props) { /> { - !!message && + downloadId ? + : + null + } + + { + indexer ? ( + + ) : null} + + { + message ? + /> : + null } ); @@ -205,12 +229,13 @@ function HistoryDetails(props) { /> { - !!droppedPath && + droppedPath ? + /> : + null } { @@ -248,7 +273,7 @@ function HistoryDetails(props) { reasonMessage = 'File was deleted by via UI'; break; case 'MissingFromDisk': - reasonMessage = 'Lidarr was unable to find the file on disk so it was removed'; + reasonMessage = 'Lidarr was unable to find the file on disk so the file was unlinked from the album/track in the database'; break; case 'Upgrade': reasonMessage = 'File was deleted to import an upgrade'; @@ -360,9 +385,9 @@ function HistoryDetails(props) { const { indexer, releaseGroup, + customFormatScore, nzbInfoUrl, downloadClient, - downloadId, age, ageHours, ageMinutes, @@ -377,64 +402,80 @@ function HistoryDetails(props) { /> { - !!indexer && + indexer ? + /> : + null } { - !!releaseGroup && + releaseGroup ? + /> : + null } { - !!nzbInfoUrl && + customFormatScore && customFormatScore !== '0' ? + : + null + } + + { + nzbInfoUrl ? - Info URL + {translate('InfoUrl')} {nzbInfoUrl} - + : + null } { - !!downloadClient && + downloadClient ? + /> : + null } { - !!downloadId && + downloadId ? + /> : + null } { - !!indexer && + age || ageHours || ageMinutes ? + /> : + null } { - !!publishedDate && + publishedDate ? + /> : + null } ); @@ -454,11 +495,21 @@ function HistoryDetails(props) { /> { - !!message && + downloadId ? + : + null + } + + { + message ? + /> : + null } ); @@ -479,6 +530,7 @@ HistoryDetails.propTypes = { eventType: PropTypes.string.isRequired, sourceTitle: PropTypes.string.isRequired, data: PropTypes.object.isRequired, + downloadId: PropTypes.string, shortDateFormat: PropTypes.string.isRequired, timeFormat: PropTypes.string.isRequired }; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.js index 187db9cd4..5362a2f43 100644 --- a/frontend/src/Activity/History/Details/HistoryDetailsModal.js +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.js @@ -42,6 +42,7 @@ function HistoryDetailsModal(props) { eventType, sourceTitle, data, + downloadId, isMarkingAsFailed, shortDateFormat, timeFormat, @@ -64,6 +65,7 @@ function HistoryDetailsModal(props) { eventType={eventType} sourceTitle={sourceTitle} data={data} + downloadId={downloadId} shortDateFormat={shortDateFormat} timeFormat={timeFormat} /> @@ -98,6 +100,7 @@ HistoryDetailsModal.propTypes = { eventType: PropTypes.string.isRequired, sourceTitle: PropTypes.string.isRequired, data: PropTypes.object.isRequired, + downloadId: PropTypes.string, isMarkingAsFailed: PropTypes.bool.isRequired, shortDateFormat: PropTypes.string.isRequired, timeFormat: PropTypes.string.isRequired, diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js index 5c830ee1a..d144a5402 100644 --- a/frontend/src/Activity/History/History.js +++ b/frontend/src/Activity/History/History.js @@ -15,6 +15,7 @@ import TablePager from 'Components/Table/TablePager'; import { align, icons, kinds } from 'Helpers/Props'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import translate from 'Utilities/String/translate'; +import HistoryFilterModal from './HistoryFilterModal'; import HistoryRowConnector from './HistoryRowConnector'; class History extends Component { @@ -52,6 +53,7 @@ class History extends Component { columns, selectedFilterKey, filters, + customFilters, totalRecords, isArtistFetching, isArtistPopulated, @@ -94,7 +96,8 @@ class History extends Component { alignMenu={align.RIGHT} selectedFilterKey={selectedFilterKey} filters={filters} - customFilters={[]} + customFilters={customFilters} + filterModalConnectorComponent={HistoryFilterModal} onFilterSelect={onFilterSelect} /> @@ -165,8 +168,9 @@ History.propTypes = { error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, - selectedFilterKey: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, totalRecords: PropTypes.number, isArtistFetching: PropTypes.bool.isRequired, isArtistPopulated: PropTypes.bool.isRequired, diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js index 801aaf0e0..2b3354bc5 100644 --- a/frontend/src/Activity/History/HistoryConnector.js +++ b/frontend/src/Activity/History/HistoryConnector.js @@ -6,6 +6,7 @@ import withCurrentPage from 'Components/withCurrentPage'; import { clearAlbums, fetchAlbums } from 'Store/Actions/albumActions'; import * as historyActions from 'Store/Actions/historyActions'; import { clearTracks, fetchTracks } from 'Store/Actions/trackActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; @@ -17,7 +18,8 @@ function createMapStateToProps() { (state) => state.artist, (state) => state.albums, (state) => state.tracks, - (history, artist, albums, tracks) => { + createCustomFiltersSelector('history'), + (history, artist, albums, tracks, customFilters) => { return { isArtistFetching: artist.isFetching, isArtistPopulated: artist.isPopulated, @@ -27,6 +29,7 @@ function createMapStateToProps() { isTracksFetching: tracks.isFetching, isTracksPopulated: tracks.isPopulated, tracksError: tracks.error, + customFilters, ...history }; } diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.js index cc0495d67..937cedd98 100644 --- a/frontend/src/Activity/History/HistoryEventTypeCell.js +++ b/frontend/src/Activity/History/HistoryEventTypeCell.js @@ -3,9 +3,10 @@ import React from 'react'; import Icon from 'Components/Icon'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import { icons, kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; import styles from './HistoryEventTypeCell.css'; -function getIconName(eventType) { +function getIconName(eventType, data) { switch (eventType) { case 'grabbed': return icons.DOWNLOADING; @@ -16,7 +17,7 @@ function getIconName(eventType) { case 'downloadFailed': return icons.DOWNLOADING; case 'trackFileDeleted': - return icons.DELETE; + return data.reason === 'MissingFromDisk' ? icons.FILE_MISSING : icons.DELETE; case 'trackFileRenamed': return icons.ORGANIZE; case 'trackFileRetagged': @@ -54,11 +55,11 @@ function getTooltip(eventType, data) { case 'downloadFailed': return 'Album download failed'; case 'trackFileDeleted': - return 'Track file deleted'; + return data.reason === 'MissingFromDisk' ? translate('TrackFileMissingTooltip') : translate('TrackFileDeletedTooltip'); case 'trackFileRenamed': - return 'Track file renamed'; + return translate('TrackFileRenamedTooltip'); case 'trackFileRetagged': - return 'Track file tags updated'; + return translate('TrackFileTagsUpdatedTooltip'); case 'albumImportIncomplete': return 'Files downloaded but not all could be imported'; case 'downloadImported': @@ -71,7 +72,7 @@ function getTooltip(eventType, data) { } function HistoryEventTypeCell({ eventType, data }) { - const iconName = getIconName(eventType); + const iconName = getIconName(eventType, data); const iconKind = getIconKind(eventType); const tooltip = getTooltip(eventType, data); diff --git a/frontend/src/Activity/History/HistoryFilterModal.tsx b/frontend/src/Activity/History/HistoryFilterModal.tsx new file mode 100644 index 000000000..f4ad2e57c --- /dev/null +++ b/frontend/src/Activity/History/HistoryFilterModal.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 { setHistoryFilter } from 'Store/Actions/historyActions'; + +function createHistorySelector() { + return createSelector( + (state: AppState) => state.history.items, + (queueItems) => { + return queueItems; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.history.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface HistoryFilterModalProps { + isOpen: boolean; +} + +export default function HistoryFilterModal(props: HistoryFilterModalProps) { + const sectionItems = useSelector(createHistorySelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'history'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setHistoryFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js index 23dea5416..9f2da78d0 100644 --- a/frontend/src/Activity/History/HistoryRow.js +++ b/frontend/src/Activity/History/HistoryRow.js @@ -65,6 +65,7 @@ class HistoryRow extends Component { sourceTitle, date, data, + downloadId, isMarkingAsFailed, columns, shortDateFormat, @@ -244,6 +245,7 @@ class HistoryRow extends Component { eventType={eventType} sourceTitle={sourceTitle} data={data} + downloadId={downloadId} isMarkingAsFailed={isMarkingAsFailed} shortDateFormat={shortDateFormat} timeFormat={timeFormat} @@ -269,6 +271,7 @@ HistoryRow.propTypes = { sourceTitle: PropTypes.string.isRequired, date: PropTypes.string.isRequired, data: PropTypes.object.isRequired, + downloadId: PropTypes.string, isMarkingAsFailed: PropTypes.bool, markAsFailedError: PropTypes.object, columns: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js index b73ce0ad7..0efc29f21 100644 --- a/frontend/src/Activity/Queue/Queue.js +++ b/frontend/src/Activity/Queue/Queue.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; @@ -21,9 +22,10 @@ import getSelectedIds from 'Utilities/Table/getSelectedIds'; import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; import selectAll from 'Utilities/Table/selectAll'; import toggleSelected from 'Utilities/Table/toggleSelected'; +import QueueFilterModal from './QueueFilterModal'; import QueueOptionsConnector from './QueueOptionsConnector'; import QueueRowConnector from './QueueRowConnector'; -import RemoveQueueItemsModal from './RemoveQueueItemsModal'; +import RemoveQueueItemModal from './RemoveQueueItemModal'; class Queue extends Component { @@ -155,11 +157,16 @@ class Queue extends Component { isAlbumsPopulated, albumsError, columns, + selectedFilterKey, + filters, + customFilters, + count, totalRecords, isGrabbing, isRemoving, isRefreshMonitoredDownloadsExecuting, onRefreshPress, + onFilterSelect, ...otherProps } = this.props; @@ -222,6 +229,15 @@ class Queue extends Component { iconName={icons.TABLE} /> + + @@ -243,7 +259,11 @@ class Queue extends Component { { isAllPopulated && !hasError && !items.length ? - {translate('QueueIsEmpty')} + { + selectedFilterKey !== 'all' && count > 0 ? + translate('QueueFilterHasNoItems') : + translate('QueueIsEmpty') + } : null } @@ -289,9 +309,16 @@ class Queue extends Component { } - { + 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); @@ -299,6 +326,17 @@ class Queue extends Component { return !!(item && item.artistId && item.albumId); }) )} + pending={isConfirmRemoveModalOpen && ( + selectedIds.every((id) => { + const item = items.find((i) => i.id === id); + + if (!item) { + return false; + } + + return item.status === 'delay' || item.status === 'downloadClientUnavailable'; + }) + )} onRemovePress={this.onRemoveSelectedConfirmed} onModalClose={this.onConfirmRemoveModalClose} /> @@ -318,13 +356,22 @@ Queue.propTypes = { isAlbumsPopulated: PropTypes.bool.isRequired, albumsError: PropTypes.object, columns: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + count: PropTypes.number.isRequired, totalRecords: PropTypes.number, isGrabbing: PropTypes.bool.isRequired, isRemoving: PropTypes.bool.isRequired, isRefreshMonitoredDownloadsExecuting: PropTypes.bool.isRequired, onRefreshPress: PropTypes.func.isRequired, onGrabSelectedPress: PropTypes.func.isRequired, - onRemoveSelectedPress: PropTypes.func.isRequired + onRemoveSelectedPress: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired +}; + +Queue.defaultProps = { + count: 0 }; export default Queue; diff --git a/frontend/src/Activity/Queue/QueueConnector.js b/frontend/src/Activity/Queue/QueueConnector.js index 1053f6f26..fc0bb4699 100644 --- a/frontend/src/Activity/Queue/QueueConnector.js +++ b/frontend/src/Activity/Queue/QueueConnector.js @@ -7,6 +7,7 @@ import withCurrentPage from 'Components/withCurrentPage'; import { clearAlbums, fetchAlbums } from 'Store/Actions/albumActions'; import { executeCommand } from 'Store/Actions/commandActions'; import * as queueActions from 'Store/Actions/queueActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; @@ -19,14 +20,18 @@ function createMapStateToProps() { (state) => state.albums, (state) => state.queue.options, (state) => state.queue.paged, + (state) => state.queue.status.item, + createCustomFiltersSelector('queue'), createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS), - (artist, albums, options, queue, isRefreshMonitoredDownloadsExecuting) => { + (artist, albums, options, queue, status, customFilters, isRefreshMonitoredDownloadsExecuting) => { return { + count: options.includeUnknownArtistItems ? status.totalCount : status.count, isArtistFetching: artist.isFetching, isArtistPopulated: artist.isPopulated, isAlbumsFetching: albums.isFetching, isAlbumsPopulated: albums.isPopulated, albumsError: albums.error, + customFilters, isRefreshMonitoredDownloadsExecuting, ...options, ...queue @@ -125,6 +130,10 @@ class QueueConnector extends Component { this.props.setQueueSort({ sortKey }); }; + onFilterSelect = (selectedFilterKey) => { + this.props.setQueueFilter({ selectedFilterKey }); + }; + onTableOptionChange = (payload) => { this.props.setQueueTableOption(payload); @@ -159,6 +168,7 @@ class QueueConnector extends Component { onLastPagePress={this.onLastPagePress} onPageSelect={this.onPageSelect} onSortPress={this.onSortPress} + onFilterSelect={this.onFilterSelect} onTableOptionChange={this.onTableOptionChange} onRefreshPress={this.onRefreshPress} onGrabSelectedPress={this.onGrabSelectedPress} @@ -181,6 +191,7 @@ QueueConnector.propTypes = { gotoQueueLastPage: PropTypes.func.isRequired, gotoQueuePage: PropTypes.func.isRequired, setQueueSort: PropTypes.func.isRequired, + setQueueFilter: PropTypes.func.isRequired, setQueueTableOption: PropTypes.func.isRequired, clearQueue: PropTypes.func.isRequired, grabQueueItems: PropTypes.func.isRequired, diff --git a/frontend/src/Activity/Queue/QueueFilterModal.tsx b/frontend/src/Activity/Queue/QueueFilterModal.tsx new file mode 100644 index 000000000..3fce6c166 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueFilterModal.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 { setQueueFilter } from 'Store/Actions/queueActions'; + +function createQueueSelector() { + return createSelector( + (state: AppState) => state.queue.paged.items, + (queueItems) => { + return queueItems; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.queue.paged.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface QueueFilterModalProps { + isOpen: boolean; +} + +export default function QueueFilterModal(props: QueueFilterModalProps) { + const sectionItems = useSelector(createQueueSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'queue'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setQueueFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} 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 08634d9f7..d0f1fbacf 100644 --- a/frontend/src/Activity/Queue/QueueRow.js +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -98,8 +98,10 @@ class QueueRow extends Component { indexer, outputPath, downloadClient, + downloadClientHasPostImportCategory, downloadForced, estimatedCompletionTime, + added, timeleft, size, sizeleft, @@ -328,6 +330,15 @@ class QueueRow extends Component { ); } + if (name === 'added') { + return ( + + ); + } + if (name === 'actions') { return ( @@ -421,8 +434,10 @@ QueueRow.propTypes = { indexer: PropTypes.string, outputPath: PropTypes.string, downloadClient: PropTypes.string, + downloadClientHasPostImportCategory: PropTypes.bool, downloadForced: PropTypes.bool.isRequired, estimatedCompletionTime: PropTypes.string, + added: PropTypes.string, timeleft: PropTypes.string, size: PropTypes.number, sizeleft: PropTypes.number, diff --git a/frontend/src/Activity/Queue/QueueStatusCell.js b/frontend/src/Activity/Queue/QueueStatusCell.js index 6d5caf6f9..d4dcdfeee 100644 --- a/frontend/src/Activity/Queue/QueueStatusCell.js +++ b/frontend/src/Activity/Queue/QueueStatusCell.js @@ -57,30 +57,40 @@ function QueueStatusCell(props) { if (status === 'paused') { iconName = icons.PAUSED; - title = 'Paused'; + title = translate('Paused'); } if (status === 'queued') { iconName = icons.QUEUED; - title = 'Queued'; + title = translate('Queued'); } if (status === 'completed') { iconName = icons.DOWNLOADED; - title = 'Downloaded'; + title = translate('Downloaded'); + + if (trackedDownloadState === 'importBlocked') { + title += ` - ${translate('UnableToImportAutomatically')}`; + iconKind = kinds.WARNING; + } + + if (trackedDownloadState === 'importFailed') { + title += ` - ${translate('ImportFailed', { sourceTitle })}`; + iconKind = kinds.WARNING; + } if (trackedDownloadState === 'importPending') { - title += ' - Waiting to Import'; + title += ` - ${translate('WaitingToImport')}`; iconKind = kinds.PURPLE; } if (trackedDownloadState === 'importing') { - title += ' - Importing'; + title += ` - ${translate('Importing')}`; iconKind = kinds.PURPLE; } if (trackedDownloadState === 'failedPending') { - title += ' - Waiting to Process'; + title += ` - ${translate('WaitingToProcess')}`; iconKind = kinds.DANGER; } } @@ -91,36 +101,38 @@ function QueueStatusCell(props) { if (status === 'delay') { iconName = icons.PENDING; - title = 'Pending'; + title = translate('Pending'); } if (status === 'downloadClientUnavailable') { iconName = icons.PENDING; iconKind = kinds.WARNING; - title = 'Pending - Download client is unavailable'; + title = translate('PendingDownloadClientUnavailable'); } if (status === 'failed') { iconName = icons.DOWNLOADING; iconKind = kinds.DANGER; - title = 'Download failed'; + title = translate('DownloadFailed'); } if (status === 'warning') { iconName = icons.DOWNLOADING; iconKind = kinds.WARNING; - title = `Download warning: ${errorMessage || 'check download client for more details'}`; + const warningMessage = + errorMessage || translate('CheckDownloadClientForDetails'); + title = translate('DownloadWarning', { warningMessage }); } if (hasError) { if (status === 'completed') { iconName = icons.DOWNLOAD; iconKind = kinds.DANGER; - title = `Import failed: ${sourceTitle}`; + title = translate('ImportFailed', { sourceTitle }); } else { iconName = icons.DOWNLOADING; iconKind = kinds.DANGER; - title = 'Download failed'; + title = translate('DownloadFailed'); } } diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.css b/frontend/src/Activity/Queue/RemoveQueueItemModal.css similarity index 100% rename from frontend/src/Activity/Queue/RemoveQueueItemsModal.css rename to frontend/src/Activity/Queue/RemoveQueueItemModal.css diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.css.d.ts b/frontend/src/Activity/Queue/RemoveQueueItemModal.css.d.ts similarity index 100% rename from frontend/src/Activity/Queue/RemoveQueueItemsModal.css.d.ts rename to frontend/src/Activity/Queue/RemoveQueueItemModal.css.d.ts diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js deleted file mode 100644 index d9e4dd7f6..000000000 --- a/frontend/src/Activity/Queue/RemoveQueueItemModal.js +++ /dev/null @@ -1,171 +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'; - -class RemoveQueueItemModal 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, - sourceTitle, - canIgnore - } = this.props; - - const { removeFromClient, blocklist, skipRedownload } = this.state; - - return ( - - - - Remove - {sourceTitle} - - - -
- Are you sure you want to remove '{sourceTitle}' from the queue? -
- - - - {translate('RemoveFromDownloadClient')} - - - - - - - - {translate('BlocklistRelease')} - - - - - - { - blocklist && - - - {translate('SkipRedownload')} - - - - } - -
- - - - - - -
-
- ); - } -} - -RemoveQueueItemModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - sourceTitle: PropTypes.string.isRequired, - canIgnore: 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 3b9164e68..000000000 --- a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js +++ /dev/null @@ -1,172 +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 - } = this.props; - - const { removeFromClient, blocklist, skipRedownload } = this.state; - - return ( - - - - {selectedCount > 1 ? translate('RemoveSelectedItems') : translate('RemoveSelectedItem')} - - - -
- {selectedCount > 1 ? translate('RemoveSelectedItemsQueueMessageText', [selectedCount]) : translate('RemoveSelectedItemQueueMessageText')} -
- - - - {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, - 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/AddArtist/ArtistMonitoringOptionsPopoverContent.css b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css new file mode 100644 index 000000000..7393b9c35 --- /dev/null +++ b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css @@ -0,0 +1,5 @@ +.message { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/Artist/Editor/ArtistEditorFooterLabel.css.d.ts b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css.d.ts similarity index 79% rename from frontend/src/Artist/Editor/ArtistEditorFooterLabel.css.d.ts rename to frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css.d.ts index 06f814a94..65c237dff 100644 --- a/frontend/src/Artist/Editor/ArtistEditorFooterLabel.css.d.ts +++ b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.css.d.ts @@ -1,8 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'label': string; - 'savingIcon': string; + 'message': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js index 653e313e1..d53bda8e3 100644 --- a/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js +++ b/frontend/src/AddArtist/ArtistMonitoringOptionsPopoverContent.js @@ -2,14 +2,17 @@ import React from 'react'; import Alert from 'Components/Alert'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; +import styles from './ArtistMonitoringOptionsPopoverContent.css'; function ArtistMonitoringOptionsPopoverContent() { return ( <> - + This is a one time adjustment to set which albums are monitored + - {title}{disambiguation ? ` (${disambiguation})` : ''} + + {albumTitle} ); } diff --git a/frontend/src/Album/Delete/DeleteAlbumModalContent.js b/frontend/src/Album/Delete/DeleteAlbumModalContent.js index c637c3dd4..28505ea75 100644 --- a/frontend/src/Album/Delete/DeleteAlbumModalContent.js +++ b/frontend/src/Album/Delete/DeleteAlbumModalContent.js @@ -53,13 +53,13 @@ class DeleteAlbumModalContent extends Component { render() { const { title, - statistics, + statistics = {}, onModalClose } = this.props; const { - trackFileCount, - sizeOnDisk + trackFileCount = 0, + sizeOnDisk = 0 } = statistics; const deleteFiles = this.state.deleteFiles; @@ -133,14 +133,14 @@ class DeleteAlbumModalContent extends Component { diff --git a/frontend/src/Album/Details/AlbumDetails.css b/frontend/src/Album/Details/AlbumDetails.css index dd6626c96..a676ae574 100644 --- a/frontend/src/Album/Details/AlbumDetails.css +++ b/frontend/src/Album/Details/AlbumDetails.css @@ -119,7 +119,10 @@ margin: 5px 10px 5px 0; } +.releaseDate, .sizeOnDisk, +.albumType, +.secondaryTypes, .qualityProfileName, .links, .tags { @@ -147,6 +150,12 @@ .headerContent { padding: 15px; } + + .title { + font-weight: 300; + font-size: 30px; + line-height: 30px; + } } @media only screen and (max-width: $breakpointLarge) { diff --git a/frontend/src/Album/Details/AlbumDetails.css.d.ts b/frontend/src/Album/Details/AlbumDetails.css.d.ts index 82ef9f0e0..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; @@ -19,6 +20,8 @@ interface CssExports { 'monitorToggleButton': string; '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 0a6c883a4..fe007e168 100644 --- a/frontend/src/Album/Details/AlbumDetails.js +++ b/frontend/src/Album/Details/AlbumDetails.js @@ -9,6 +9,7 @@ import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector'; import AlbumInteractiveSearchModalConnector from 'Album/Search/AlbumInteractiveSearchModalConnector'; import ArtistGenres from 'Artist/Details/ArtistGenres'; import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal'; +import Alert from 'Components/Alert'; import HeartRating from 'Components/HeartRating'; import Icon from 'Components/Icon'; import Label from 'Components/Label'; @@ -39,11 +40,7 @@ const intermediateFontSize = parseInt(fonts.intermediateFontSize); const lineHeight = parseFloat(fonts.lineHeight); function getFanartUrl(images) { - const fanartImage = _.find(images, { coverType: 'fanart' }); - if (fanartImage) { - // Remove protocol - return fanartImage.url.replace(/^https?:/, ''); - } + return _.find(images, { coverType: 'fanart' })?.url; } function formatDuration(timeSpan) { @@ -195,6 +192,7 @@ class AlbumDetails extends Component { duration, overview, albumType, + secondaryTypes, statistics = {}, monitored, releaseDate, @@ -207,6 +205,7 @@ class AlbumDetails extends Component { isFetching, isPopulated, albumsError, + tracksError, trackFilesError, hasTrackFiles, shortDateFormat, @@ -219,8 +218,8 @@ class AlbumDetails extends Component { } = this.props; const { - trackFileCount, - sizeOnDisk + trackFileCount = 0, + sizeOnDisk = 0 } = statistics; const { @@ -399,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={ @@ -463,32 +462,55 @@ class AlbumDetails extends Component { className={styles.detailsLabel} size={sizes.LARGE} > - - - - {monitored ? 'Monitored' : 'Unmonitored'} - +
+ + + {monitored ? translate('Monitored') : translate('Unmonitored')} + +
{ - !!albumType && + albumType ? : + null + } - - {albumType} - - + { + secondaryTypes.length ? + : + null } - - - - Links - +
+ + + {translate('Links')} + +
} tooltip={ @@ -530,28 +553,38 @@ class AlbumDetails extends Component {
{ - !isPopulated && !albumsError && !trackFilesError && - + !isPopulated && !albumsError && !tracksError && !trackFilesError ? + : + null } { - !isFetching && albumsError && -
- {translate('LoadingAlbumsFailed')} -
+ !isFetching && albumsError ? + + {translate('AlbumsLoadError')} + : + null } { - !isFetching && trackFilesError && -
- {translate('LoadingTrackFilesFailed')} -
+ !isFetching && tracksError ? + + {translate('TracksLoadError')} + : + null + } + + { + !isFetching && trackFilesError ? + + {translate('TrackFilesLoadError')} + : + null } { isPopulated && !!media.length &&
- { media.slice(0).map((medium) => { return ( @@ -569,6 +602,14 @@ class AlbumDetails extends Component {
} + { + isPopulated && !media.length ? + + {translate('NoMediumInformation')} + : + null + } +
-1 ); + const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, artistId: artist.id })); + const isRenamingArtistCommand = findCommand(commands, { name: commandNames.RENAME_ARTIST }); + const isRenamingArtist = ( + isCommandExecuting(isRenamingArtistCommand) && + isRenamingArtistCommand.body.artistIds.indexOf(artist.id) > -1 + ); const isFetching = tracks.isFetching || isTrackFilesFetching; const isPopulated = tracks.isPopulated && isTrackFilesPopulated; @@ -80,6 +86,8 @@ function createMapStateToProps() { shortDateFormat: uiSettings.shortDateFormat, artist, isSearching, + isRenamingFiles, + isRenamingArtist, isFetching, isPopulated, tracksError, @@ -113,8 +121,27 @@ class AlbumDetailsConnector extends Component { } componentDidUpdate(prevProps) { - if (!_.isEqual(getMonitoredReleases(prevProps), getMonitoredReleases(this.props)) || - (prevProps.anyReleaseOk === false && this.props.anyReleaseOk === true)) { + const { + id, + anyReleaseOk, + isRenamingFiles, + isRenamingArtist + } = this.props; + + if ( + (prevProps.isRenamingFiles && !isRenamingFiles) || + (prevProps.isRenamingArtist && !isRenamingArtist) || + !_.isEqual(getMonitoredReleases(prevProps), getMonitoredReleases(this.props)) || + (prevProps.anyReleaseOk === false && anyReleaseOk === true) + ) { + this.unpopulate(); + this.populate(); + } + + // If the id has changed we need to clear the album + // files and fetch from the server. + + if (prevProps.id !== id) { this.unpopulate(); this.populate(); } @@ -174,6 +201,8 @@ class AlbumDetailsConnector extends Component { AlbumDetailsConnector.propTypes = { id: PropTypes.number, anyReleaseOk: PropTypes.bool, + isRenamingFiles: PropTypes.bool.isRequired, + isRenamingArtist: PropTypes.bool.isRequired, isAlbumFetching: PropTypes.bool, isAlbumPopulated: PropTypes.bool, foreignAlbumId: PropTypes.string.isRequired, diff --git a/frontend/src/Album/Details/AlbumDetailsMedium.js b/frontend/src/Album/Details/AlbumDetailsMedium.js index fb665cb88..9e80e2c7a 100644 --- a/frontend/src/Album/Details/AlbumDetailsMedium.js +++ b/frontend/src/Album/Details/AlbumDetailsMedium.js @@ -12,16 +12,13 @@ import TrackRowConnector from './TrackRowConnector'; import styles from './AlbumDetailsMedium.css'; function getMediumStatistics(tracks) { - let trackCount = 0; + const trackCount = tracks.length; let trackFileCount = 0; let totalTrackCount = 0; tracks.forEach((track) => { 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 3162ac9cc..912c00101 100644 --- a/frontend/src/Album/Details/TrackRow.css +++ b/frontend/src/Album/Details/TrackRow.css @@ -23,6 +23,7 @@ } .duration, +.size, .status { composes: cell from '~Components/Table/Cells/TableRowCell.css'; @@ -34,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 7b473fe05..79bbdaf43 100644 --- a/frontend/src/Album/Details/TrackRow.css.d.ts +++ b/frontend/src/Album/Details/TrackRow.css.d.ts @@ -4,7 +4,9 @@ interface CssExports { 'audio': string; 'customFormatScore': string; 'duration': string; + 'indexerFlags': string; 'monitored': string; + 'size': string; 'status': string; 'title': string; 'trackNumber': string; diff --git a/frontend/src/Album/Details/TrackRow.js b/frontend/src/Album/Details/TrackRow.js index a8deb3e98..db128d493 100644 --- a/frontend/src/Album/Details/TrackRow.js +++ b/frontend/src/Album/Details/TrackRow.js @@ -2,14 +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'; @@ -28,8 +33,10 @@ class TrackRow extends Component { title, duration, trackFilePath, + trackFileSize, customFormats, customFormatScore, + indexerFlags, columns, deleteTrackFile } = this.props; @@ -139,12 +146,41 @@ 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 ( + + {!!trackFileSize && formatBytes(trackFileSize)} + + ); + } + if (name === 'status') { return ( { return { trackFilePath: trackFile ? trackFile.path : null, + trackFileSize: trackFile ? trackFile.size : null, customFormats: trackFile ? trackFile.customFormats : [], - customFormatScore: trackFile ? trackFile.customFormatScore : 0 + customFormatScore: trackFile ? trackFile.customFormatScore : 0, + indexerFlags: trackFile ? trackFile.indexerFlags : 0 }; } ); diff --git a/frontend/src/Album/Edit/EditAlbumModalContent.js b/frontend/src/Album/Edit/EditAlbumModalContent.js index 8c5ed58a6..dafc0312d 100644 --- a/frontend/src/Album/Edit/EditAlbumModalContent.js +++ b/frontend/src/Album/Edit/EditAlbumModalContent.js @@ -35,7 +35,7 @@ class EditAlbumModalContent extends Component { title, artistName, albumType, - statistics, + statistics = {}, item, isSaving, onInputChange, @@ -43,6 +43,10 @@ class EditAlbumModalContent extends Component { ...otherProps } = this.props; + const { + trackFileCount = 0 + } = statistics; + const { monitored, anyReleaseOk, @@ -96,7 +100,7 @@ class EditAlbumModalContent extends Component { type={inputTypes.ALBUM_RELEASE_SELECT} name="releases" helpText={translate('ReleasesHelpText')} - isDisabled={anyReleaseOk.value && statistics.trackFileCount > 0} + isDisabled={anyReleaseOk.value && trackFileCount > 0} albumReleases={releases} onChange={onInputChange} /> diff --git a/frontend/src/Album/IndexerFlags.tsx b/frontend/src/Album/IndexerFlags.tsx new file mode 100644 index 000000000..74e2e033c --- /dev/null +++ b/frontend/src/Album/IndexerFlags.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import createIndexerFlagsSelector from 'Store/Selectors/createIndexerFlagsSelector'; + +interface IndexerFlagsProps { + indexerFlags: number; +} + +function IndexerFlags({ indexerFlags = 0 }: IndexerFlagsProps) { + const allIndexerFlags = useSelector(createIndexerFlagsSelector); + + const flags = allIndexerFlags.items.filter( + // eslint-disable-next-line no-bitwise + (item) => (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/Album/TrackQuality.js b/frontend/src/Album/TrackQuality.js index ca94227a0..6eac5d2f8 100644 --- a/frontend/src/Album/TrackQuality.js +++ b/frontend/src/Album/TrackQuality.js @@ -3,6 +3,7 @@ import React from 'react'; import Label from 'Components/Label'; import { kinds } from 'Helpers/Props'; import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; function getTooltip(title, quality, size) { if (!title) { @@ -26,13 +27,44 @@ function getTooltip(title, quality, size) { return title; } +function revisionLabel(className, quality, showRevision) { + if (!showRevision) { + return; + } + + if (quality.revision.isRepack) { + return ( + + ); + } + + if (quality.revision.version && quality.revision.version > 1) { + return ( + + ); + } +} + function TrackQuality(props) { const { className, title, quality, size, - isCutoffNotMet + isCutoffNotMet, + showRevision } = props; if (!quality) { @@ -40,13 +72,15 @@ function TrackQuality(props) { } return ( - + + {revisionLabel(className, quality, showRevision)} + ); } @@ -55,11 +89,13 @@ TrackQuality.propTypes = { title: PropTypes.string, quality: PropTypes.object.isRequired, size: PropTypes.number, - isCutoffNotMet: PropTypes.bool + isCutoffNotMet: PropTypes.bool, + showRevision: PropTypes.bool }; TrackQuality.defaultProps = { - title: '' + title: '', + showRevision: false }; export default TrackQuality; diff --git a/frontend/src/AlbumStudio/AlbumStudio.css b/frontend/src/AlbumStudio/AlbumStudio.css deleted file mode 100644 index 033279591..000000000 --- a/frontend/src/AlbumStudio/AlbumStudio.css +++ /dev/null @@ -1,36 +0,0 @@ -.pageContentBodyWrapper { - display: flex; - flex: 1 0 1px; - overflow: hidden; -} - -.contentBody { - composes: contentBody from '~Components/Page/PageContentBody.css'; - - display: flex; - flex-direction: column; -} - -.tableInnerContentBody { - composes: innerContentBody from '~Components/Page/PageContentBody.css'; - - display: flex; - flex-direction: column; - flex-grow: 1; -} - -.contentBodyContainer { - display: flex; - flex-direction: column; - flex-grow: 1; -} - -@media only screen and (max-width: $breakpointSmall) { - .pageContentBodyWrapper { - flex-basis: auto; - } - - .contentBody { - flex-basis: 1px; - } -} diff --git a/frontend/src/AlbumStudio/AlbumStudio.js b/frontend/src/AlbumStudio/AlbumStudio.js deleted file mode 100644 index fb4d1fa4d..000000000 --- a/frontend/src/AlbumStudio/AlbumStudio.js +++ /dev/null @@ -1,443 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { CellMeasurer, CellMeasurerCache } from 'react-virtualized'; -import NoArtist from 'Artist/NoArtist'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import PageJumpBar from 'Components/Page/PageJumpBar'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import VirtualTable from 'Components/Table/VirtualTable'; -import VirtualTableRow from 'Components/Table/VirtualTableRow'; -import { align, sortDirections } from 'Helpers/Props'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -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 AlbumStudioFilterModalConnector from './AlbumStudioFilterModalConnector'; -import AlbumStudioFooter from './AlbumStudioFooter'; -import AlbumStudioRowConnector from './AlbumStudioRowConnector'; -import AlbumStudioTableHeader from './AlbumStudioTableHeader'; -import styles from './AlbumStudio.css'; - -const columns = [ - { - name: 'status', - isVisible: true - }, - { - name: 'sortName', - label: () => translate('Name'), - isSortable: true, - isVisible: true - }, - { - name: 'albumCount', - label: () => translate('Albums'), - isSortable: false, - isVisible: true - } -]; - -class AlbumStudio extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - estimatedRowSize: 100, - scroller: null, - jumpBarItems: { order: [] }, - scrollIndex: null, - jumpCount: 0, - allSelected: false, - allUnselected: false, - lastToggled: null, - selectedState: {} - }; - - this.cache = new CellMeasurerCache({ - defaultHeight: 100, - fixedWidth: true - }); - } - - componentDidMount() { - this.setSelectedState(); - } - - componentDidUpdate(prevProps) { - const { - isSaving, - saveError - } = this.props; - - const { - scrollIndex, - jumpCount - } = this.state; - - if (prevProps.isSaving && !isSaving && !saveError) { - this.onSelectAllChange({ value: false }); - } - - // nasty hack to fix react-virtualized jumping incorrectly - // due to variable row heights - if (scrollIndex != null && scrollIndex > 0) { - if (jumpCount === 0) { - this.setState({ - scrollIndex: scrollIndex - 1, - jumpCount: 1 - }); - } else if (jumpCount === 1) { - this.setState({ - scrollIndex: scrollIndex + 1, - jumpCount: 2 - }); - } else { - this.setState({ - scrollIndex: null, - jumpCount: 0 - }); - } - } - } - - // - // Control - - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - }; - - setJumpBarItems() { - const { - items, - sortKey, - sortDirection - } = this.props; - - // Reset if not sorting by sortName - if (sortKey !== 'sortName') { - this.setState({ jumpBarItems: { order: [] } }); - return; - } - - const characters = _.reduce(items, (acc, item) => { - let char = item.sortName.charAt(0); - - if (!isNaN(char)) { - char = '#'; - } - - if (char in acc) { - acc[char] = acc[char] + 1; - } else { - acc[char] = 1; - } - - return acc; - }, {}); - - const order = Object.keys(characters).sort(); - - // Reverse if sorting descending - if (sortDirection === sortDirections.DESCENDING) { - order.reverse(); - } - - const jumpBarItems = { - characters, - order - }; - - this.setState({ jumpBarItems }); - } - - getSelectedIds = () => { - if (this.state.allUnselected) { - return []; - } - return getSelectedIds(this.state.selectedState); - }; - - setSelectedState = () => { - const { - items - } = this.props; - - const { - selectedState - } = this.state; - - const newSelectedState = {}; - - items.forEach((artist) => { - const isItemSelected = selectedState[artist.id]; - - if (isItemSelected) { - newSelectedState[artist.id] = isItemSelected; - } else { - newSelectedState[artist.id] = false; - } - }); - - const selectedCount = getSelectedIds(newSelectedState).length; - const newStateCount = Object.keys(newSelectedState).length; - let isAllSelected = false; - let isAllUnselected = false; - - if (selectedCount === 0) { - isAllUnselected = true; - } else if (selectedCount === newStateCount) { - isAllSelected = true; - } - - this.setState({ selectedState: newSelectedState, allSelected: isAllSelected, allUnselected: isAllUnselected }); - }; - - estimateRowHeight = (width) => { - const { - albumCount, - items - } = this.props; - - if (albumCount === undefined || albumCount === 0 || items.length === 0) { - return 100; - } - - // guess 250px per album entry - // available width is total width less 186px for select, status etc - const cols = Math.max(Math.floor((width - 186) / 250), 1); - const albumsPerArtist = albumCount / items.length; - const albumRowsPerArtist = albumsPerArtist / cols; - - // each row is 23px per album row plus 16px padding - return albumRowsPerArtist * 23 + 16; - }; - - rowRenderer = ({ key, rowIndex, parent, style }) => { - const { - items - } = this.props; - - const { - selectedState - } = this.state; - - const item = items[rowIndex]; - - return ( - - {({ registerChild }) => ( - - - - )} - - ); - }; - - // - // Listeners - - onSelectAllChange = ({ value }) => { - this.setState(selectAll(this.state.selectedState, value)); - }; - - onSelectedChange = ({ id, value, shiftKey = false }) => { - this.setState((state) => { - return toggleSelected(state, this.props.items, id, value, shiftKey); - }); - }; - - onSelectAllPress = () => { - this.onSelectAllChange({ value: !this.state.allSelected }); - }; - - onUpdateSelectedPress = (changes) => { - this.props.onUpdateSelectedPress({ - artistIds: this.getSelectedIds(), - ...changes - }); - }; - - onJumpBarItemPress = (jumpToCharacter) => { - const scrollIndex = getIndexOfFirstCharacter(this.props.items, jumpToCharacter); - - if (scrollIndex != null) { - this.setState({ scrollIndex }); - } - }; - - onGridRecompute = (width) => { - this.setJumpBarItems(); - this.setSelectedState(); - this.setState({ estimatedRowSize: this.estimateRowHeight(width) }); - this.cache.clearAll(); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - totalItems, - items, - selectedFilterKey, - filters, - customFilters, - sortKey, - sortDirection, - isSaving, - saveError, - isSmallScreen, - onSortPress, - onFilterSelect - } = this.props; - - const { - allSelected, - allUnselected, - estimatedRowSize, - scroller, - jumpBarItems, - scrollIndex - } = this.state; - - return ( - - - - - - - - -
- - { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && -
{getErrorMessage(error, 'Failed to load artist from API')}
- } - - { - !error && isPopulated && !!items.length && -
- - } - sortKey={sortKey} - sortDirection={sortDirection} - deferredMeasurementCache={this.cache} - rowHeight={this.cache.rowHeight} - estimatedRowSize={estimatedRowSize} - onRecompute={this.onGridRecompute} - /> -
- } - - { - !error && isPopulated && !items.length && - - } -
- - { - isPopulated && !!jumpBarItems.order.length && - - } -
- - -
- ); - } -} - -AlbumStudio.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - totalItems: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - albumCount: PropTypes.number.isRequired, - sortKey: PropTypes.string, - sortDirection: PropTypes.oneOf(sortDirections.all), - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - isSaving: PropTypes.bool.isRequired, - saveError: PropTypes.object, - isSmallScreen: PropTypes.bool.isRequired, - onSortPress: PropTypes.func.isRequired, - onFilterSelect: PropTypes.func.isRequired, - onUpdateSelectedPress: PropTypes.func.isRequired -}; - -export default AlbumStudio; diff --git a/frontend/src/AlbumStudio/AlbumStudioAlbum.js b/frontend/src/AlbumStudio/AlbumStudioAlbum.js deleted file mode 100644 index 5dc9dc233..000000000 --- a/frontend/src/AlbumStudio/AlbumStudioAlbum.js +++ /dev/null @@ -1,102 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import MonitorToggleButton from 'Components/MonitorToggleButton'; -import translate from 'Utilities/String/translate'; -import styles from './AlbumStudioAlbum.css'; - -class AlbumStudioAlbum extends Component { - - // - // Listeners - - onAlbumMonitoredPress = () => { - const { - id, - monitored - } = this.props; - - this.props.onAlbumMonitoredPress(id, !monitored); - }; - - // - // Render - - render() { - const { - title, - disambiguation, - albumType, - monitored, - statistics, - isSaving - } = this.props; - - const { - trackFileCount, - totalTrackCount, - percentOfTracks - } = statistics; - - return ( -
-
- - - - { - disambiguation ? `${title} (${disambiguation})` : `${title}` - } - -
- -
- - { - `${albumType}` - } - -
- -
- { - totalTrackCount === 0 ? '0/0' : `${trackFileCount}/${totalTrackCount}` - } -
-
- ); - } -} - -AlbumStudioAlbum.propTypes = { - id: PropTypes.number.isRequired, - title: PropTypes.string.isRequired, - disambiguation: PropTypes.string, - albumType: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - statistics: PropTypes.object.isRequired, - isSaving: PropTypes.bool.isRequired, - onAlbumMonitoredPress: PropTypes.func.isRequired -}; - -AlbumStudioAlbum.defaultProps = { - isSaving: false, - statistics: { - trackFileCount: 0, - totalTrackCount: 0, - percentOfTracks: 0 - } -}; - -export default AlbumStudioAlbum; diff --git a/frontend/src/AlbumStudio/AlbumStudioConnector.js b/frontend/src/AlbumStudio/AlbumStudioConnector.js deleted file mode 100644 index 25fedafa4..000000000 --- a/frontend/src/AlbumStudio/AlbumStudioConnector.js +++ /dev/null @@ -1,116 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { clearAlbums, fetchAlbums } from 'Store/Actions/albumActions'; -import { saveAlbumStudio, setAlbumStudioFilter, setAlbumStudioSort } from 'Store/Actions/albumStudioActions'; -import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import AlbumStudio from './AlbumStudio'; - -function createAlbumFetchStateSelector() { - return createSelector( - (state) => state.albums.items.length, - (state) => state.albums.isFetching, - (state) => state.albums.isPopulated, - (length, isFetching, isPopulated) => { - const albumCount = (!isFetching && isPopulated) ? length : 0; - return { - albumCount, - isFetching, - isPopulated - }; - } - ); -} - -function createMapStateToProps() { - return createSelector( - createAlbumFetchStateSelector(), - createArtistClientSideCollectionItemsSelector('albumStudio'), - createDimensionsSelector(), - (albums, artist, dimensionsState) => { - const isPopulated = albums.isPopulated && artist.isPopulated; - const isFetching = artist.isFetching || albums.isFetching; - return { - ...artist, - isPopulated, - isFetching, - albumCount: albums.albumCount, - isSmallScreen: dimensionsState.isSmallScreen - }; - } - ); -} - -const mapDispatchToProps = { - fetchAlbums, - clearAlbums, - setAlbumStudioSort, - setAlbumStudioFilter, - saveAlbumStudio -}; - -class AlbumStudioConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.populate(); - } - - componentWillUnmount() { - this.unpopulate(); - } - - // - // Control - - populate = () => { - this.props.fetchAlbums(); - }; - - unpopulate = () => { - this.props.clearAlbums(); - }; - - // - // Listeners - - onSortPress = (sortKey) => { - this.props.setAlbumStudioSort({ sortKey }); - }; - - onFilterSelect = (selectedFilterKey) => { - this.props.setAlbumStudioFilter({ selectedFilterKey }); - }; - - onUpdateSelectedPress = (payload) => { - this.props.saveAlbumStudio(payload); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -AlbumStudioConnector.propTypes = { - setAlbumStudioSort: PropTypes.func.isRequired, - setAlbumStudioFilter: PropTypes.func.isRequired, - fetchAlbums: PropTypes.func.isRequired, - clearAlbums: PropTypes.func.isRequired, - saveAlbumStudio: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(AlbumStudioConnector); diff --git a/frontend/src/AlbumStudio/AlbumStudioFilterModalConnector.js b/frontend/src/AlbumStudio/AlbumStudioFilterModalConnector.js deleted file mode 100644 index db378a7f2..000000000 --- a/frontend/src/AlbumStudio/AlbumStudioFilterModalConnector.js +++ /dev/null @@ -1,24 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import FilterModal from 'Components/Filter/FilterModal'; -import { setAlbumStudioFilter } from 'Store/Actions/albumStudioActions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artist.items, - (state) => state.albumStudio.filterBuilderProps, - (sectionItems, filterBuilderProps) => { - return { - sectionItems, - filterBuilderProps, - customFilterType: 'albumStudio' - }; - } - ); -} - -const mapDispatchToProps = { - dispatchSetFilter: setAlbumStudioFilter -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/AlbumStudio/AlbumStudioFooter.css b/frontend/src/AlbumStudio/AlbumStudioFooter.css deleted file mode 100644 index 11ea5496a..000000000 --- a/frontend/src/AlbumStudio/AlbumStudioFooter.css +++ /dev/null @@ -1,14 +0,0 @@ -.inputContainer { - margin-right: 20px; -} - -.label { - margin-bottom: 3px; - font-weight: bold; -} - -.updateSelectedButton { - composes: button from '~Components/Link/SpinnerButton.css'; - - height: 35px; -} diff --git a/frontend/src/AlbumStudio/AlbumStudioFooter.js b/frontend/src/AlbumStudio/AlbumStudioFooter.js deleted file mode 100644 index 8543a0347..000000000 --- a/frontend/src/AlbumStudio/AlbumStudioFooter.js +++ /dev/null @@ -1,174 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import MonitorAlbumsSelectInput from 'Components/Form/MonitorAlbumsSelectInput'; -import MonitorNewItemsSelectInput from 'Components/Form/MonitorNewItemsSelectInput'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import PageContentFooter from 'Components/Page/PageContentFooter'; -import { inputTypes, kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import styles from './AlbumStudioFooter.css'; - -const NO_CHANGE = 'noChange'; - -class AlbumStudioFooter extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - monitored: NO_CHANGE, - monitor: NO_CHANGE, - monitorNewItems: NO_CHANGE - }; - } - - componentDidUpdate(prevProps) { - const { - isSaving, - saveError - } = this.props; - - if (prevProps.isSaving && !isSaving && !saveError) { - this.setState({ - monitored: NO_CHANGE, - monitor: NO_CHANGE, - monitorNewItems: NO_CHANGE - }); - } - } - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.setState({ [name]: value }); - }; - - onUpdateSelectedPress = () => { - const { - monitor, - monitored, - monitorNewItems - } = this.state; - - const changes = {}; - - if (monitored !== NO_CHANGE) { - changes.monitored = monitored === 'monitored'; - } - - if (monitor !== NO_CHANGE) { - changes.monitor = monitor; - } - - if (monitorNewItems !== NO_CHANGE) { - changes.monitorNewItems = monitorNewItems; - } - - this.props.onUpdateSelectedPress(changes); - }; - - // - // Render - - render() { - const { - selectedCount, - isSaving - } = this.props; - - const { - monitored, - monitor, - monitorNewItems - } = this.state; - - const monitoredOptions = [ - { key: NO_CHANGE, value: translate('NoChange'), disabled: true }, - { key: 'monitored', value: translate('Monitored') }, - { key: 'unmonitored', value: translate('Unmonitored') } - ]; - - const noChanges = monitored === NO_CHANGE && - monitor === NO_CHANGE && - monitorNewItems === NO_CHANGE; - - return ( - -
-
- {translate('MonitorArtist')} -
- - -
- -
-
- {translate('MonitorExistingAlbums')} -
- - -
- -
-
- {translate('MonitorNewAlbums')} -
- - -
- -
-
- {translate('CountArtistsSelected', { selectedCount })} -
- - - {translate('UpdateSelected')} - -
-
- ); - } -} - -AlbumStudioFooter.propTypes = { - selectedCount: PropTypes.number.isRequired, - isSaving: PropTypes.bool.isRequired, - saveError: PropTypes.object, - onUpdateSelectedPress: PropTypes.func.isRequired -}; - -export default AlbumStudioFooter; diff --git a/frontend/src/AlbumStudio/AlbumStudioRow.css b/frontend/src/AlbumStudio/AlbumStudioRow.css deleted file mode 100644 index d8def1d50..000000000 --- a/frontend/src/AlbumStudio/AlbumStudioRow.css +++ /dev/null @@ -1,41 +0,0 @@ -.cell { - composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; - - display: flex; - align-items: center; -} - -.selectCell { - composes: cell from '~Components/Table/Cells/TableRowCell.css'; - - display: flex; - align-items: center; -} - -.status { - composes: cell from '~Components/Table/Cells/TableRowCell.css'; - - display: flex; - align-items: center; - padding: 0; - min-width: 60px; -} - -.title { - composes: cell from '~Components/Table/Cells/TableRowCell.css'; - - display: flex; - align-items: center; - - flex-shrink: 0; - min-width: 110px; -} - -.albums { - composes: cell; - - display: flex; - flex-grow: 4; - flex-wrap: wrap; - min-width: 400px; -} diff --git a/frontend/src/AlbumStudio/AlbumStudioRow.js b/frontend/src/AlbumStudio/AlbumStudioRow.js deleted file mode 100644 index 5a13f442d..000000000 --- a/frontend/src/AlbumStudio/AlbumStudioRow.js +++ /dev/null @@ -1,95 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ArtistNameLink from 'Artist/ArtistNameLink'; -import ArtistStatusCell from 'Artist/Index/Table/ArtistStatusCell'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; -import AlbumStudioAlbum from './AlbumStudioAlbum'; -import styles from './AlbumStudioRow.css'; - -class AlbumStudioRow extends Component { - - // - // Render - - render() { - const { - artistId, - status, - foreignArtistId, - artistName, - artistType, - monitored, - albums, - isSaving, - isSelected, - onSelectedChange, - onArtistMonitoredPress, - onAlbumMonitoredPress - } = this.props; - - return ( - <> - - - - - - - - - - { - albums.map((album) => { - return ( - - ); - }) - } - - - ); - } -} - -AlbumStudioRow.propTypes = { - artistId: PropTypes.number.isRequired, - status: PropTypes.string.isRequired, - foreignArtistId: PropTypes.string.isRequired, - artistName: PropTypes.string.isRequired, - artistType: PropTypes.string, - monitored: PropTypes.bool.isRequired, - albums: PropTypes.arrayOf(PropTypes.object).isRequired, - isSaving: PropTypes.bool.isRequired, - isSelected: PropTypes.bool, - onSelectedChange: PropTypes.func.isRequired, - onArtistMonitoredPress: PropTypes.func.isRequired, - onAlbumMonitoredPress: PropTypes.func.isRequired -}; - -AlbumStudioRow.defaultProps = { - isSaving: false -}; - -export default AlbumStudioRow; diff --git a/frontend/src/AlbumStudio/AlbumStudioRowConnector.js b/frontend/src/AlbumStudio/AlbumStudioRowConnector.js deleted file mode 100644 index fd0bd21dc..000000000 --- a/frontend/src/AlbumStudio/AlbumStudioRowConnector.js +++ /dev/null @@ -1,94 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { toggleAlbumsMonitored } from 'Store/Actions/albumActions'; -import { toggleArtistMonitored } from 'Store/Actions/artistActions'; -import createArtistSelector from 'Store/Selectors/createArtistSelector'; -import AlbumStudioRow from './AlbumStudioRow'; - -// Use a const to share the reselect cache between instances -const getAlbumMap = createSelector( - (state) => state.albums.items, - (albums) => { - return albums.reduce((acc, curr) => { - (acc[curr.artistId] = acc[curr.artistId] || []).push(curr); - return acc; - }, {}); - } -); - -function createMapStateToProps() { - return createSelector( - createArtistSelector(), - getAlbumMap, - (artist, albumMap) => { - const albumsInArtist = albumMap.hasOwnProperty(artist.id) ? albumMap[artist.id] : []; - const sortedAlbums = _.orderBy(albumsInArtist, 'releaseDate', 'desc'); - - return { - ...artist, - artistId: artist.id, - artistName: artist.artistName, - monitored: artist.monitored, - status: artist.status, - isSaving: artist.isSaving, - albums: sortedAlbums - }; - } - ); -} - -const mapDispatchToProps = { - toggleArtistMonitored, - toggleAlbumsMonitored -}; - -class AlbumStudioRowConnector extends Component { - - // - // Listeners - - onArtistMonitoredPress = () => { - const { - artistId, - monitored - } = this.props; - - this.props.toggleArtistMonitored({ - artistId, - monitored: !monitored - }); - }; - - onAlbumMonitoredPress = (albumId, monitored) => { - const albumIds = [albumId]; - this.props.toggleAlbumsMonitored({ - albumIds, - monitored - }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -AlbumStudioRowConnector.propTypes = { - artistId: PropTypes.number.isRequired, - monitored: PropTypes.bool.isRequired, - toggleArtistMonitored: PropTypes.func.isRequired, - toggleAlbumsMonitored: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(AlbumStudioRowConnector); diff --git a/frontend/src/AlbumStudio/AlbumStudioTableHeader.css b/frontend/src/AlbumStudio/AlbumStudioTableHeader.css deleted file mode 100644 index da21f0553..000000000 --- a/frontend/src/AlbumStudio/AlbumStudioTableHeader.css +++ /dev/null @@ -1,18 +0,0 @@ -.status { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 0 60px; - padding: 0; -} - -.sortName { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 0 110px; -} - -.albumCount { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - padding: 12px; -} diff --git a/frontend/src/AlbumStudio/AlbumStudioTableHeader.js b/frontend/src/AlbumStudio/AlbumStudioTableHeader.js deleted file mode 100644 index 8d18babbe..000000000 --- a/frontend/src/AlbumStudio/AlbumStudioTableHeader.js +++ /dev/null @@ -1,61 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; -import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; -import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell'; -import styles from './AlbumStudioTableHeader.css'; - -function AlbumStudioTableHeader(props) { - const { - columns, - allSelected, - allUnselected, - onSelectAllChange, - ...otherProps - } = props; - - return ( - - - { - columns.map((column) => { - const { - name, - label, - isSortable, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - return ( - - {typeof label === 'function' ? label() : label} - - ); - }) - } - - ); -} - -AlbumStudioTableHeader.propTypes = { - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - allSelected: PropTypes.bool.isRequired, - allUnselected: PropTypes.bool.isRequired, - onSelectAllChange: PropTypes.func.isRequired -}; - -export default AlbumStudioTableHeader; 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 223e0f90e..c1004d36d 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -5,15 +5,13 @@ import BlocklistConnector from 'Activity/Blocklist/BlocklistConnector'; import HistoryConnector from 'Activity/History/HistoryConnector'; import QueueConnector from 'Activity/Queue/QueueConnector'; import AlbumDetailsPageConnector from 'Album/Details/AlbumDetailsPageConnector'; -import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector'; import ArtistDetailsPageConnector from 'Artist/Details/ArtistDetailsPageConnector'; -import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector'; -import ArtistIndexConnector from 'Artist/Index/ArtistIndexConnector'; +import ArtistIndex from 'Artist/Index/ArtistIndex'; 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'; @@ -31,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'; @@ -51,7 +49,7 @@ function AppRoutes(props) { { @@ -78,12 +76,28 @@ function AppRoutes(props) { { + return ( + + ); + }} /> { + return ( + + ); + }} /> - {translate('AppUpdated', { appName: 'Lidarr' })} + {translate('AppUpdated')}
- +
{ diff --git a/frontend/src/App/ApplyTheme.js b/frontend/src/App/ApplyTheme.js deleted file mode 100644 index 7e937e586..000000000 --- a/frontend/src/App/ApplyTheme.js +++ /dev/null @@ -1,50 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Fragment, useCallback, useEffect } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import themes from 'Styles/Themes'; - -function createMapStateToProps() { - return createSelector( - (state) => 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/ConnectionLostModal.js b/frontend/src/App/ConnectionLostModal.js index 1d1af0152..5c08f491f 100644 --- a/frontend/src/App/ConnectionLostModal.js +++ b/frontend/src/App/ConnectionLostModal.js @@ -28,11 +28,11 @@ function ConnectionLostModal(props) {
- {translate('ConnectionLostToBackend', { appName: 'Lidarr' })} + {translate('ConnectionLostToBackend')}
- {translate('ConnectionLostReconnect', { appName: 'Lidarr' })} + {translate('ConnectionLostReconnect')}
diff --git a/frontend/src/App/SelectContext.tsx b/frontend/src/App/SelectContext.tsx new file mode 100644 index 000000000..66be388ce --- /dev/null +++ b/frontend/src/App/SelectContext.tsx @@ -0,0 +1,83 @@ +import { cloneDeep } from 'lodash'; +import React, { useCallback, useEffect } from 'react'; +import useSelectState, { SelectState } from 'Helpers/Hooks/useSelectState'; +import ModelBase from './ModelBase'; + +export type SelectContextAction = + | { type: 'reset' } + | { type: 'selectAll' } + | { type: 'unselectAll' } + | { + type: 'toggleSelected'; + id: number; + isSelected: boolean; + shiftKey: boolean; + } + | { + type: 'removeItem'; + id: number; + } + | { + type: 'updateItems'; + items: ModelBase[]; + }; + +export type SelectDispatch = (action: SelectContextAction) => void; + +interface SelectProviderOptions { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children: any; + items: Array; +} + +const SelectContext = React.createContext< + [SelectState, SelectDispatch] | undefined +>(cloneDeep(undefined)); + +export function SelectProvider( + props: SelectProviderOptions +) { + const { items } = props; + const [state, dispatch] = useSelectState(); + + const dispatchWrapper = useCallback( + (action: SelectContextAction) => { + switch (action.type) { + case 'reset': + case 'removeItem': + dispatch(action); + break; + + default: + dispatch({ + ...action, + items, + }); + break; + } + }, + [items, dispatch] + ); + + const value: [SelectState, SelectDispatch] = [state, dispatchWrapper]; + + useEffect(() => { + dispatch({ type: 'updateItems', items }); + }, [items, dispatch]); + + return ( + + {props.children} + + ); +} + +export function useSelect() { + const context = React.useContext(SelectContext); + + if (context === undefined) { + throw new Error('useSelect must be used within a SelectProvider'); + } + + return context; +} diff --git a/frontend/src/App/State/AlbumAppState.ts b/frontend/src/App/State/AlbumAppState.ts new file mode 100644 index 000000000..e03d4a497 --- /dev/null +++ b/frontend/src/App/State/AlbumAppState.ts @@ -0,0 +1,8 @@ +import Album from 'Album/Album'; +import AppSectionState, { + AppSectionDeleteState, +} from 'App/State/AppSectionState'; + +interface AlbumAppState extends AppSectionState, AppSectionDeleteState {} + +export default AlbumAppState; diff --git a/frontend/src/App/State/AppSectionState.ts b/frontend/src/App/State/AppSectionState.ts index d511963fc..cabc39b1c 100644 --- a/frontend/src/App/State/AppSectionState.ts +++ b/frontend/src/App/State/AppSectionState.ts @@ -1,4 +1,5 @@ import SortDirection from 'Helpers/Props/SortDirection'; +import { FilterBuilderProp } from './AppState'; export interface Error { responseJSON: { @@ -20,6 +21,10 @@ export interface PagedAppSectionState { pageSize: number; } +export interface AppSectionFilterState { + filterBuilderProps: FilterBuilderProp[]; +} + export interface AppSectionSchemaState { isSchemaFetching: boolean; isSchemaPopulated: boolean; diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 8c8b99fba..cb8da5987 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,5 +1,15 @@ +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'; interface FilterBuilderPropOption { id: string; @@ -33,9 +43,30 @@ export interface CustomFilter { filers: PropertyFilter[]; } +export interface AppSectionState { + version: string; + dimensions: { + isSmallScreen: boolean; + width: number; + height: number; + }; +} + interface AppState { + albums: AlbumAppState; + 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/ArtistAppState.ts b/frontend/src/App/State/ArtistAppState.ts new file mode 100644 index 000000000..9e0628df7 --- /dev/null +++ b/frontend/src/App/State/ArtistAppState.ts @@ -0,0 +1,72 @@ +import AppSectionState, { + AppSectionDeleteState, + AppSectionSaveState, +} from 'App/State/AppSectionState'; +import Artist from 'Artist/Artist'; +import Column from 'Components/Table/Column'; +import SortDirection from 'Helpers/Props/SortDirection'; +import { Filter, FilterBuilderProp } from './AppState'; + +export interface ArtistIndexAppState { + sortKey: string; + sortDirection: SortDirection; + secondarySortKey: string; + secondarySortDirection: SortDirection; + view: string; + + posterOptions: { + detailedProgressBar: boolean; + size: string; + showTitle: boolean; + showMonitored: boolean; + showQualityProfile: boolean; + showNextAlbum: boolean; + showSearchAction: boolean; + }; + + bannerOptions: { + detailedProgressBar: boolean; + size: string; + showTitle: boolean; + showMonitored: boolean; + showQualityProfile: boolean; + showNextAlbum: boolean; + showSearchAction: boolean; + }; + + overviewOptions: { + detailedProgressBar: boolean; + size: string; + showMonitored: boolean; + showQualityProfile: boolean; + showLastAlbum: boolean; + showAdded: boolean; + showAlbumCount: boolean; + showPath: boolean; + showSizeOnDisk: boolean; + showSearchAction: boolean; + }; + + tableOptions: { + showBanners: boolean; + showSearchAction: boolean; + }; + + selectedFilterKey: string; + filterBuilderProps: FilterBuilderProp[]; + filters: Filter[]; + columns: Column[]; +} + +interface ArtistAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState { + itemMap: Record; + + deleteOptions: { + addImportListExclusion: boolean; + }; +} + +export default ArtistAppState; 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/ClientSideCollectionAppState.ts b/frontend/src/App/State/ClientSideCollectionAppState.ts new file mode 100644 index 000000000..f4110ef73 --- /dev/null +++ b/frontend/src/App/State/ClientSideCollectionAppState.ts @@ -0,0 +1,8 @@ +import { CustomFilter } from './AppState'; + +interface ClientSideCollectionAppState { + totalItems: number; + customFilters: CustomFilter[]; +} + +export default ClientSideCollectionAppState; 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/CustomFiltersAppState.ts b/frontend/src/App/State/CustomFiltersAppState.ts new file mode 100644 index 000000000..6ac4820c7 --- /dev/null +++ b/frontend/src/App/State/CustomFiltersAppState.ts @@ -0,0 +1,10 @@ +import AppSectionState, { + AppSectionDeleteState, +} from 'App/State/AppSectionState'; +import { CustomFilter } from './AppState'; + +interface CustomFiltersAppState + extends AppSectionState, + AppSectionDeleteState {} + +export default CustomFiltersAppState; diff --git a/frontend/src/App/State/HistoryAppState.ts b/frontend/src/App/State/HistoryAppState.ts new file mode 100644 index 000000000..e368ff86e --- /dev/null +++ b/frontend/src/App/State/HistoryAppState.ts @@ -0,0 +1,10 @@ +import AppSectionState, { + AppSectionFilterState, +} from 'App/State/AppSectionState'; +import History from 'typings/History'; + +interface HistoryAppState + extends AppSectionState, + AppSectionFilterState {} + +export default HistoryAppState; 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/QueueAppState.ts b/frontend/src/App/State/QueueAppState.ts new file mode 100644 index 000000000..05d74acac --- /dev/null +++ b/frontend/src/App/State/QueueAppState.ts @@ -0,0 +1,27 @@ +import Queue from 'typings/Queue'; +import AppSectionState, { + AppSectionFilterState, + AppSectionItemState, + Error, +} from './AppSectionState'; + +export interface QueueDetailsAppState extends AppSectionState { + params: unknown; +} + +export interface QueuePagedAppState + extends AppSectionState, + AppSectionFilterState { + isGrabbing: boolean; + grabError: Error; + isRemoving: boolean; + removeError: Error; +} + +interface QueueAppState { + status: AppSectionItemState; + details: QueueDetailsAppState; + paged: QueuePagedAppState; +} + +export default QueueAppState; diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 4c0680956..b387e13fd 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -1,18 +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 { UiSettings } from 'typings/UiSettings'; +import QualityProfile from 'typings/QualityProfile'; +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, @@ -27,14 +37,40 @@ export interface NotificationAppState extends AppSectionState, AppSectionDeleteState {} -export type UiSettingsAppState = AppSectionState; +export interface QualityProfilesAppState + extends AppSectionState, + AppSectionSchemaState {} + +export interface MetadataProfilesAppState + extends AppSectionState, + AppSectionSchemaState {} + +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; - uiSettings: UiSettingsAppState; + qualityProfiles: QualityProfilesAppState; + 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/App/State/TrackFilesAppState.ts b/frontend/src/App/State/TrackFilesAppState.ts new file mode 100644 index 000000000..403ba904d --- /dev/null +++ b/frontend/src/App/State/TrackFilesAppState.ts @@ -0,0 +1,10 @@ +import AppSectionState, { + AppSectionDeleteState, +} from 'App/State/AppSectionState'; +import { TrackFile } from 'TrackFile/TrackFile'; + +interface TrackFilesAppState + extends AppSectionState, + AppSectionDeleteState {} + +export default TrackFilesAppState; diff --git a/frontend/src/App/State/TracksAppState.ts b/frontend/src/App/State/TracksAppState.ts new file mode 100644 index 000000000..22aaabed9 --- /dev/null +++ b/frontend/src/App/State/TracksAppState.ts @@ -0,0 +1,6 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Track from 'Track/Track'; + +type TracksAppState = AppSectionState; + +export default TracksAppState; diff --git a/frontend/src/Artist/Artist.ts b/frontend/src/Artist/Artist.ts new file mode 100644 index 000000000..813dbea08 --- /dev/null +++ b/frontend/src/Artist/Artist.ts @@ -0,0 +1,51 @@ +import Album from 'Album/Album'; +import ModelBase from 'App/ModelBase'; + +export interface Image { + coverType: string; + url: string; + remoteUrl: string; +} + +export interface Statistics { + albumCount: number; + trackCount: number; + trackFileCount: number; + percentOfTracks: number; + sizeOnDisk: number; + totalTrackCount: number; +} + +export interface Ratings { + votes: number; + value: number; +} + +interface Artist extends ModelBase { + added: string; + foreignArtistId: string; + cleanName: string; + ended: boolean; + genres: string[]; + images: Image[]; + monitored: boolean; + overview: string; + path: string; + lastAlbum?: Album; + nextAlbum?: Album; + qualityProfileId: number; + metadataProfileId: number; + monitorNewItems: string; + ratings: Ratings; + rootFolderPath: string; + sortName: string; + statistics: Statistics; + status: string; + tags: number[]; + artistName: string; + artistType?: string; + disambiguation?: string; + isSaving?: boolean; +} + +export default Artist; diff --git a/frontend/src/Artist/ArtistBanner.js b/frontend/src/Artist/ArtistBanner.js index b409667b1..5483912e1 100644 --- a/frontend/src/Artist/ArtistBanner.js +++ b/frontend/src/Artist/ArtistBanner.js @@ -15,6 +15,10 @@ function ArtistBanner(props) { } ArtistBanner.propTypes = { + ...ArtistImage.propTypes, + coverType: PropTypes.string, + placeholder: PropTypes.string, + overflow: PropTypes.bool, size: PropTypes.number.isRequired }; diff --git a/frontend/src/Artist/ArtistImage.js b/frontend/src/Artist/ArtistImage.js index 6ebb48fe3..669cba8d8 100644 --- a/frontend/src/Artist/ArtistImage.js +++ b/frontend/src/Artist/ArtistImage.js @@ -7,13 +7,10 @@ function findImage(images, coverType) { } function getUrl(image, coverType, size) { - if (image) { - // Remove protocol - let url = image.url.replace(/^https?:/, ''); + const imageUrl = image?.url; - url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`); - - return url; + if (imageUrl) { + return imageUrl.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`); } } diff --git a/frontend/src/Artist/ArtistLogo.js b/frontend/src/Artist/ArtistLogo.js index 10358625f..93b91c2da 100644 --- a/frontend/src/Artist/ArtistLogo.js +++ b/frontend/src/Artist/ArtistLogo.js @@ -10,12 +10,10 @@ function findLogo(images) { } function getLogoUrl(logo, size) { - if (logo) { - // Remove protocol - let url = logo.url.replace(/^https?:/, ''); - url = url.replace('logo.jpg', `logo-${size}.jpg`); + const logoUrl = logo?.url; - return url; + if (logoUrl) { + return logoUrl.replace('logo.jpg', `logo-${size}.jpg`); } } diff --git a/frontend/src/Artist/ArtistPoster.js b/frontend/src/Artist/ArtistPoster.js index 4eebd9ca4..de594e5b9 100644 --- a/frontend/src/Artist/ArtistPoster.js +++ b/frontend/src/Artist/ArtistPoster.js @@ -15,6 +15,10 @@ function ArtistPoster(props) { } ArtistPoster.propTypes = { + ...ArtistImage.propTypes, + coverType: PropTypes.string, + placeholder: PropTypes.string, + overflow: PropTypes.bool, size: PropTypes.number.isRequired }; diff --git a/frontend/src/Artist/Delete/DeleteArtistModal.js b/frontend/src/Artist/Delete/DeleteArtistModal.js index 8e0b87296..c647b7735 100644 --- a/frontend/src/Artist/Delete/DeleteArtistModal.js +++ b/frontend/src/Artist/Delete/DeleteArtistModal.js @@ -26,6 +26,7 @@ function DeleteArtistModal(props) { } DeleteArtistModal.propTypes = { + ...DeleteArtistModalContentConnector.propTypes, isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Delete/DeleteArtistModalContent.js index 368c08107..ac1e2b041 100644 --- a/frontend/src/Artist/Delete/DeleteArtistModalContent.js +++ b/frontend/src/Artist/Delete/DeleteArtistModalContent.js @@ -23,8 +23,7 @@ class DeleteArtistModalContent extends Component { super(props, context); this.state = { - deleteFiles: false, - addImportListExclusion: false + deleteFiles: false }; } @@ -35,16 +34,11 @@ class DeleteArtistModalContent extends Component { this.setState({ deleteFiles: value }); }; - onAddImportListExclusionChange = ({ value }) => { - this.setState({ addImportListExclusion: value }); - }; - onDeleteArtistConfirmed = () => { const deleteFiles = this.state.deleteFiles; - const addImportListExclusion = this.state.addImportListExclusion; + const addImportListExclusion = this.props.deleteOptions.addImportListExclusion; this.setState({ deleteFiles: false }); - this.setState({ addImportListExclusion: false }); this.props.onDeletePress(deleteFiles, addImportListExclusion); }; @@ -56,16 +50,18 @@ class DeleteArtistModalContent extends Component { artistName, path, statistics, - onModalClose + deleteOptions, + onModalClose, + onDeleteOptionChange } = this.props; const { - trackFileCount, - sizeOnDisk + trackFileCount = 0, + sizeOnDisk = 0 } = statistics; const deleteFiles = this.state.deleteFiles; - const addImportListExclusion = this.state.addImportListExclusion; + const addImportListExclusion = deleteOptions.addImportListExclusion; let deleteFilesLabel = `Delete ${trackFileCount} Track Files`; let deleteFilesHelpText = translate('DeleteFilesHelpText'); @@ -117,7 +113,7 @@ class DeleteArtistModalContent extends Component { value={addImportListExclusion} helpText={translate('AddImportListExclusionArtistHelpText')} kind={kinds.DANGER} - onChange={this.onAddImportListExclusionChange} + onChange={onDeleteOptionChange} /> @@ -139,14 +135,14 @@ class DeleteArtistModalContent extends Component { @@ -158,14 +154,14 @@ DeleteArtistModalContent.propTypes = { artistName: PropTypes.string.isRequired, path: PropTypes.string.isRequired, statistics: PropTypes.object.isRequired, + deleteOptions: PropTypes.object.isRequired, + onDeleteOptionChange: PropTypes.func.isRequired, onDeletePress: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; DeleteArtistModalContent.defaultProps = { - statistics: { - trackFileCount: 0 - } + statistics: {} }; export default DeleteArtistModalContent; diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js b/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js index 5b7fef377..321dc63a6 100644 --- a/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js +++ b/frontend/src/Artist/Delete/DeleteArtistModalContentConnector.js @@ -1,56 +1,44 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { deleteArtist } from 'Store/Actions/artistActions'; +import { deleteArtist, setDeleteOption } from 'Store/Actions/artistActions'; import createArtistSelector from 'Store/Selectors/createArtistSelector'; import DeleteArtistModalContent from './DeleteArtistModalContent'; function createMapStateToProps() { return createSelector( + (state) => state.artist.deleteOptions, createArtistSelector(), - (artist) => { - return artist; + (deleteOptions, artist) => { + return { + ...artist, + deleteOptions + }; } ); } -const mapDispatchToProps = { - deleteArtist -}; +function createMapDispatchToProps(dispatch, props) { + return { + onDeleteOptionChange(option) { + dispatch( + setDeleteOption({ + [option.name]: option.value + }) + ); + }, -class DeleteArtistModalContentConnector extends Component { + onDeletePress(deleteFiles, addImportListExclusion) { + dispatch( + deleteArtist({ + id: props.artistId, + deleteFiles, + addImportListExclusion + }) + ); - // - // Listeners - - onDeletePress = (deleteFiles, addImportListExclusion) => { - this.props.deleteArtist({ - id: this.props.artistId, - deleteFiles, - addImportListExclusion - }); - - this.props.onModalClose(true); + props.onModalClose(true); + } }; - - // - // Render - - render() { - return ( - - ); - } } -DeleteArtistModalContentConnector.propTypes = { - artistId: PropTypes.number.isRequired, - onModalClose: PropTypes.func.isRequired, - deleteArtist: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(DeleteArtistModalContentConnector); +export default connect(createMapStateToProps, createMapDispatchToProps)(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 ( { + this.setState({ isMonitorOptionsModalOpen: true }); + }; + + onMonitorOptionsClose = () => { + this.setState({ isMonitorOptionsModalOpen: false }); + }; + onExpandAllPress = () => { const { allExpanded, @@ -185,7 +192,7 @@ class ArtistDetails extends Component { artistName, ratings, path, - statistics, + statistics = {}, qualityProfileId, monitored, genres, @@ -215,8 +222,8 @@ class ArtistDetails extends Component { } = this.props; const { - trackFileCount, - sizeOnDisk + trackFileCount = 0, + sizeOnDisk = 0 } = statistics; const { @@ -228,13 +235,14 @@ class ArtistDetails extends Component { isArtistHistoryModalOpen, isInteractiveImportModalOpen, isInteractiveSearchModalOpen, + isMonitorOptionsModalOpen, allExpanded, allCollapsed, expandedState } = this.state; const continuing = status === 'continuing'; - const endedString = artistType === 'Person' ? 'Deceased' : 'Inactive'; + const endedString = artistType === 'Person' ? translate('Deceased') : translate('Inactive'); let trackFilesCountMessage = translate('TrackFilesCountMessage'); @@ -323,6 +331,12 @@ class ArtistDetails extends Component { + + + @@ -510,7 +525,7 @@ class ArtistDetails extends Component { /> - {monitored ? 'Monitored' : 'Unmonitored'} + {monitored ? translate('Monitored') : translate('Unmonitored')} @@ -525,7 +540,7 @@ class ArtistDetails extends Component { /> - {continuing ? 'Continuing' : endedString} + {continuing ? translate('Continuing') : endedString} @@ -541,7 +556,7 @@ class ArtistDetails extends Component { /> - Links + {translate('Links')} } @@ -597,17 +612,19 @@ class ArtistDetails extends Component { } { - !isFetching && albumsError && -
- {translate('LoadingAlbumsFailed')} -
+ !isFetching && albumsError ? + + {translate('AlbumsLoadError')} + : + null } { - !isFetching && trackFilesError && -
- {translate('LoadingTrackFilesFailed')} -
+ !isFetching && trackFilesError ? + + {translate('TrackFilesLoadError')} + : + null } { @@ -693,6 +710,12 @@ class ArtistDetails extends Component { artistId={id} onModalClose={this.onInteractiveSearchModalClose} /> + + ); diff --git a/frontend/src/Artist/Details/ArtistDetailsConnector.js b/frontend/src/Artist/Details/ArtistDetailsConnector.js index cd2a911c5..bed30a937 100644 --- a/frontend/src/Artist/Details/ArtistDetailsConnector.js +++ b/frontend/src/Artist/Details/ArtistDetailsConnector.js @@ -107,7 +107,6 @@ function createMapStateToProps() { const isRefreshing = isArtistRefreshing || allArtistRefreshing; const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.ARTIST_SEARCH, artistId: artist.id })); const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, artistId: artist.id })); - const isRenamingArtistCommand = findCommand(commands, { name: commandNames.RENAME_ARTIST }); const isRenamingArtist = ( isCommandExecuting(isRenamingArtistCommand) && diff --git a/frontend/src/Artist/Details/ArtistDetailsSeason.js b/frontend/src/Artist/Details/ArtistDetailsSeason.js index 9af3f8a17..004613e30 100644 --- a/frontend/src/Artist/Details/ArtistDetailsSeason.js +++ b/frontend/src/Artist/Details/ArtistDetailsSeason.js @@ -20,34 +20,45 @@ import AlbumGroupInfo from './AlbumGroupInfo'; import AlbumRowConnector from './AlbumRowConnector'; import styles from './ArtistDetailsSeason.css'; -function getAlbumTypeStatistics(albums) { +function getAlbumStatistics(albums) { let albumCount = 0; + let albumFileCount = 0; let trackFileCount = 0; let totalAlbumCount = 0; let monitoredAlbumCount = 0; let hasMonitoredAlbums = false; let sizeOnDisk = 0; - albums.forEach((album) => { - 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 getAlbumTypeStatistics(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,12 +203,13 @@ class ArtistDetailsSeason extends Component { const { albumCount, + albumFileCount, totalAlbumCount, trackFileCount, monitoredAlbumCount, - sizeOnDisk, - hasMonitoredAlbums - } = getAlbumTypeStatistics(items); + hasMonitoredAlbums, + sizeOnDisk = 0 + } = getAlbumStatistics(items); const { isOrganizeModalOpen, @@ -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/EditArtistModal.js b/frontend/src/Artist/Edit/EditArtistModal.js index 6e99a2f53..f221e728c 100644 --- a/frontend/src/Artist/Edit/EditArtistModal.js +++ b/frontend/src/Artist/Edit/EditArtistModal.js @@ -18,6 +18,7 @@ function EditArtistModal({ isOpen, onModalClose, ...otherProps }) { } EditArtistModal.propTypes = { + ...EditArtistModalContentConnector.propTypes, isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Edit/EditArtistModalConnector.js b/frontend/src/Artist/Edit/EditArtistModalConnector.js index 56e336201..9c4e6325f 100644 --- a/frontend/src/Artist/Edit/EditArtistModalConnector.js +++ b/frontend/src/Artist/Edit/EditArtistModalConnector.js @@ -32,6 +32,7 @@ class EditArtistModalConnector extends Component { } EditArtistModalConnector.propTypes = { + ...EditArtistModal.propTypes, onModalClose: PropTypes.func.isRequired, clearPendingChanges: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Edit/EditArtistModalContent.js b/frontend/src/Artist/Edit/EditArtistModalContent.js index c919aec88..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'; @@ -35,6 +35,10 @@ class EditArtistModalContent extends Component { // // Listeners + onCancelPress = () => { + this.setState({ isConfirmMoveModalOpen: false }); + }; + onSavePress = () => { const { isPathChanging, @@ -89,7 +93,7 @@ class EditArtistModalContent extends Component {
- + {translate('Monitored')} @@ -103,9 +107,10 @@ class EditArtistModalContent extends Component { /> - + {translate('MonitorNewItems')} + - + {translate('QualityProfile')} @@ -142,10 +147,10 @@ class EditArtistModalContent extends Component { { - showMetadataProfile && - + showMetadataProfile ? + - Metadata Profile + {translate('MetadataProfile')} - + : + null } - + {translate('Path')} @@ -185,7 +191,7 @@ class EditArtistModalContent extends Component { /> - + {translate('Tags')} @@ -205,7 +211,7 @@ class EditArtistModalContent extends Component { kind={kinds.DANGER} onPress={onDeleteArtistPress} > - Delete + {translate('Delete')} - - - - - ); -} - -RetagArtistModalContent.propTypes = { - artistNames: PropTypes.arrayOf(PropTypes.string).isRequired, - onModalClose: PropTypes.func.isRequired, - onRetagArtistPress: PropTypes.func.isRequired -}; - -export default RetagArtistModalContent; diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContentConnector.js b/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContentConnector.js deleted file mode 100644 index 83e9fc06d..000000000 --- a/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContentConnector.js +++ /dev/null @@ -1,67 +0,0 @@ -import _ from 'lodash'; -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 createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; -import RetagArtistModalContent from './RetagArtistModalContent'; - -function createMapStateToProps() { - return createSelector( - (state, { artistIds }) => artistIds, - createAllArtistSelector(), - (artistIds, allArtists) => { - const artist = _.intersectionWith(allArtists, artistIds, (s, id) => { - return s.id === id; - }); - - const sortedArtist = _.orderBy(artist, 'sortName'); - const artistNames = _.map(sortedArtist, 'artistName'); - - return { - artistNames - }; - } - ); -} - -const mapDispatchToProps = { - executeCommand -}; - -class RetagArtistModalContentConnector extends Component { - - // - // Listeners - - onRetagArtistPress = () => { - this.props.executeCommand({ - name: commandNames.RETAG_ARTIST, - artistIds: this.props.artistIds - }); - - this.props.onModalClose(true); - }; - - // - // Render - - render(props) { - return ( - - ); - } -} - -RetagArtistModalContentConnector.propTypes = { - artistIds: PropTypes.arrayOf(PropTypes.number).isRequired, - onModalClose: PropTypes.func.isRequired, - executeCommand: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(RetagArtistModalContentConnector); diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js deleted file mode 100644 index 11fd79d5d..000000000 --- a/frontend/src/Artist/Editor/Delete/DeleteArtistModal.js +++ /dev/null @@ -1,31 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import DeleteArtistModalContentConnector from './DeleteArtistModalContentConnector'; - -function DeleteArtistModal(props) { - const { - isOpen, - onModalClose, - ...otherProps - } = props; - - return ( - - - - ); -} - -DeleteArtistModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default DeleteArtistModal; diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.js deleted file mode 100644 index 6e04fc7e6..000000000 --- a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.js +++ /dev/null @@ -1,124 +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 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 } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import styles from './DeleteArtistModalContent.css'; - -class DeleteArtistModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - deleteFiles: false - }; - } - - // - // Listeners - - onDeleteFilesChange = ({ value }) => { - this.setState({ deleteFiles: value }); - }; - - onDeleteArtistConfirmed = () => { - const deleteFiles = this.state.deleteFiles; - - this.setState({ deleteFiles: false }); - this.props.onDeleteSelectedPress(deleteFiles); - }; - - // - // Render - - render() { - const { - artist, - onModalClose - } = this.props; - const deleteFiles = this.state.deleteFiles; - - return ( - - - {translate('DeleteArtist')} - - - -
- - {`Delete Artist Folder${artist.length > 1 ? 's' : ''}`} - - 1 ? 's' : ''} and all contents`} - kind={kinds.DANGER} - onChange={this.onDeleteFilesChange} - /> - -
- -
- {`Are you sure you want to delete ${artist.length} selected artist${artist.length > 1 ? 's' : ''}${deleteFiles ? ' and all contents' : ''}?`} -
- -
    - { - artist.map((s) => { - return ( -
  • - {s.artistName} - - { - deleteFiles && - - - - - {s.path} - - - } -
  • - ); - }) - } -
-
- - - - - - -
- ); - } -} - -DeleteArtistModalContent.propTypes = { - artist: PropTypes.arrayOf(PropTypes.object).isRequired, - onModalClose: PropTypes.func.isRequired, - onDeleteSelectedPress: PropTypes.func.isRequired -}; - -export default DeleteArtistModalContent; diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js b/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js deleted file mode 100644 index e3b0fa0fd..000000000 --- a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContentConnector.js +++ /dev/null @@ -1,45 +0,0 @@ -import _ from 'lodash'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { bulkDeleteArtist } from 'Store/Actions/artistEditorActions'; -import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; -import DeleteArtistModalContent from './DeleteArtistModalContent'; - -function createMapStateToProps() { - return createSelector( - (state, { artistIds }) => artistIds, - createAllArtistSelector(), - (artistIds, allArtists) => { - const selectedArtist = _.intersectionWith(allArtists, artistIds, (s, id) => { - return s.id === id; - }); - - const sortedArtist = _.orderBy(selectedArtist, 'sortName'); - const artist = _.map(sortedArtist, (s) => { - return { - artistName: s.artistName, - path: s.path - }; - }); - - return { - artist - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onDeleteSelectedPress(deleteFiles) { - dispatch(bulkDeleteArtist({ - artistIds: props.artistIds, - deleteFiles - })); - - props.onModalClose(); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteArtistModalContent); diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModal.js b/frontend/src/Artist/Editor/Organize/OrganizeArtistModal.js deleted file mode 100644 index 412396355..000000000 --- a/frontend/src/Artist/Editor/Organize/OrganizeArtistModal.js +++ /dev/null @@ -1,31 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import OrganizeArtistModalContentConnector from './OrganizeArtistModalContentConnector'; - -function OrganizeArtistModal(props) { - const { - isOpen, - onModalClose, - ...otherProps - } = props; - - return ( - - - - ); -} - -OrganizeArtistModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default OrganizeArtistModal; diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js deleted file mode 100644 index 30a6929cd..000000000 --- a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.js +++ /dev/null @@ -1,75 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Alert from 'Components/Alert'; -import Icon from 'Components/Icon'; -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 { icons, kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import styles from './OrganizeArtistModalContent.css'; - -function OrganizeArtistModalContent(props) { - const { - artistNames, - onModalClose, - onOrganizeArtistPress - } = props; - - return ( - - - {translate('OrganizeArtist')} - - - - - Tip: To preview a rename, select "Cancel", then select any artist name and use the - - - -
- Are you sure you want to organize all files in the {artistNames.length} selected artist? -
- -
    - { - artistNames.map((artistName) => { - return ( -
  • - {artistName} -
  • - ); - }) - } -
-
- - - - - - -
- ); -} - -OrganizeArtistModalContent.propTypes = { - artistNames: PropTypes.arrayOf(PropTypes.string).isRequired, - onModalClose: PropTypes.func.isRequired, - onOrganizeArtistPress: PropTypes.func.isRequired -}; - -export default OrganizeArtistModalContent; diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContentConnector.js b/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContentConnector.js deleted file mode 100644 index 3fad07da0..000000000 --- a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContentConnector.js +++ /dev/null @@ -1,67 +0,0 @@ -import _ from 'lodash'; -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 createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; -import OrganizeArtistModalContent from './OrganizeArtistModalContent'; - -function createMapStateToProps() { - return createSelector( - (state, { artistIds }) => artistIds, - createAllArtistSelector(), - (artistIds, allArtists) => { - const artist = _.intersectionWith(allArtists, artistIds, (s, id) => { - return s.id === id; - }); - - const sortedArtist = _.orderBy(artist, 'sortName'); - const artistNames = _.map(sortedArtist, 'artistName'); - - return { - artistNames - }; - } - ); -} - -const mapDispatchToProps = { - executeCommand -}; - -class OrganizeArtistModalContentConnector extends Component { - - // - // Listeners - - onOrganizeArtistPress = () => { - this.props.executeCommand({ - name: commandNames.RENAME_ARTIST, - artistIds: this.props.artistIds - }); - - this.props.onModalClose(true); - }; - - // - // Render - - render(props) { - return ( - - ); - } -} - -OrganizeArtistModalContentConnector.propTypes = { - artistIds: PropTypes.arrayOf(PropTypes.number).isRequired, - onModalClose: PropTypes.func.isRequired, - executeCommand: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(OrganizeArtistModalContentConnector); diff --git a/frontend/src/Artist/Editor/Tags/TagsModalContent.js b/frontend/src/Artist/Editor/Tags/TagsModalContent.js deleted file mode 100644 index 5245ba097..000000000 --- a/frontend/src/Artist/Editor/Tags/TagsModalContent.js +++ /dev/null @@ -1,194 +0,0 @@ -import _ from 'lodash'; -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 Label from 'Components/Label'; -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, sizes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import styles from './TagsModalContent.css'; - -class TagsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - tags: [], - applyTags: 'add' - }; - } - - // - // Lifecycle - - onInputChange = ({ name, value }) => { - this.setState({ [name]: value }); - }; - - onApplyTagsPress = () => { - const { - tags, - applyTags - } = this.state; - - this.props.onApplyTagsPress(tags, applyTags); - }; - - // - // Render - - render() { - const { - artistTags, - tagList, - onModalClose - } = this.props; - - const { - tags, - applyTags - } = this.state; - - const applyTagsOptions = [ - { key: 'add', value: translate('Add') }, - { key: 'remove', value: translate('Remove') }, - { key: 'replace', value: translate('Replace') } - ]; - - return ( - - - Tags - - - - - - - {translate('Tags')} - - - - - - - - {translate('ApplyTags')} - - - - - - - - {translate('Result')} - - -
- { - artistTags.map((t) => { - const tag = _.find(tagList, { id: t }); - - if (!tag) { - return null; - } - - const removeTag = (applyTags === 'remove' && tags.indexOf(t) > -1) || - (applyTags === 'replace' && tags.indexOf(t) === -1); - - return ( - - ); - }) - } - - { - (applyTags === 'add' || applyTags === 'replace') && - tags.map((t) => { - const tag = _.find(tagList, { id: t }); - - if (!tag) { - return null; - } - - if (artistTags.indexOf(t) > -1) { - return null; - } - - return ( - - ); - }) - } -
-
- -
- - - - - - -
- ); - } -} - -TagsModalContent.propTypes = { - artistTags: PropTypes.arrayOf(PropTypes.number).isRequired, - tagList: PropTypes.arrayOf(PropTypes.object).isRequired, - onModalClose: PropTypes.func.isRequired, - onApplyTagsPress: PropTypes.func.isRequired -}; - -export default TagsModalContent; diff --git a/frontend/src/Artist/Editor/Tags/TagsModalContentConnector.js b/frontend/src/Artist/Editor/Tags/TagsModalContentConnector.js deleted file mode 100644 index 6741e8b5c..000000000 --- a/frontend/src/Artist/Editor/Tags/TagsModalContentConnector.js +++ /dev/null @@ -1,36 +0,0 @@ -import _ from 'lodash'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; -import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import TagsModalContent from './TagsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state, { artistIds }) => artistIds, - createAllArtistSelector(), - createTagsSelector(), - (artistIds, allArtists, tagList) => { - const artist = _.intersectionWith(allArtists, artistIds, (s, id) => { - return s.id === id; - }); - - const artistTags = _.uniq(_.concat(..._.map(artist, 'tags'))); - - return { - artistTags, - tagList - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onAction() { - // Do something - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(TagsModalContent); diff --git a/frontend/src/Artist/History/ArtistHistoryModal.js b/frontend/src/Artist/History/ArtistHistoryModal.js index 7139d7633..a4362484c 100644 --- a/frontend/src/Artist/History/ArtistHistoryModal.js +++ b/frontend/src/Artist/History/ArtistHistoryModal.js @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; import ArtistHistoryModalContentConnector from './ArtistHistoryModalContentConnector'; function ArtistHistoryModal(props) { @@ -13,6 +14,7 @@ function ArtistHistoryModal(props) { return ( translate('Date'), - isVisible: true - }, - { - name: 'details', - label: () => translate('Details'), + name: 'customFormats', + label: () => translate('CustomFormats'), + isSortable: false, isVisible: true }, { @@ -53,9 +49,13 @@ const columns = [ isSortable: true, isVisible: true }, + { + name: 'date', + label: () => translate('Date'), + isVisible: true + }, { name: 'actions', - label: () => translate('Actions'), isVisible: true } ]; diff --git a/frontend/src/Artist/History/ArtistHistoryRow.css b/frontend/src/Artist/History/ArtistHistoryRow.css index a62b9c2bb..33dba8df9 100644 --- a/frontend/src/Artist/History/ArtistHistoryRow.css +++ b/frontend/src/Artist/History/ArtistHistoryRow.css @@ -4,7 +4,6 @@ word-break: break-word; } -.details, .actions { composes: cell from '~Components/Table/Cells/TableRowCell.css'; diff --git a/frontend/src/Artist/History/ArtistHistoryRow.css.d.ts b/frontend/src/Artist/History/ArtistHistoryRow.css.d.ts index e3425487e..b0b91a6b8 100644 --- a/frontend/src/Artist/History/ArtistHistoryRow.css.d.ts +++ b/frontend/src/Artist/History/ArtistHistoryRow.css.d.ts @@ -2,7 +2,6 @@ // Please do not change this file! interface CssExports { 'actions': string; - 'details': string; 'sourceTitle': string; } export const cssExports: CssExports; diff --git a/frontend/src/Artist/History/ArtistHistoryRow.js b/frontend/src/Artist/History/ArtistHistoryRow.js index d48e2eb31..fe8326378 100644 --- a/frontend/src/Artist/History/ArtistHistoryRow.js +++ b/frontend/src/Artist/History/ArtistHistoryRow.js @@ -11,7 +11,6 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo 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 { icons, kinds, tooltipPositions } from 'Helpers/Props'; import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; import translate from 'Utilities/String/translate'; @@ -82,6 +81,7 @@ class ArtistHistoryRow extends Component { customFormatScore, date, data, + downloadId, album } = this.props; @@ -111,11 +111,19 @@ class ArtistHistoryRow extends Component { />
+ + + + + + {formatCustomFormatScore(customFormatScore, customFormats.length)} + + - + } position={tooltipPositions.LEFT} /> - - - } - position={tooltipPositions.BOTTOM} - /> - - - { eventType === 'grabbed' && } @@ -180,6 +177,7 @@ ArtistHistoryRow.propTypes = { customFormatScore: PropTypes.number.isRequired, date: PropTypes.string.isRequired, data: PropTypes.object.isRequired, + downloadId: PropTypes.string, fullArtist: PropTypes.bool.isRequired, artist: PropTypes.object.isRequired, album: PropTypes.object.isRequired, diff --git a/frontend/src/Artist/Index/ArtistIndex.css b/frontend/src/Artist/Index/ArtistIndex.css index 43b445c3c..908cb2d16 100644 --- a/frontend/src/Artist/Index/ArtistIndex.css +++ b/frontend/src/Artist/Index/ArtistIndex.css @@ -13,6 +13,7 @@ .contentBody { composes: contentBody from '~Components/Page/PageContentBody.css'; + position: relative; display: flex; flex-direction: column; } diff --git a/frontend/src/Artist/Index/ArtistIndex.js b/frontend/src/Artist/Index/ArtistIndex.js deleted file mode 100644 index 6f68f7fcd..000000000 --- a/frontend/src/Artist/Index/ArtistIndex.js +++ /dev/null @@ -1,407 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import NoArtist from 'Artist/NoArtist'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import PageJumpBar from 'Components/Page/PageJumpBar'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import { align, icons, sortDirections } from 'Helpers/Props'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import translate from 'Utilities/String/translate'; -import ArtistIndexFooterConnector from './ArtistIndexFooterConnector'; -import ArtistIndexBannersConnector from './Banners/ArtistIndexBannersConnector'; -import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal'; -import ArtistIndexFilterMenu from './Menus/ArtistIndexFilterMenu'; -import ArtistIndexSortMenu from './Menus/ArtistIndexSortMenu'; -import ArtistIndexViewMenu from './Menus/ArtistIndexViewMenu'; -import ArtistIndexOverviewsConnector from './Overview/ArtistIndexOverviewsConnector'; -import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal'; -import ArtistIndexPostersConnector from './Posters/ArtistIndexPostersConnector'; -import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal'; -import ArtistIndexTableConnector from './Table/ArtistIndexTableConnector'; -import ArtistIndexTableOptionsConnector from './Table/ArtistIndexTableOptionsConnector'; -import styles from './ArtistIndex.css'; - -function getViewComponent(view) { - if (view === 'posters') { - return ArtistIndexPostersConnector; - } - - if (view === 'banners') { - return ArtistIndexBannersConnector; - } - - if (view === 'overview') { - return ArtistIndexOverviewsConnector; - } - - return ArtistIndexTableConnector; -} - -class ArtistIndex extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - scroller: null, - jumpBarItems: { order: [] }, - jumpToCharacter: null, - isPosterOptionsModalOpen: false, - isBannerOptionsModalOpen: false, - isOverviewOptionsModalOpen: false - }; - } - - componentDidMount() { - this.setJumpBarItems(); - } - - componentDidUpdate(prevProps) { - const { - items, - sortKey, - sortDirection - } = this.props; - - if (sortKey !== prevProps.sortKey || - sortDirection !== prevProps.sortDirection || - hasDifferentItemsOrOrder(prevProps.items, items) - ) { - this.setJumpBarItems(); - } - - if (this.state.jumpToCharacter != null) { - this.setState({ jumpToCharacter: null }); - } - } - - // - // Control - - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - }; - - setJumpBarItems() { - const { - items, - sortKey, - sortDirection - } = this.props; - - // Reset if not sorting by sortName - if (sortKey !== 'sortName') { - this.setState({ jumpBarItems: { order: [] } }); - return; - } - - const characters = _.reduce(items, (acc, item) => { - let char = item.sortName.charAt(0); - - if (!isNaN(char)) { - char = '#'; - } - - if (char in acc) { - acc[char] = acc[char] + 1; - } else { - acc[char] = 1; - } - - return acc; - }, {}); - - const order = Object.keys(characters).sort(); - - // Reverse if sorting descending - if (sortDirection === sortDirections.DESCENDING) { - order.reverse(); - } - - const jumpBarItems = { - characters, - order - }; - - this.setState({ jumpBarItems }); - } - - // - // Listeners - - onPosterOptionsPress = () => { - this.setState({ isPosterOptionsModalOpen: true }); - }; - - onPosterOptionsModalClose = () => { - this.setState({ isPosterOptionsModalOpen: false }); - }; - - onBannerOptionsPress = () => { - this.setState({ isBannerOptionsModalOpen: true }); - }; - - onBannerOptionsModalClose = () => { - this.setState({ isBannerOptionsModalOpen: false }); - }; - - onOverviewOptionsPress = () => { - this.setState({ isOverviewOptionsModalOpen: true }); - }; - - onOverviewOptionsModalClose = () => { - this.setState({ isOverviewOptionsModalOpen: false }); - }; - - onJumpBarItemPress = (jumpToCharacter) => { - this.setState({ jumpToCharacter }); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - totalItems, - items, - columns, - selectedFilterKey, - filters, - customFilters, - sortKey, - sortDirection, - view, - isRefreshingArtist, - isRssSyncExecuting, - onScroll, - onSortSelect, - onFilterSelect, - onViewSelect, - onRefreshArtistPress, - onRssSyncPress, - ...otherProps - } = this.props; - - const { - scroller, - jumpBarItems, - jumpToCharacter, - isPosterOptionsModalOpen, - isBannerOptionsModalOpen, - isOverviewOptionsModalOpen - } = this.state; - - const ViewComponent = getViewComponent(view); - const isLoaded = !!(!error && isPopulated && items.length && scroller); - const hasNoArtist = !totalItems; - - return ( - - - - - - - - - - - { - view === 'table' ? - - - : - null - } - - { - view === 'posters' ? - : - null - } - - { - view === 'banners' ? - : - null - } - - { - view === 'overview' ? - : - null - } - - - - - - - - - - - -
- - { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && -
- {getErrorMessage(error, 'Failed to load artist from API')} -
- } - - { - isLoaded && -
- - - -
- } - - { - !error && isPopulated && !items.length && - - } -
- - { - isLoaded && !!jumpBarItems.order.length && - - } -
- - - - - - -
- ); - } -} - -ArtistIndex.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - totalItems: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - sortDirection: PropTypes.oneOf(sortDirections.all), - view: PropTypes.string.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isRssSyncExecuting: PropTypes.bool.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - onSortSelect: PropTypes.func.isRequired, - onFilterSelect: PropTypes.func.isRequired, - onViewSelect: PropTypes.func.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onRssSyncPress: PropTypes.func.isRequired, - onScroll: PropTypes.func.isRequired -}; - -export default ArtistIndex; diff --git a/frontend/src/Artist/Index/ArtistIndex.tsx b/frontend/src/Artist/Index/ArtistIndex.tsx new file mode 100644 index 000000000..2fcc0fadf --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndex.tsx @@ -0,0 +1,375 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { SelectProvider } from 'App/SelectContext'; +import ArtistAppState, { ArtistIndexAppState } from 'App/State/ArtistAppState'; +import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState'; +import NoArtist from 'Artist/NoArtist'; +import { RSS_SYNC } from 'Commands/commandNames'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageJumpBar from 'Components/Page/PageJumpBar'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import withScrollPosition from 'Components/withScrollPosition'; +import { align, icons } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; +import { + setArtistFilter, + setArtistSort, + setArtistTableOption, + setArtistView, +} from 'Store/Actions/artistIndexActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchQueueDetails } from 'Store/Actions/queueActions'; +import scrollPositions from 'Store/scrollPositions'; +import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import ArtistIndexFooter from './ArtistIndexFooter'; +import ArtistIndexRefreshArtistsButton from './ArtistIndexRefreshArtistsButton'; +import ArtistIndexBanners from './Banners/ArtistIndexBanners'; +import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal'; +import ArtistIndexFilterMenu from './Menus/ArtistIndexFilterMenu'; +import ArtistIndexSortMenu from './Menus/ArtistIndexSortMenu'; +import ArtistIndexViewMenu from './Menus/ArtistIndexViewMenu'; +import ArtistIndexOverviews from './Overview/ArtistIndexOverviews'; +import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal'; +import ArtistIndexPosters from './Posters/ArtistIndexPosters'; +import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal'; +import ArtistIndexSelectAllButton from './Select/ArtistIndexSelectAllButton'; +import ArtistIndexSelectAllMenuItem from './Select/ArtistIndexSelectAllMenuItem'; +import ArtistIndexSelectFooter from './Select/ArtistIndexSelectFooter'; +import ArtistIndexSelectModeButton from './Select/ArtistIndexSelectModeButton'; +import ArtistIndexSelectModeMenuItem from './Select/ArtistIndexSelectModeMenuItem'; +import ArtistIndexTable from './Table/ArtistIndexTable'; +import ArtistIndexTableOptions from './Table/ArtistIndexTableOptions'; +import styles from './ArtistIndex.css'; + +function getViewComponent(view: string) { + if (view === 'posters') { + return ArtistIndexPosters; + } + + if (view === 'banners') { + return ArtistIndexBanners; + } + + if (view === 'overview') { + return ArtistIndexOverviews; + } + + return ArtistIndexTable; +} + +interface ArtistIndexProps { + initialScrollTop?: number; +} + +const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + columns, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + view, + }: ArtistAppState & ArtistIndexAppState & ClientSideCollectionAppState = + useSelector(createArtistClientSideCollectionItemsSelector('artistIndex')); + + const isRssSyncExecuting = useSelector( + createCommandExecutingSelector(RSS_SYNC) + ); + const { isSmallScreen } = useSelector(createDimensionsSelector()); + const dispatch = useDispatch(); + const scrollerRef = useRef(null); + const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); + const [jumpToCharacter, setJumpToCharacter] = useState( + undefined + ); + const [isSelectMode, setIsSelectMode] = useState(false); + + useEffect(() => { + dispatch(fetchQueueDetails({ all: true })); + }, [dispatch]); + + const onRssSyncPress = useCallback(() => { + dispatch( + executeCommand({ + name: RSS_SYNC, + }) + ); + }, [dispatch]); + + const onSelectModePress = useCallback(() => { + setIsSelectMode(!isSelectMode); + }, [isSelectMode, setIsSelectMode]); + + const onTableOptionChange = useCallback( + (payload: unknown) => { + dispatch(setArtistTableOption(payload)); + }, + [dispatch] + ); + + const onViewSelect = useCallback( + (value: string) => { + dispatch(setArtistView({ view: value })); + + if (scrollerRef.current) { + scrollerRef.current.scrollTo(0, 0); + } + }, + [scrollerRef, dispatch] + ); + + const onSortSelect = useCallback( + (value: string) => { + dispatch(setArtistSort({ sortKey: value })); + }, + [dispatch] + ); + + const onFilterSelect = useCallback( + (value: string) => { + dispatch(setArtistFilter({ selectedFilterKey: value })); + }, + [dispatch] + ); + + const onOptionsPress = useCallback(() => { + setIsOptionsModalOpen(true); + }, [setIsOptionsModalOpen]); + + const onOptionsModalClose = useCallback(() => { + setIsOptionsModalOpen(false); + }, [setIsOptionsModalOpen]); + + const onJumpBarItemPress = useCallback( + (character: string) => { + setJumpToCharacter(character); + }, + [setJumpToCharacter] + ); + + const onScroll = useCallback( + ({ scrollTop }: { scrollTop: number }) => { + setJumpToCharacter(undefined); + scrollPositions.artistIndex = scrollTop; + }, + [setJumpToCharacter] + ); + + const jumpBarItems = useMemo(() => { + // Reset if not sorting by sortName + if (sortKey !== 'sortName') { + return { + order: [], + }; + } + + const characters = items.reduce((acc: Record, item) => { + let char = item.sortName.charAt(0); + + if (!isNaN(Number(char))) { + char = '#'; + } + + if (char in acc) { + acc[char] = acc[char] + 1; + } else { + acc[char] = 1; + } + + return acc; + }, {}); + + const order = Object.keys(characters).sort(); + + // Reverse if sorting descending + if (sortDirection === SortDirection.Descending) { + order.reverse(); + } + + return { + characters, + order, + }; + }, [items, sortKey, sortDirection]); + const ViewComponent = useMemo(() => getViewComponent(view), [view]); + + const isLoaded = !!(!error && isPopulated && items.length); + const hasNoArtist = !totalItems; + + return ( + + + + + + + + + + + + + + + + + {view === 'table' ? ( + + + + ) : ( + + )} + + + + + + + + + + +
+ + {isFetching && !isPopulated ? : null} + + {!isFetching && !!error ? ( +
+ {getErrorMessage(error, 'Failed to load artist from API')} +
+ ) : null} + + {isLoaded ? ( +
+ + + +
+ ) : null} + + {!error && isPopulated && !items.length ? ( + + ) : null} +
+ {isLoaded && !!jumpBarItems.order.length ? ( + + ) : null} +
+ + {isSelectMode ? : null} + + {view === 'posters' ? ( + + ) : null} + {view === 'banners' ? ( + + ) : null} + {view === 'overview' ? ( + + ) : null} +
+
+ ); +}, 'artistIndex'); + +export default ArtistIndex; diff --git a/frontend/src/Artist/Index/ArtistIndexConnector.js b/frontend/src/Artist/Index/ArtistIndexConnector.js deleted file mode 100644 index 541d9819e..000000000 --- a/frontend/src/Artist/Index/ArtistIndexConnector.js +++ /dev/null @@ -1,106 +0,0 @@ -/* eslint max-params: 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 withScrollPosition from 'Components/withScrollPosition'; -import { setArtistFilter, setArtistSort, setArtistTableOption, setArtistView } from 'Store/Actions/artistIndexActions'; -import { executeCommand } from 'Store/Actions/commandActions'; -import scrollPositions from 'Store/scrollPositions'; -import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import ArtistIndex from './ArtistIndex'; - -function createMapStateToProps() { - return createSelector( - createArtistClientSideCollectionItemsSelector('artistIndex'), - createCommandExecutingSelector(commandNames.REFRESH_ARTIST), - createCommandExecutingSelector(commandNames.RSS_SYNC), - createDimensionsSelector(), - ( - artist, - isRefreshingArtist, - isRssSyncExecuting, - dimensionsState - ) => { - return { - ...artist, - isRefreshingArtist, - isRssSyncExecuting, - isSmallScreen: dimensionsState.isSmallScreen - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onTableOptionChange(payload) { - dispatch(setArtistTableOption(payload)); - }, - - onSortSelect(sortKey) { - dispatch(setArtistSort({ sortKey })); - }, - - onFilterSelect(selectedFilterKey) { - dispatch(setArtistFilter({ selectedFilterKey })); - }, - - dispatchSetArtistView(view) { - dispatch(setArtistView({ view })); - }, - - onRefreshArtistPress() { - dispatch(executeCommand({ - name: commandNames.REFRESH_ARTIST - })); - }, - - onRssSyncPress() { - dispatch(executeCommand({ - name: commandNames.RSS_SYNC - })); - } - }; -} - -class ArtistIndexConnector extends Component { - - // - // Listeners - - onViewSelect = (view) => { - this.props.dispatchSetArtistView(view); - }; - - onScroll = ({ scrollTop }) => { - scrollPositions.artistIndex = scrollTop; - }; - - // - // Render - - render() { - return ( - - ); - } -} - -ArtistIndexConnector.propTypes = { - isSmallScreen: PropTypes.bool.isRequired, - view: PropTypes.string.isRequired, - dispatchSetArtistView: PropTypes.func.isRequired -}; - -export default withScrollPosition( - connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexConnector), - 'artistIndex' -); diff --git a/frontend/src/Artist/Index/ArtistIndexFilterModal.tsx b/frontend/src/Artist/Index/ArtistIndexFilterModal.tsx new file mode 100644 index 000000000..07e454fc2 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndexFilterModal.tsx @@ -0,0 +1,56 @@ +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 { setArtistFilter } from 'Store/Actions/artistIndexActions'; + +function createArtistSelector() { + return createSelector( + (state: AppState) => state.artist.items, + (artist) => { + return artist; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.artistIndex.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface ArtistIndexFilterModalProps { + isOpen: boolean; +} + +export default function ArtistIndexFilterModal( + props: ArtistIndexFilterModalProps +) { + const sectionItems = useSelector(createArtistSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'artist'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setArtistFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js b/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js deleted file mode 100644 index cf5ec33ea..000000000 --- a/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js +++ /dev/null @@ -1,24 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import FilterModal from 'Components/Filter/FilterModal'; -import { setArtistFilter } from 'Store/Actions/artistIndexActions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artist.items, - (state) => state.artistIndex.filterBuilderProps, - (sectionItems, filterBuilderProps) => { - return { - sectionItems, - filterBuilderProps, - customFilterType: 'artistIndex' - }; - } - ); -} - -const mapDispatchToProps = { - dispatchSetFilter: setArtistFilter -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.css b/frontend/src/Artist/Index/ArtistIndexFooter.css index c1c4b5a46..bf3fedfd6 100644 --- a/frontend/src/Artist/Index/ArtistIndexFooter.css +++ b/frontend/src/Artist/Index/ArtistIndexFooter.css @@ -50,6 +50,12 @@ } } +.downloading { + composes: legendItemColor; + + background-color: var(--purple); +} + .statistics { display: flex; justify-content: space-between; diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.css.d.ts b/frontend/src/Artist/Index/ArtistIndexFooter.css.d.ts index b88d23a6c..29f693a8c 100644 --- a/frontend/src/Artist/Index/ArtistIndexFooter.css.d.ts +++ b/frontend/src/Artist/Index/ArtistIndexFooter.css.d.ts @@ -2,6 +2,7 @@ // Please do not change this file! interface CssExports { 'continuing': string; + 'downloading': string; 'ended': string; 'footer': string; 'legendItem': string; diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.js b/frontend/src/Artist/Index/ArtistIndexFooter.js deleted file mode 100644 index 5b0f1fc5a..000000000 --- a/frontend/src/Artist/Index/ArtistIndexFooter.js +++ /dev/null @@ -1,167 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { PureComponent } from 'react'; -import { ColorImpairedConsumer } from 'App/ColorImpairedContext'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import formatBytes from 'Utilities/Number/formatBytes'; -import translate from 'Utilities/String/translate'; -import styles from './ArtistIndexFooter.css'; - -class ArtistIndexFooter extends PureComponent { - - // - // Render - - render() { - const { artist } = this.props; - const count = artist.length; - let tracks = 0; - let trackFiles = 0; - let ended = 0; - let continuing = 0; - let monitored = 0; - let totalFileSize = 0; - - artist.forEach((s) => { - const { statistics = {} } = s; - - const { - trackCount = 0, - trackFileCount = 0, - sizeOnDisk = 0 - } = statistics; - - tracks += trackCount; - trackFiles += trackFileCount; - - if (s.status === 'ended') { - ended++; - } else { - continuing++; - } - - if (s.monitored) { - monitored++; - } - - totalFileSize += sizeOnDisk; - }); - - return ( - - {(enableColorImpairedMode) => { - return ( -
-
-
-
-
- {translate('ContinuingAllTracksDownloaded')} -
-
- -
-
-
- {translate('EndedAllTracksDownloaded')} -
-
- -
-
-
- {translate('MissingTracksArtistMonitored')} -
-
- -
-
-
- {translate('MissingTracksArtistNotMonitored')} -
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - -
-
- ); - }} - - ); - } -} - -ArtistIndexFooter.propTypes = { - artist: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -export default ArtistIndexFooter; diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.tsx b/frontend/src/Artist/Index/ArtistIndexFooter.tsx new file mode 100644 index 000000000..47241b224 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndexFooter.tsx @@ -0,0 +1,179 @@ +import classNames from 'classnames'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import { ColorImpairedConsumer } from 'App/ColorImpairedContext'; +import ArtistAppState from 'App/State/ArtistAppState'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import styles from './ArtistIndexFooter.css'; + +function createUnoptimizedSelector() { + return createSelector( + createClientSideCollectionSelector('artist', 'artistIndex'), + (artist: ArtistAppState) => { + return artist.items.map((s) => { + const { monitored, status, statistics } = s; + + return { + monitored, + status, + statistics, + }; + }); + } + ); +} + +function createArtistSelector() { + return createDeepEqualSelector( + createUnoptimizedSelector(), + (artist) => artist + ); +} + +export default function ArtistIndexFooter() { + const artist = useSelector(createArtistSelector()); + const count = artist.length; + let tracks = 0; + let trackFiles = 0; + let ended = 0; + let continuing = 0; + let monitored = 0; + let totalFileSize = 0; + + artist.forEach((a) => { + const { statistics = { trackCount: 0, trackFileCount: 0, sizeOnDisk: 0 } } = + a; + + const { trackCount = 0, trackFileCount = 0, sizeOnDisk = 0 } = statistics; + + tracks += trackCount; + trackFiles += trackFileCount; + + if (a.status === 'ended') { + ended++; + } else { + continuing++; + } + + if (a.monitored) { + monitored++; + } + + totalFileSize += sizeOnDisk; + }); + + return ( + + {(enableColorImpairedMode) => { + return ( +
+
+
+
+
{translate('ContinuingAllTracksDownloaded')}
+
+ +
+
+
{translate('EndedAllTracksDownloaded')}
+
+ +
+
+
{translate('MissingTracksArtistMonitored')}
+
+ +
+
+
{translate('MissingTracksArtistNotMonitored')}
+
+ +
+
+
{translate('ArtistIndexFooterDownloading')}
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); + }} + + ); +} diff --git a/frontend/src/Artist/Index/ArtistIndexFooterConnector.js b/frontend/src/Artist/Index/ArtistIndexFooterConnector.js deleted file mode 100644 index 2cb0e3e7d..000000000 --- a/frontend/src/Artist/Index/ArtistIndexFooterConnector.js +++ /dev/null @@ -1,46 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; -import ArtistIndexFooter from './ArtistIndexFooter'; - -function createUnoptimizedSelector() { - return createSelector( - createClientSideCollectionSelector('artist', 'artistIndex'), - (artist) => { - return artist.items.map((s) => { - const { - monitored, - status, - statistics - } = s; - - return { - monitored, - status, - statistics - }; - }); - } - ); -} - -function createArtistSelector() { - return createDeepEqualSelector( - createUnoptimizedSelector(), - (artist) => artist - ); -} - -function createMapStateToProps() { - return createSelector( - createArtistSelector(), - (artist) => { - return { - artist - }; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexFooter); diff --git a/frontend/src/Artist/Index/ArtistIndexItemConnector.js b/frontend/src/Artist/Index/ArtistIndexItemConnector.js deleted file mode 100644 index 43d92ef13..000000000 --- a/frontend/src/Artist/Index/ArtistIndexItemConnector.js +++ /dev/null @@ -1,153 +0,0 @@ -/* eslint max-params: 0 */ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import { toggleArtistMonitored } from 'Store/Actions/artistActions'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createArtistMetadataProfileSelector from 'Store/Selectors/createArtistMetadataProfileSelector'; -import createArtistQualityProfileSelector from 'Store/Selectors/createArtistQualityProfileSelector'; -import createArtistSelector from 'Store/Selectors/createArtistSelector'; -import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; - -function selectShowSearchAction() { - return createSelector( - (state) => state.artistIndex, - (artistIndex) => { - const view = artistIndex.view; - - switch (view) { - case 'posters': - return artistIndex.posterOptions.showSearchAction; - case 'banners': - return artistIndex.bannerOptions.showSearchAction; - case 'overview': - return artistIndex.overviewOptions.showSearchAction; - default: - return artistIndex.tableOptions.showSearchAction; - } - } - ); -} - -function createMapStateToProps() { - return createSelector( - createArtistSelector(), - createArtistQualityProfileSelector(), - createArtistMetadataProfileSelector(), - selectShowSearchAction(), - createExecutingCommandsSelector(), - ( - artist, - qualityProfile, - metadataProfile, - showSearchAction, - 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 {}; - } - - const isRefreshingArtist = executingCommands.some((command) => { - return ( - command.name === commandNames.REFRESH_ARTIST && - command.body.artistId === artist.id - ); - }); - - const isSearchingArtist = executingCommands.some((command) => { - return ( - command.name === commandNames.ARTIST_SEARCH && - command.body.artistId === artist.id - ); - }); - - const latestAlbum = _.maxBy(artist.albums, (album) => album.releaseDate); - - return { - ...artist, - qualityProfile, - metadataProfile, - latestAlbum, - showSearchAction, - isRefreshingArtist, - isSearchingArtist - }; - } - ); -} - -const mapDispatchToProps = { - dispatchExecuteCommand: executeCommand, - toggleArtistMonitored -}; - -class ArtistIndexItemConnector extends Component { - - // - // Listeners - - onRefreshArtistPress = () => { - this.props.dispatchExecuteCommand({ - name: commandNames.REFRESH_ARTIST, - artistId: this.props.id - }); - }; - - onSearchPress = () => { - this.props.dispatchExecuteCommand({ - name: commandNames.ARTIST_SEARCH, - artistId: this.props.id - }); - }; - - onMonitoredPress = () => { - this.props.toggleArtistMonitored({ - artistId: this.props.id, - monitored: !this.props.monitored - }); - }; - - // - // Render - - render() { - const { - id, - component: ItemComponent, - ...otherProps - } = this.props; - - if (!id) { - return null; - } - - return ( - - ); - } -} - -ArtistIndexItemConnector.propTypes = { - id: PropTypes.number, - monitored: PropTypes.bool.isRequired, - component: PropTypes.elementType.isRequired, - dispatchExecuteCommand: PropTypes.func.isRequired, - toggleArtistMonitored: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexItemConnector); diff --git a/frontend/src/Artist/Index/ArtistIndexRefreshArtistsButton.tsx b/frontend/src/Artist/Index/ArtistIndexRefreshArtistsButton.tsx new file mode 100644 index 000000000..07a180857 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndexRefreshArtistsButton.tsx @@ -0,0 +1,74 @@ +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useSelect } from 'App/SelectContext'; +import ArtistAppState, { ArtistIndexAppState } from 'App/State/ArtistAppState'; +import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState'; +import { REFRESH_ARTIST } from 'Commands/commandNames'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; + +interface ArtistIndexRefreshArtistsButtonProps { + isSelectMode: boolean; + selectedFilterKey: string; +} + +function ArtistIndexRefreshArtistsButton( + props: ArtistIndexRefreshArtistsButtonProps +) { + const isRefreshing = useSelector( + createCommandExecutingSelector(REFRESH_ARTIST) + ); + const { + items, + totalItems, + }: ArtistAppState & ArtistIndexAppState & ClientSideCollectionAppState = + useSelector(createArtistClientSideCollectionItemsSelector('artistIndex')); + + const dispatch = useDispatch(); + const { isSelectMode, selectedFilterKey } = props; + const [selectState] = useSelect(); + const { selectedState } = selectState; + + const selectedArtistIds = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const artistsToRefresh = + isSelectMode && selectedArtistIds.length > 0 + ? selectedArtistIds + : items.map((m) => m.id); + + let refreshLabel = translate('UpdateAll'); + + if (selectedArtistIds.length > 0) { + refreshLabel = translate('UpdateSelected'); + } else if (selectedFilterKey !== 'all') { + refreshLabel = translate('UpdateFiltered'); + } + + const onPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + artistIds: artistsToRefresh, + }) + ); + }, [dispatch, artistsToRefresh]); + + return ( + + ); +} + +export default ArtistIndexRefreshArtistsButton; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css index e22472389..7f1fc71c6 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css @@ -1,9 +1,5 @@ $hoverScale: 1.05; -.container { - padding: 10px; -} - .content { transition: all 200ms ease-in; @@ -26,12 +22,29 @@ $hoverScale: 1.05; .link { composes: link from '~Components/Link/Link.css'; + position: relative; display: block; + height: 50px; background-color: var(--defaultColor); } -.nextAiring { - background-color: #fafbfc; +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: var(--offWhite); + text-align: center; + font-size: 20px; +} + +.nextAlbum { + background-color: var(--artistBackgroundColor); text-align: center; font-size: $smallFontSize; } @@ -39,8 +52,7 @@ $hoverScale: 1.05; .title { @add-mixin truncate; - background-color: var(--defaultColor); - color: var(--white); + background-color: var(--artistBackgroundColor); text-align: center; font-size: $smallFontSize; } @@ -49,6 +61,7 @@ $hoverScale: 1.05; position: absolute; top: 0; right: 0; + z-index: 1; width: 0; height: 0; border-width: 0 25px 25px 0; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css.d.ts b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css.d.ts index 393757652..bd6cb4ac9 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css.d.ts +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css.d.ts @@ -8,7 +8,8 @@ interface CssExports { 'controls': string; 'ended': string; 'link': string; - 'nextAiring': string; + 'nextAlbum': string; + 'overlayTitle': string; 'title': string; } export const cssExports: CssExports; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js deleted file mode 100644 index 43c7ca22b..000000000 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js +++ /dev/null @@ -1,271 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ArtistBanner from 'Artist/ArtistBanner'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; -import Label from 'Components/Label'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import { icons } from 'Helpers/Props'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import translate from 'Utilities/String/translate'; -import ArtistIndexBannerInfo from './ArtistIndexBannerInfo'; -import styles from './ArtistIndexBanner.css'; - -class ArtistIndexBanner extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - // - // Listeners - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - artistName, - monitored, - status, - foreignArtistId, - nextAiring, - statistics, - images, - bannerWidth, - bannerHeight, - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - showSearchAction, - qualityProfile, - showRelativeDates, - shortDateFormat, - timeFormat, - isRefreshingArtist, - isSearchingArtist, - onRefreshArtistPress, - onSearchPress, - ...otherProps - } = this.props; - - const { - albumCount, - sizeOnDisk, - trackCount, - trackFileCount, - totalTrackCount - } = statistics; - - const { - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - const link = `/artist/${foreignArtistId}`; - - const elementStyle = { - width: `${bannerWidth}px`, - height: `${bannerHeight}px` - }; - - return ( -
-
-
- - - { - status === 'ended' && -
- } - - - - -
- - - - { - showTitle && -
- {artistName} -
- } - - { - showMonitored && -
- {monitored ? 'Monitored' : 'Unmonitored'} -
- } - - { - showQualityProfile && -
- {qualityProfile.name} -
- } - { - nextAiring && -
- { - getRelativeDate( - nextAiring, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } -
- } - - - - - - -
-
- ); - } -} - -ArtistIndexBanner.propTypes = { - id: PropTypes.number.isRequired, - artistName: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - foreignArtistId: PropTypes.string.isRequired, - nextAiring: PropTypes.string, - statistics: PropTypes.object.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - bannerWidth: PropTypes.number.isRequired, - bannerHeight: PropTypes.number.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showTitle: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - qualityProfile: PropTypes.object.isRequired, - showSearchAction: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isSearchingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired -}; - -ArtistIndexBanner.defaultProps = { - statistics: { - albumCount: 0, - trackCount: 0, - trackFileCount: 0, - totalTrackCount: 0 - } -}; - -export default ArtistIndexBanner; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx new file mode 100644 index 000000000..f6a03c521 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx @@ -0,0 +1,263 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Statistics } from 'Artist/Artist'; +import ArtistBanner from 'Artist/ArtistBanner'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import ArtistIndexBannerInfo from 'Artist/Index/Banners/ArtistIndexBannerInfo'; +import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector'; +import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; +import ArtistIndexPosterSelect from 'Artist/Index/Select/ArtistIndexPosterSelect'; +import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import translate from 'Utilities/String/translate'; +import selectBannerOptions from './selectBannerOptions'; +import styles from './ArtistIndexBanner.css'; + +interface ArtistIndexBannerProps { + artistId: number; + sortKey: string; + isSelectMode: boolean; + bannerWidth: number; + bannerHeight: number; +} + +function ArtistIndexBanner(props: ArtistIndexBannerProps) { + const { artistId, sortKey, isSelectMode, bannerWidth, bannerHeight } = props; + + const { + artist, + qualityProfile, + metadataProfile, + isRefreshingArtist, + isSearchingArtist, + } = useSelector(createArtistIndexItemSelector(props.artistId)); + + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + showSearchAction, + } = useSelector(selectBannerOptions); + + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = + useSelector(createUISettingsSelector()); + + const { + artistName, + artistType, + monitored, + status, + path, + foreignArtistId, + nextAlbum, + added, + statistics = {} as Statistics, + images, + tags, + } = artist; + + const { + albumCount = 0, + trackCount = 0, + trackFileCount = 0, + totalTrackCount = 0, + sizeOnDisk = 0, + } = statistics; + + const dispatch = useDispatch(); + const [hasBannerError, setHasBannerError] = useState(false); + const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false); + const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: ARTIST_SEARCH, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onBannerLoadError = useCallback(() => { + setHasBannerError(true); + }, [setHasBannerError]); + + const onBannerLoad = useCallback(() => { + setHasBannerError(false); + }, [setHasBannerError]); + + const onEditArtistPress = useCallback(() => { + setIsEditArtistModalOpen(true); + }, [setIsEditArtistModalOpen]); + + const onEditArtistModalClose = useCallback(() => { + setIsEditArtistModalOpen(false); + }, [setIsEditArtistModalOpen]); + + const onDeleteArtistPress = useCallback(() => { + setIsEditArtistModalOpen(false); + setIsDeleteArtistModalOpen(true); + }, [setIsDeleteArtistModalOpen]); + + const onDeleteArtistModalClose = useCallback(() => { + setIsDeleteArtistModalOpen(false); + }, [setIsDeleteArtistModalOpen]); + + const link = `/artist/${foreignArtistId}`; + + const elementStyle = { + width: `${bannerWidth}px`, + height: `${bannerHeight}px`, + }; + + return ( +
+
+ {isSelectMode ? : null} + + + + {status === 'ended' ? ( +
+ ) : null} + + + + + {hasBannerError ? ( +
{artistName}
+ ) : null} + +
+ + + + {showTitle ? ( +
+ {artistName} +
+ ) : null} + + {showMonitored ? ( +
+ {monitored ? translate('Monitored') : translate('Unmonitored')} +
+ ) : null} + + {showQualityProfile && !!qualityProfile?.name ? ( +
+ {qualityProfile.name} +
+ ) : null} + + {showNextAlbum && !!nextAlbum?.releaseDate ? ( +
+ {getRelativeDate( + nextAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )} +
+ ) : null} + + + + + + +
+ ); +} + +export default ArtistIndexBanner; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js deleted file mode 100644 index f641de0e1..000000000 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js +++ /dev/null @@ -1,115 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import formatBytes from 'Utilities/Number/formatBytes'; -import styles from './ArtistIndexBannerInfo.css'; - -function ArtistIndexBannerInfo(props) { - const { - qualityProfile, - showQualityProfile, - previousAiring, - added, - albumCount, - path, - sizeOnDisk, - sortKey, - showRelativeDates, - shortDateFormat, - timeFormat - } = props; - - if (sortKey === 'qualityProfileId' && !showQualityProfile) { - return ( -
- {qualityProfile.name} -
- ); - } - - if (sortKey === 'previousAiring' && previousAiring) { - return ( -
- { - getRelativeDate( - previousAiring, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } -
- ); - } - - if (sortKey === 'added' && added) { - const addedDate = getRelativeDate( - added, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: false - } - ); - - return ( -
- {`Added ${addedDate}`} -
- ); - } - - if (sortKey === 'albumCount') { - let albums = '1 album'; - - if (albumCount === 0) { - albums = 'No albums'; - } else if (albumCount > 1) { - albums = `${albumCount} albums`; - } - - return ( -
- {albums} -
- ); - } - - if (sortKey === 'path') { - return ( -
- {path} -
- ); - } - - if (sortKey === 'sizeOnDisk') { - return ( -
- {formatBytes(sizeOnDisk)} -
- ); - } - - return null; -} - -ArtistIndexBannerInfo.propTypes = { - qualityProfile: PropTypes.object.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - previousAiring: PropTypes.string, - added: PropTypes.string, - albumCount: PropTypes.number.isRequired, - path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, - sortKey: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default ArtistIndexBannerInfo; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.tsx b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.tsx new file mode 100644 index 000000000..a93b0bafc --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import Album from 'Album/Album'; +import TagListConnector from 'Components/TagListConnector'; +import MetadataProfile from 'typings/MetadataProfile'; +import QualityProfile from 'typings/QualityProfile'; +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 styles from './ArtistIndexBannerInfo.css'; + +interface ArtistIndexBannerInfoProps { + artistType?: string; + showQualityProfile: boolean; + qualityProfile?: QualityProfile; + metadataProfile?: MetadataProfile; + showNextAlbum: boolean; + nextAlbum?: Album; + lastAlbum?: Album; + added?: string; + albumCount: number; + path: string; + sizeOnDisk?: number; + tags?: number[]; + sortKey: string; + showRelativeDates: boolean; + shortDateFormat: string; + longDateFormat: string; + timeFormat: string; +} + +function ArtistIndexBannerInfo(props: ArtistIndexBannerInfoProps) { + const { + artistType, + qualityProfile, + metadataProfile, + showQualityProfile, + showNextAlbum, + nextAlbum, + lastAlbum, + added, + albumCount, + path, + sizeOnDisk, + tags, + sortKey, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + } = props; + + if (sortKey === 'artistType' && artistType) { + return ( +
+ {artistType} +
+ ); + } + + if ( + sortKey === 'qualityProfileId' && + !showQualityProfile && + !!qualityProfile?.name + ) { + return ( +
+ {qualityProfile.name} +
+ ); + } + + if (sortKey === 'metadataProfileId' && !!metadataProfile?.name) { + return ( +
+ {metadataProfile.name} +
+ ); + } + + if (sortKey === 'nextAlbum' && !showNextAlbum && !!nextAlbum?.releaseDate) { + return ( +
+ {getRelativeDate( + nextAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )} +
+ ); + } + + if (sortKey === 'lastAlbum' && !!lastAlbum?.releaseDate) { + return ( +
+ {getRelativeDate( + lastAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )} +
+ ); + } + + if (sortKey === 'added' && added) { + const addedDate = getRelativeDate( + added, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: false, + } + ); + + return ( +
+ {translate('Added')}: {addedDate} +
+ ); + } + + if (sortKey === 'albumCount') { + let albums = translate('OneAlbum'); + + if (albumCount === 0) { + albums = translate('NoAlbums'); + } else if (albumCount > 1) { + albums = translate('CountAlbums', { albumCount }); + } + + return
{albums}
; + } + + if (sortKey === 'path') { + return ( +
+ {path} +
+ ); + } + + if (sortKey === 'sizeOnDisk') { + return ( +
+ {formatBytes(sizeOnDisk)} +
+ ); + } + + if (sortKey === 'tags' && tags) { + return ( +
+ +
+ ); + } + + return null; +} + +export default ArtistIndexBannerInfo; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js deleted file mode 100644 index be3cdb502..000000000 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js +++ /dev/null @@ -1,327 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Grid, WindowScroller } from 'react-virtualized'; -import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; -import Measure from 'Components/Measure'; -import dimensions from 'Styles/Variables/dimensions'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import ArtistIndexBanner from './ArtistIndexBanner'; -import styles from './ArtistIndexBanners.css'; - -// container dimensions -const columnPadding = parseInt(dimensions.artistIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); -const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); -const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); - -const additionalColumnCount = { - small: 3, - medium: 2, - large: 1 -}; - -function calculateColumnWidth(width, bannerSize, isSmallScreen) { - const maxiumColumnWidth = isSmallScreen ? 344 : 364; - const columns = Math.floor(width / maxiumColumnWidth); - const remainder = width % maxiumColumnWidth; - - if (remainder === 0 && bannerSize === 'large') { - return maxiumColumnWidth; - } - - return Math.floor(width / (columns + additionalColumnCount[bannerSize])); -} - -function calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions) { - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile - } = bannerOptions; - - const nextAiringHeight = 19; - - const heights = [ - bannerHeight, - detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, - nextAiringHeight, - isSmallScreen ? columnPaddingSmallScreen : columnPadding - ]; - - if (showTitle) { - heights.push(19); - } - - if (showMonitored) { - heights.push(19); - } - - if (showQualityProfile) { - heights.push(19); - } - - switch (sortKey) { - case 'seasons': - case 'previousAiring': - case 'added': - case 'path': - case 'sizeOnDisk': - heights.push(19); - break; - case 'qualityProfileId': - if (!showQualityProfile) { - heights.push(19); - } - break; - default: - // No need to add a height of 0 - } - - return heights.reduce((acc, height) => acc + height, 0); -} - -function calculateHeight(bannerWidth) { - return Math.ceil((88/476) * bannerWidth); -} - -class ArtistIndexBanners extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - width: 0, - columnWidth: 364, - columnCount: 1, - bannerWidth: 476, - bannerHeight: 88, - rowHeight: calculateRowHeight(88, null, props.isSmallScreen, {}), - scrollRestored: false - }; - - this._isInitialized = false; - this._grid = null; - } - - componentDidUpdate(prevProps, prevState) { - const { - items, - sortKey, - bannerOptions, - jumpToCharacter, - scrollTop - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight, - scrollRestored - } = this.state; - - if (prevProps.sortKey !== sortKey || - prevProps.bannerOptions !== bannerOptions) { - this.calculateGrid(); - } - - if (this._grid && - (prevState.width !== width || - prevState.columnWidth !== columnWidth || - prevState.columnCount !== columnCount || - prevState.rowHeight !== rowHeight || - hasDifferentItemsOrOrder(prevProps.items, items))) { - // recomputeGridSize also forces Grid to discard its cache of rendered cells - this._grid.recomputeGridSize(); - } - - if (this._grid && scrollTop !== 0 && !scrollRestored) { - this.setState({ scrollRestored: true }); - this._grid.scrollToPosition({ scrollTop }); - } - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - const index = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (this._grid && index != null) { - const row = Math.floor(index / columnCount); - - this._grid.scrollToCell({ - rowIndex: row, - columnIndex: 0 - }); - } - } - - } - - // - // Control - - setGridRef = (ref) => { - this._grid = ref; - }; - - calculateGrid = (width = this.state.width, isSmallScreen) => { - const { - sortKey, - bannerOptions - } = this.props; - - const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; - const columnWidth = calculateColumnWidth(width, bannerOptions.size, isSmallScreen); - const columnCount = Math.max(Math.floor(width / columnWidth), 1); - const bannerWidth = columnWidth - padding; - const bannerHeight = calculateHeight(bannerWidth); - const rowHeight = calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions); - - this.setState({ - width, - columnWidth, - columnCount, - bannerWidth, - bannerHeight, - rowHeight - }); - }; - - cellRenderer = ({ key, rowIndex, columnIndex, style }) => { - const { - items, - sortKey, - bannerOptions, - showRelativeDates, - shortDateFormat, - timeFormat - } = this.props; - - const { - bannerWidth, - bannerHeight, - columnCount - } = this.state; - - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile - } = bannerOptions; - - const artist = items[rowIndex * columnCount + columnIndex]; - - if (!artist) { - return null; - } - - return ( -
- -
- ); - }; - - // - // Listeners - - onMeasure = ({ width }) => { - this.calculateGrid(width, this.props.isSmallScreen); - }; - - // - // Render - - render() { - const { - items, - isSmallScreen, - scroller - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight - } = this.state; - - const rowCount = Math.ceil(items.length / columnCount); - - return ( - - - {({ height, registerChild, onChildScroll, scrollTop }) => { - if (!height) { - return
; - } - - return ( - - ); - } - } - - - ); - } -} - -ArtistIndexBanners.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - bannerOptions: PropTypes.object.isRequired, - jumpToCharacter: PropTypes.string, - scrollTop: PropTypes.number.isRequired, - scroller: PropTypes.instanceOf(Element).isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default ArtistIndexBanners; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx new file mode 100644 index 000000000..3582da097 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx @@ -0,0 +1,304 @@ +import { throttle } from 'lodash'; +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'; +import SortDirection from 'Helpers/Props/SortDirection'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; + +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.artistIndexColumnPaddingSmallScreen +); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +const ADDITIONAL_COLUMN_COUNT: Record = { + small: 3, + medium: 2, + large: 1, +}; + +interface CellItemData { + layout: { + columnCount: number; + padding: number; + bannerWidth: number; + bannerHeight: number; + }; + items: Artist[]; + sortKey: string; + isSelectMode: boolean; +} + +interface ArtistIndexBannersProps { + items: Artist[]; + sortKey: string; + sortDirection?: SortDirection; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: RefObject; + isSelectMode: boolean; + isSmallScreen: boolean; +} + +const artistIndexSelector = createSelector( + (state: AppState) => state.artistIndex.bannerOptions, + (bannerOptions) => { + return { + bannerOptions, + }; + } +); + +const Cell: React.FC> = ({ + columnIndex, + rowIndex, + style, + data, +}) => { + const { layout, items, sortKey, isSelectMode } = data; + const { columnCount, padding, bannerWidth, bannerHeight } = layout; + const index = rowIndex * columnCount + columnIndex; + + if (index >= items.length) { + return null; + } + + const artist = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +export default function ArtistIndexBanners(props: ArtistIndexBannersProps) { + const { + scrollerRef, + items, + sortKey, + jumpToCharacter, + isSelectMode, + isSmallScreen, + } = props; + + const { bannerOptions } = useSelector(artistIndexSelector); + const ref = useRef(null); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const columnWidth = useMemo(() => { + const { width } = size; + const maximumColumnWidth = isSmallScreen ? 344 : 364; + const columns = Math.floor(width / maximumColumnWidth); + const remainder = width % maximumColumnWidth; + return remainder === 0 + ? maximumColumnWidth + : Math.floor( + width / (columns + ADDITIONAL_COLUMN_COUNT[bannerOptions.size]) + ); + }, [isSmallScreen, bannerOptions, size]); + + const columnCount = useMemo( + () => Math.max(Math.floor(size.width / columnWidth), 1), + [size, columnWidth] + ); + const padding = props.isSmallScreen + ? columnPaddingSmallScreen + : columnPadding; + const bannerWidth = columnWidth - padding * 2; + const bannerHeight = Math.ceil((88 / 476) * bannerWidth); + + const rowHeight = useMemo(() => { + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + } = bannerOptions; + + const nextAiringHeight = 19; + + const heights = [ + bannerHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + nextAiringHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding, + ]; + + if (showTitle) { + heights.push(19); + } + + if (showMonitored) { + heights.push(19); + } + + if (showQualityProfile) { + heights.push(19); + } + + if (showNextAlbum) { + heights.push(19); + } + + switch (sortKey) { + case 'artistType': + case 'metadataProfileId': + case 'lastAlbum': + case 'added': + case 'albumCount': + case 'path': + case 'sizeOnDisk': + case 'tags': + heights.push(19); + break; + case 'qualityProfileId': + if (!showQualityProfile) { + heights.push(19); + } + break; + case 'nextAlbum': + if (!showNextAlbum) { + heights.push(19); + } + break; + default: + // No need to add a height of 0 + } + + return heights.reduce((acc, height) => acc + height, 0); + }, [isSmallScreen, bannerOptions, sortKey, bannerHeight]); + + useEffect(() => { + const current = scrollerRef.current; + + if (isSmallScreen) { + const padding = bodyPaddingSmallScreen - 5; + + setSize({ + width: window.innerWidth - padding * 2, + height: window.innerHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = bodyPadding - 5; + + setSize({ + width: width - padding * 2, + height: window.innerHeight, + }); + } + }, [isSmallScreen, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollerRef = scrollerRef.current as HTMLElement; + const currentScrollListener = isSmallScreen ? window : currentScrollerRef; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + ref.current?.scrollTo({ scrollLeft: 0, scrollTop }); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, ref, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const rowIndex = Math.floor(index / columnCount); + + const scrollTop = rowIndex * rowHeight + padding; + + ref.current?.scrollTo({ scrollLeft: 0, scrollTop }); + scrollerRef.current?.scrollTo(0, scrollTop); + } + } + }, [ + jumpToCharacter, + rowHeight, + columnCount, + padding, + items, + scrollerRef, + ref, + ]); + + return ( +
+ + ref={ref} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + columnCount={columnCount} + columnWidth={columnWidth} + rowCount={Math.ceil(items.length / columnCount)} + rowHeight={rowHeight} + itemData={{ + layout: { + columnCount, + padding, + bannerWidth, + bannerHeight, + }, + items, + sortKey, + isSelectMode, + }} + > + {Cell} + +
+ ); +} diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js deleted file mode 100644 index 1cf68ba2b..000000000 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import ArtistIndexBanners from './ArtistIndexBanners'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex.bannerOptions, - createUISettingsSelector(), - createDimensionsSelector(), - (bannerOptions, uiSettings, dimensions) => { - return { - bannerOptions, - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat, - isSmallScreen: dimensions.isSmallScreen - }; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexBanners); diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js deleted file mode 100644 index 34c8abfcf..000000000 --- a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import ArtistIndexBannerOptionsModalContentConnector from './ArtistIndexBannerOptionsModalContentConnector'; - -function ArtistIndexBannerOptionsModal({ isOpen, onModalClose, ...otherProps }) { - return ( - - - - ); -} - -ArtistIndexBannerOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexBannerOptionsModal; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.tsx b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.tsx new file mode 100644 index 000000000..156e06079 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistIndexBannerOptionsModalContent from './ArtistIndexBannerOptionsModalContent'; + +interface ArtistIndexBannerOptionsModalProps { + isOpen: boolean; + onModalClose(...args: unknown[]): unknown; +} + +function ArtistIndexBannerOptionsModal({ + isOpen, + onModalClose, +}: ArtistIndexBannerOptionsModalProps) { + return ( + + + + ); +} + +export default ArtistIndexBannerOptionsModal; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js deleted file mode 100644 index 8951a7b3d..000000000 --- a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js +++ /dev/null @@ -1,226 +0,0 @@ -import _ from 'lodash'; -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 } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -const bannerSizeOptions = [ - { key: 'small', value: 'Small' }, - { key: 'medium', value: 'Medium' }, - { key: 'large', value: 'Large' } -]; - -class ArtistIndexBannerOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - detailedProgressBar: props.detailedProgressBar, - size: props.size, - showTitle: props.showTitle, - showMonitored: props.showMonitored, - showQualityProfile: props.showQualityProfile, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showSearchAction - } = this.props; - - const state = {}; - - if (detailedProgressBar !== prevProps.detailedProgressBar) { - state.detailedProgressBar = detailedProgressBar; - } - - if (size !== prevProps.size) { - state.size = size; - } - - if (showTitle !== prevProps.showTitle) { - state.showTitle = showTitle; - } - - if (showMonitored !== prevProps.showMonitored) { - state.showMonitored = showMonitored; - } - - if (showQualityProfile !== prevProps.showQualityProfile) { - state.showQualityProfile = showQualityProfile; - } - - if (showSearchAction !== prevProps.showSearchAction) { - state.showSearchAction = showSearchAction; - } - - if (!_.isEmpty(state)) { - this.setState(state); - } - } - - // - // Listeners - - onChangeBannerOption = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onChangeBannerOption({ [name]: value }); - }); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showSearchAction - } = this.state; - - return ( - - - Options - - - -
- - - {translate('Size')} - - - - - - - - {translate('DetailedProgressBar')} - - - - - - - - {translate('ShowName')} - - - - - - - - {translate('ShowMonitored')} - - - - - - - - {translate('ShowQualityProfile')} - - - - - - - - {translate('ShowSearch')} - - - - -
-
- - - - -
- ); - } -} - -ArtistIndexBannerOptionsModalContent.propTypes = { - size: PropTypes.string.isRequired, - showTitle: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onChangeBannerOption: PropTypes.func.isRequired, - showMonitored: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexBannerOptionsModalContent; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx new file mode 100644 index 000000000..f889ea450 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx @@ -0,0 +1,167 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import selectBannerOptions from 'Artist/Index/Banners/selectBannerOptions'; +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 } from 'Helpers/Props'; +import { setArtistBannerOption } from 'Store/Actions/artistIndexActions'; +import translate from 'Utilities/String/translate'; + +const bannerSizeOptions = [ + { + key: 'small', + get value() { + return translate('Small'); + }, + }, + { + key: 'medium', + get value() { + return translate('Medium'); + }, + }, + { + key: 'large', + get value() { + return translate('Large'); + }, + }, +]; + +interface ArtistIndexBannerOptionsModalContentProps { + onModalClose(...args: unknown[]): unknown; +} + +function ArtistIndexBannerOptionsModalContent( + props: ArtistIndexBannerOptionsModalContentProps +) { + const { onModalClose } = props; + + const bannerOptions = useSelector(selectBannerOptions); + + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + showSearchAction, + } = bannerOptions; + + const dispatch = useDispatch(); + + const onBannerOptionChange = useCallback( + ({ name, value }: { name: string; value: unknown }) => { + dispatch(setArtistBannerOption({ [name]: value })); + }, + [dispatch] + ); + + return ( + + {translate('BannerOptions')} + + +
+ + {translate('BannerSize')} + + + + + + {translate('DetailedProgressBar')} + + + + + + {translate('ShowName')} + + + + + + {translate('ShowMonitored')} + + + + + + {translate('ShowQualityProfile')} + + + + + + {translate('ShowNextAlbum')} + + + + + + {translate('ShowSearch')} + + + +
+
+ + + + +
+ ); +} + +export default ArtistIndexBannerOptionsModalContent; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js deleted file mode 100644 index 884edd05d..000000000 --- a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js +++ /dev/null @@ -1,23 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setArtistBannerOption } from 'Store/Actions/artistIndexActions'; -import ArtistIndexBannerOptionsModalContent from './ArtistIndexBannerOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex, - (artistIndex) => { - return artistIndex.bannerOptions; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onChangeBannerOption(payload) { - dispatch(setArtistBannerOption(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexBannerOptionsModalContent); diff --git a/frontend/src/Artist/Index/Banners/selectBannerOptions.ts b/frontend/src/Artist/Index/Banners/selectBannerOptions.ts new file mode 100644 index 000000000..529c15e06 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/selectBannerOptions.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const selectBannerOptions = createSelector( + (state: AppState) => state.artistIndex.bannerOptions, + (bannerOptions) => bannerOptions +); + +export default selectBannerOptions; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js deleted file mode 100644 index d146fdf7d..000000000 --- a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js +++ /dev/null @@ -1,41 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ArtistIndexFilterModalConnector from 'Artist/Index/ArtistIndexFilterModalConnector'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import { align } from 'Helpers/Props'; - -function ArtistIndexFilterMenu(props) { - const { - selectedFilterKey, - filters, - customFilters, - isDisabled, - onFilterSelect - } = props; - - return ( - - ); -} - -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 -}; - -export default ArtistIndexFilterMenu; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.tsx b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.tsx new file mode 100644 index 000000000..91ebbef2d --- /dev/null +++ b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.tsx @@ -0,0 +1,41 @@ +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'; + +interface ArtistIndexFilterMenuProps { + selectedFilterKey: string | number; + filters: object[]; + customFilters: CustomFilter[]; + isDisabled: boolean; + onFilterSelect(filterName: string): unknown; +} + +function ArtistIndexFilterMenu(props: ArtistIndexFilterMenuProps) { + const { + selectedFilterKey, + filters, + customFilters, + isDisabled, + onFilterSelect, + } = props; + + return ( + + ); +} + +ArtistIndexFilterMenu.defaultProps = { + showCustomFilters: false, +}; + +export default ArtistIndexFilterMenu; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.tsx similarity index 74% rename from frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js rename to frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.tsx index 967b34d49..1b72d0f4c 100644 --- a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js +++ b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.tsx @@ -1,23 +1,23 @@ -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) { - const { - sortKey, - sortDirection, - isDisabled, - onSortSelect - } = props; +interface SeriesIndexSortMenuProps { + sortKey?: string; + sortDirection?: SortDirection; + isDisabled: boolean; + onSortSelect(sortKey: string): unknown; +} + +function ArtistIndexSortMenu(props: SeriesIndexSortMenuProps) { + const { sortKey, sortDirection, isDisabled, onSortSelect } = props; return ( - + - 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.js b/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js deleted file mode 100644 index 246409a7b..000000000 --- a/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.js +++ /dev/null @@ -1,63 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import MenuContent from 'Components/Menu/MenuContent'; -import ViewMenu from 'Components/Menu/ViewMenu'; -import ViewMenuItem from 'Components/Menu/ViewMenuItem'; -import { align } from 'Helpers/Props'; - -function ArtistIndexViewMenu(props) { - const { - view, - isDisabled, - onViewSelect - } = props; - - return ( - - - - Table - - - - Posters - - - - Banners - - - - Overview - - - - ); -} - -ArtistIndexViewMenu.propTypes = { - view: PropTypes.string.isRequired, - isDisabled: PropTypes.bool.isRequired, - onViewSelect: PropTypes.func.isRequired -}; - -export default ArtistIndexViewMenu; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.tsx b/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.tsx new file mode 100644 index 000000000..bb88d9149 --- /dev/null +++ b/frontend/src/Artist/Index/Menus/ArtistIndexViewMenu.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenu from 'Components/Menu/ViewMenu'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; +import { align } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; + +interface ArtistIndexViewMenuProps { + view: string; + isDisabled: boolean; + onViewSelect(value: string): unknown; +} + +function ArtistIndexViewMenu(props: ArtistIndexViewMenuProps) { + const { view, isDisabled, onViewSelect } = props; + + return ( + + + + {translate('Table')} + + + + {translate('Posters')} + + + + {translate('Banners')} + + + + {translate('Overview')} + + + + ); +} + +export default ArtistIndexViewMenu; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css index 3b1888228..1f482a2d6 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css @@ -1,13 +1,5 @@ $hoverScale: 1.05; -.container { - &:hover { - .content { - background-color: var(--tableRowHoverBackgroundColor); - } - } -} - .content { display: flex; flex-grow: 1; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css.d.ts b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css.d.ts index 76a72536a..de94277cc 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css.d.ts +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css.d.ts @@ -2,7 +2,6 @@ // Please do not change this file! interface CssExports { 'actions': string; - 'container': string; 'content': string; 'details': string; 'ended': string; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js deleted file mode 100644 index 1baac838f..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js +++ /dev/null @@ -1,283 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import TextTruncate from 'react-text-truncate'; -import ArtistPoster from 'Artist/ArtistPoster'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import { icons } from 'Helpers/Props'; -import dimensions from 'Styles/Variables/dimensions'; -import fonts from 'Styles/Variables/fonts'; -import translate from 'Utilities/String/translate'; -import ArtistIndexOverviewInfo from './ArtistIndexOverviewInfo'; -import styles from './ArtistIndexOverview.css'; - -const columnPadding = parseInt(dimensions.artistIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); -const defaultFontSize = parseInt(fonts.defaultFontSize); -const lineHeight = parseFloat(fonts.lineHeight); - -// Hardcoded height beased on line-height of 32 + bottom margin of 10. -// Less side-effecty than using react-measure. -const titleRowHeight = 42; - -function getContentHeight(rowHeight, isSmallScreen) { - const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; - - return rowHeight - padding; -} - -class ArtistIndexOverview extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - // - // Listeners - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - artistName, - overview, - monitored, - status, - foreignArtistId, - nextAiring, - statistics, - images, - posterWidth, - posterHeight, - qualityProfile, - overviewOptions, - showSearchAction, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - rowHeight, - isSmallScreen, - isRefreshingArtist, - isSearchingArtist, - onRefreshArtistPress, - onSearchPress, - ...otherProps - } = this.props; - - const { - albumCount, - sizeOnDisk, - trackCount, - trackFileCount, - totalTrackCount - } = statistics; - - const { - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - const link = `/artist/${foreignArtistId}`; - - const elementStyle = { - width: `${posterWidth}px`, - height: `${posterHeight}px` - }; - - const contentHeight = getContentHeight(rowHeight, isSmallScreen); - const overviewHeight = contentHeight - titleRowHeight; - - return ( -
-
-
-
- { - status === 'ended' && -
- } - - - - -
- - -
- -
-
- - {artistName} - - -
- - - { - showSearchAction && - - } - - -
-
- -
- - - - - - -
-
-
- - - - -
- ); - } -} - -ArtistIndexOverview.propTypes = { - id: PropTypes.number.isRequired, - artistName: PropTypes.string.isRequired, - overview: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - foreignArtistId: PropTypes.string.isRequired, - nextAiring: PropTypes.string, - statistics: PropTypes.object.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - posterWidth: PropTypes.number.isRequired, - posterHeight: PropTypes.number.isRequired, - rowHeight: PropTypes.number.isRequired, - qualityProfile: PropTypes.object.isRequired, - overviewOptions: PropTypes.object.isRequired, - showSearchAction: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isSearchingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired -}; - -ArtistIndexOverview.defaultProps = { - statistics: { - albumCount: 0, - trackCount: 0, - trackFileCount: 0, - totalTrackCount: 0 - } -}; - -export default ArtistIndexOverview; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx new file mode 100644 index 000000000..ebef28264 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx @@ -0,0 +1,249 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import TextTruncate from 'react-text-truncate'; +import { Statistics } from 'Artist/Artist'; +import ArtistPoster from 'Artist/ArtistPoster'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; +import ArtistIndexPosterSelect from 'Artist/Index/Select/ArtistIndexPosterSelect'; +import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import dimensions from 'Styles/Variables/dimensions'; +import fonts from 'Styles/Variables/fonts'; +import translate from 'Utilities/String/translate'; +import createArtistIndexItemSelector from '../createArtistIndexItemSelector'; +import ArtistIndexOverviewInfo from './ArtistIndexOverviewInfo'; +import selectOverviewOptions from './selectOverviewOptions'; +import styles from './ArtistIndexOverview.css'; + +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.artistIndexColumnPaddingSmallScreen +); +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +// Hardcoded height based on line-height of 32 + bottom margin of 10. +// Less side-effecty than using react-measure. +const TITLE_HEIGHT = 42; + +interface ArtistIndexOverviewProps { + artistId: number; + sortKey: string; + posterWidth: number; + posterHeight: number; + rowHeight: number; + isSelectMode: boolean; + isSmallScreen: boolean; +} + +function ArtistIndexOverview(props: ArtistIndexOverviewProps) { + const { + artistId, + sortKey, + posterWidth, + posterHeight, + rowHeight, + isSelectMode, + isSmallScreen, + } = props; + + const { artist, qualityProfile, isRefreshingArtist, isSearchingArtist } = + useSelector(createArtistIndexItemSelector(props.artistId)); + + const overviewOptions = useSelector(selectOverviewOptions); + + const { + artistName, + monitored, + status, + path, + foreignArtistId, + nextAlbum, + lastAlbum, + added, + overview, + statistics = {} as Statistics, + images, + } = artist; + + const { + albumCount = 0, + sizeOnDisk = 0, + trackCount = 0, + trackFileCount = 0, + totalTrackCount = 0, + } = statistics; + + const dispatch = useDispatch(); + const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false); + const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: ARTIST_SEARCH, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onEditArtistPress = useCallback(() => { + setIsEditArtistModalOpen(true); + }, [setIsEditArtistModalOpen]); + + const onEditArtistModalClose = useCallback(() => { + setIsEditArtistModalOpen(false); + }, [setIsEditArtistModalOpen]); + + const onDeleteArtistPress = useCallback(() => { + setIsEditArtistModalOpen(false); + setIsDeleteArtistModalOpen(true); + }, [setIsDeleteArtistModalOpen]); + + const onDeleteArtistModalClose = useCallback(() => { + setIsDeleteArtistModalOpen(false); + }, [setIsDeleteArtistModalOpen]); + + const link = `/artist/${foreignArtistId}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px`, + }; + + const contentHeight = useMemo(() => { + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + + return rowHeight - padding; + }, [rowHeight, isSmallScreen]); + + const overviewHeight = contentHeight - TITLE_HEIGHT; + + return ( +
+
+
+
+ {isSelectMode ? ( + + ) : null} + + {status === 'ended' && ( +
+ )} + + + + +
+ + +
+ +
+
+ + {artistName} + + +
+ + + {overviewOptions.showSearchAction ? ( + + ) : null} + + +
+
+ +
+ + + + + +
+
+
+ + + + +
+ ); +} + +export default ArtistIndexOverview; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js deleted file mode 100644 index f7cda7916..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js +++ /dev/null @@ -1,249 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { icons } from 'Helpers/Props'; -import dimensions from 'Styles/Variables/dimensions'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import formatBytes from 'Utilities/Number/formatBytes'; -import ArtistIndexOverviewInfoRow from './ArtistIndexOverviewInfoRow'; -import styles from './ArtistIndexOverviewInfo.css'; - -const infoRowHeight = parseInt(dimensions.artistIndexOverviewInfoRowHeight); - -const rows = [ - { - name: 'monitored', - showProp: 'showMonitored', - valueProp: 'monitored' - }, - { - name: 'qualityProfileId', - showProp: 'showQualityProfile', - valueProp: 'qualityProfileId' - }, - { - name: 'lastAlbum', - showProp: 'showLastAlbum', - valueProp: 'lastAlbum' - }, - { - name: 'added', - showProp: 'showAdded', - valueProp: 'added' - }, - { - name: 'albumCount', - showProp: 'showAlbumCount', - valueProp: 'albumCount' - }, - { - name: 'path', - showProp: 'showPath', - valueProp: 'path' - }, - { - name: 'sizeOnDisk', - showProp: 'showSizeOnDisk', - valueProp: 'sizeOnDisk' - } -]; - -function isVisible(row, props) { - const { - name, - showProp, - valueProp - } = row; - - if (props[valueProp] == null) { - return false; - } - - return props[showProp] || props.sortKey === name; -} - -function getInfoRowProps(row, props) { - const { name } = row; - - if (name === 'monitored') { - const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored'; - - return { - title: monitoredText, - iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED, - label: monitoredText - }; - } - - if (name === 'qualityProfileId') { - return { - title: 'Quality Profile', - iconName: icons.PROFILE, - label: props.qualityProfile.name - }; - } - - if (name === 'lastAlbum') { - const { - lastAlbum, - showRelativeDates, - shortDateFormat, - timeFormat - } = props; - - return { - title: `Last Album: ${lastAlbum.title}`, - iconName: icons.CALENDAR, - label: getRelativeDate( - lastAlbum.releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - }; - } - - if (name === 'added') { - const { - added, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat - } = props; - - return { - title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, - iconName: icons.ADD, - label: getRelativeDate( - added, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - }; - } - - if (name === 'albumCount') { - const { albumCount } = props; - let albums = '1 album'; - - if (albumCount === 0) { - albums = 'No albums'; - } else if (albumCount > 1) { - albums = `${albumCount} albums`; - } - - return { - title: 'Album Count', - iconName: icons.CIRCLE, - label: albums - }; - } - - if (name === 'path') { - return { - title: 'Path', - iconName: icons.FOLDER, - label: props.path - }; - } - - if (name === 'sizeOnDisk') { - return { - title: 'Size on Disk', - iconName: icons.DRIVE, - label: formatBytes(props.sizeOnDisk) - }; - } -} - -function ArtistIndexOverviewInfo(props) { - const { - height, - nextAiring, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat - } = props; - - let shownRows = 1; - - const maxRows = Math.floor(height / (infoRowHeight + 4)); - - return ( -
- { - !!nextAiring && - - } - - { - rows.map((row) => { - if (!isVisible(row, props)) { - return null; - } - - if (shownRows >= maxRows) { - return null; - } - - shownRows++; - - const infoRowProps = getInfoRowProps(row, props); - - return ( - - ); - }) - } -
- ); -} - -ArtistIndexOverviewInfo.propTypes = { - height: PropTypes.number.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showAdded: PropTypes.bool.isRequired, - showAlbumCount: PropTypes.bool.isRequired, - showPath: PropTypes.bool.isRequired, - showSizeOnDisk: PropTypes.bool.isRequired, - monitored: PropTypes.bool.isRequired, - nextAiring: PropTypes.string, - qualityProfile: PropTypes.object.isRequired, - lastAlbum: PropTypes.object, - added: PropTypes.string, - albumCount: PropTypes.number.isRequired, - path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, - sortKey: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default ArtistIndexOverviewInfo; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx new file mode 100644 index 000000000..c95d34b84 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx @@ -0,0 +1,260 @@ +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 = [ + { + name: 'monitored', + showProp: 'showMonitored', + valueProp: 'monitored', + }, + { + name: 'qualityProfileId', + showProp: 'showQualityProfile', + valueProp: 'qualityProfile', + }, + { + name: 'lastAlbum', + showProp: 'showLastAlbum', + valueProp: 'lastAlbum', + }, + { + name: 'added', + showProp: 'showAdded', + valueProp: 'added', + }, + { + name: 'albumCount', + showProp: 'showAlbumCount', + valueProp: 'albumCount', + }, + { + name: 'path', + showProp: 'showPath', + valueProp: 'path', + }, + { + name: 'sizeOnDisk', + showProp: 'showSizeOnDisk', + valueProp: 'sizeOnDisk', + }, +]; + +function getInfoRowProps( + row: RowProps, + props: ArtistIndexOverviewInfoProps, + uiSettings: UiSettings +): RowInfoProps | null { + const { name } = row; + + if (name === 'monitored') { + const monitoredText = props.monitored + ? translate('Monitored') + : translate('Unmonitored'); + + return { + title: monitoredText, + iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED, + label: monitoredText, + }; + } + + if (name === 'qualityProfileId' && !!props.qualityProfile?.name) { + return { + title: translate('QualityProfile'), + iconName: icons.PROFILE, + label: props.qualityProfile.name, + }; + } + + if (name === 'lastAlbum' && !!props.lastAlbum?.title) { + const lastAlbum = props.lastAlbum; + const { showRelativeDates, shortDateFormat, timeFormat } = uiSettings; + + return { + title: `Last Album: ${lastAlbum.title}`, + iconName: icons.CALENDAR, + label: + getRelativeDate( + lastAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + ) ?? '', + }; + } + + if (name === 'added') { + const added = props.added; + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = + uiSettings; + + return { + title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, + iconName: icons.ADD, + label: + getRelativeDate(added, shortDateFormat, showRelativeDates, { + timeFormat, + timeForToday: true, + }) ?? '', + }; + } + + if (name === 'albumCount') { + const { albumCount } = props; + let albums = '1 album'; + + if (albumCount === 0) { + albums = 'No albums'; + } else if (albumCount > 1) { + albums = `${albumCount} albums`; + } + + return { + title: translate('AlbumCount'), + iconName: icons.CIRCLE, + label: albums, + }; + } + + if (name === 'path') { + return { + title: translate('Path'), + iconName: icons.FOLDER, + label: props.path, + }; + } + + if (name === 'sizeOnDisk') { + return { + title: translate('SizeOnDisk'), + iconName: icons.DRIVE, + label: formatBytes(props.sizeOnDisk), + }; + } + + return null; +} + +function ArtistIndexOverviewInfo(props: ArtistIndexOverviewInfoProps) { + const { height, nextAlbum } = props; + + const uiSettings = useSelector(createUISettingsSelector()); + + const { shortDateFormat, showRelativeDates, longDateFormat, timeFormat } = + uiSettings; + + let shownRows = 1; + const maxRows = Math.floor(height / (infoRowHeight + 4)); + + const rowInfo = useMemo(() => { + return rows.map((row) => { + 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 { + ...row, + isVisible, + }; + }); + }, [props]); + + return ( +
+ {!!nextAlbum?.releaseDate && ( + + )} + + {rowInfo.map((row) => { + if (!row.isVisible) { + return null; + } + + if (shownRows >= maxRows) { + return null; + } + + shownRows++; + + const infoRowProps = getInfoRowProps(row, props, uiSettings); + + if (infoRowProps == null) { + return null; + } + + return ; + })} +
+ ); +} + +export default ArtistIndexOverviewInfo; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js deleted file mode 100644 index b04029b88..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js +++ /dev/null @@ -1,35 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import styles from './ArtistIndexOverviewInfoRow.css'; - -function ArtistIndexOverviewInfoRow(props) { - const { - title, - iconName, - label - } = props; - - return ( -
- - - {label} -
- ); -} - -ArtistIndexOverviewInfoRow.propTypes = { - title: PropTypes.string, - iconName: PropTypes.object.isRequired, - label: PropTypes.string.isRequired -}; - -export default ArtistIndexOverviewInfoRow; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx new file mode 100644 index 000000000..5d9b4a069 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx @@ -0,0 +1,24 @@ +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?: IconDefinition; + label: string | null; +} + +function ArtistIndexOverviewInfoRow(props: ArtistIndexOverviewInfoRowProps) { + const { title, iconName, label } = props; + + return ( +
+ + + {label} +
+ ); +} + +export default ArtistIndexOverviewInfoRow; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js deleted file mode 100644 index 101092170..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js +++ /dev/null @@ -1,275 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Grid, WindowScroller } from 'react-virtualized'; -import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; -import Measure from 'Components/Measure'; -import dimensions from 'Styles/Variables/dimensions'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import ArtistIndexOverview from './ArtistIndexOverview'; -import styles from './ArtistIndexOverviews.css'; - -// Poster container dimensions -const columnPadding = parseInt(dimensions.artistIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); -const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); -const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); - -function calculatePosterWidth(posterSize, isSmallScreen) { - const maxiumPosterWidth = isSmallScreen ? 192 : 202; - - if (posterSize === 'large') { - return maxiumPosterWidth; - } - - if (posterSize === 'medium') { - return Math.floor(maxiumPosterWidth * 0.75); - } - - return Math.floor(maxiumPosterWidth * 0.5); -} - -function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) { - const { - detailedProgressBar - } = overviewOptions; - - const heights = [ - posterHeight, - detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, - isSmallScreen ? columnPaddingSmallScreen : columnPadding - ]; - - return heights.reduce((acc, height) => acc + height, 0); -} - -function calculatePosterHeight(posterWidth) { - return posterWidth; -} - -class ArtistIndexOverviews extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - width: 0, - columnCount: 1, - posterWidth: 238, - posterHeight: 238, - rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}), - scrollRestored: false - }; - - this._grid = null; - } - - componentDidUpdate(prevProps, prevState) { - const { - items, - sortKey, - overviewOptions, - jumpToCharacter, - scrollTop, - isSmallScreen - } = this.props; - - const { - width, - rowHeight, - scrollRestored - } = this.state; - - if (prevProps.sortKey !== sortKey || - prevProps.overviewOptions !== overviewOptions) { - this.calculateGrid(this.state.width, isSmallScreen); - } - - if ( - this._grid && - (prevState.width !== width || - prevState.rowHeight !== rowHeight || - hasDifferentItemsOrOrder(prevProps.items, items) || - prevProps.overviewOptions !== overviewOptions - ) - ) { - // recomputeGridSize also forces Grid to discard its cache of rendered cells - this._grid.recomputeGridSize(); - } - - if (this._grid && scrollTop !== 0 && !scrollRestored) { - this.setState({ scrollRestored: true }); - this._grid.scrollToPosition({ scrollTop }); - } - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - const index = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (this._grid && index != null) { - - this._grid.scrollToCell({ - rowIndex: index, - columnIndex: 0 - }); - } - } - } - - // - // Control - - setGridRef = (ref) => { - this._grid = ref; - }; - - calculateGrid = (width = this.state.width, isSmallScreen) => { - const { - sortKey, - overviewOptions - } = this.props; - - const posterWidth = calculatePosterWidth(overviewOptions.size, isSmallScreen); - const posterHeight = calculatePosterHeight(posterWidth); - const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions); - - this.setState({ - width, - posterWidth, - posterHeight, - rowHeight - }); - }; - - cellRenderer = ({ key, rowIndex, style }) => { - const { - items, - sortKey, - overviewOptions, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - isSmallScreen - } = this.props; - - const { - posterWidth, - posterHeight, - rowHeight - } = this.state; - - const artist = items[rowIndex]; - - if (!artist) { - return null; - } - - return ( -
- -
- ); - }; - - // - // Listeners - - onMeasure = ({ width }) => { - this.calculateGrid(width, this.props.isSmallScreen); - }; - - // - // Render - - render() { - const { - items, - isSmallScreen, - scroller - } = this.props; - - const { - width, - rowHeight - } = this.state; - - return ( - - - {({ height, registerChild, onChildScroll, scrollTop }) => { - if (!height) { - return
; - } - - return ( -
- -
- ); - } - } - - - ); - } -} - -ArtistIndexOverviews.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - overviewOptions: PropTypes.object.isRequired, - scrollTop: PropTypes.number.isRequired, - jumpToCharacter: PropTypes.string, - scroller: PropTypes.instanceOf(Element).isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default ArtistIndexOverviews; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx new file mode 100644 index 000000000..11285c1b3 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx @@ -0,0 +1,213 @@ +import { throttle } from 'lodash'; +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'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import ArtistIndexOverview from './ArtistIndexOverview'; +import selectOverviewOptions from './selectOverviewOptions'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.artistIndexColumnPaddingSmallScreen +); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); + +interface RowItemData { + items: Artist[]; + sortKey: string; + posterWidth: number; + posterHeight: number; + rowHeight: number; + isSelectMode: boolean; + isSmallScreen: boolean; +} + +interface ArtistIndexOverviewsProps { + items: Artist[]; + sortKey: string; + sortDirection?: string; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: RefObject; + isSelectMode: boolean; + isSmallScreen: boolean; +} + +const Row: React.FC> = ({ + index, + style, + data, +}) => { + const { items, ...otherData } = data; + + if (index >= items.length) { + return null; + } + + const artist = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +function ArtistIndexOverviews(props: ArtistIndexOverviewsProps) { + const { + items, + sortKey, + jumpToCharacter, + scrollerRef, + isSelectMode, + isSmallScreen, + } = props; + + const { size: posterSize, detailedProgressBar } = useSelector( + selectOverviewOptions + ); + const listRef = useRef(null); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const posterWidth = useMemo(() => { + const maxiumPosterWidth = isSmallScreen ? 192 : 202; + + if (posterSize === 'large') { + return maxiumPosterWidth; + } + + if (posterSize === 'medium') { + return Math.floor(maxiumPosterWidth * 0.75); + } + + return Math.floor(maxiumPosterWidth * 0.5); + }, [posterSize, isSmallScreen]); + + const posterHeight = useMemo(() => { + return posterWidth; + }, [posterWidth]); + + const rowHeight = useMemo(() => { + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding, + ]; + + return heights.reduce((acc, height) => acc + height, 0); + }, [detailedProgressBar, posterHeight, isSmallScreen]); + + useEffect(() => { + const current = scrollerRef.current as HTMLElement; + + if (isSmallScreen) { + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = + (isSmallScreen ? bodyPaddingSmallScreen : bodyPadding) - 5; + + setSize({ + width: width - padding * 2, + height: window.innerHeight, + }); + } + }, [isSmallScreen, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollerRef = scrollerRef.current as HTMLElement; + const currentScrollListener = isSmallScreen ? window : currentScrollerRef; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + listRef.current?.scrollTo(scrollTop); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, listRef, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + let scrollTop = index * rowHeight; + + // If the offset is zero go to the top, otherwise offset + // by the approximate size of the header + padding (37 + 20). + if (scrollTop > 0) { + const offset = 57; + + scrollTop += offset; + } + + listRef.current?.scrollTo(scrollTop); + scrollerRef.current?.scrollTo(0, scrollTop); + } + } + }, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]); + + return ( +
+ + ref={listRef} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + itemCount={items.length} + itemSize={rowHeight} + itemData={{ + items, + sortKey, + posterWidth, + posterHeight, + rowHeight, + isSelectMode, + isSmallScreen, + }} + > + {Row} + +
+ ); +} + +export default ArtistIndexOverviews; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js deleted file mode 100644 index 030e8999b..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import ArtistIndexOverviews from './ArtistIndexOverviews'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex.overviewOptions, - createUISettingsSelector(), - createDimensionsSelector(), - (overviewOptions, uiSettings, dimensions) => { - return { - overviewOptions, - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat, - isSmallScreen: dimensions.isSmallScreen - }; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexOverviews); diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js deleted file mode 100644 index 9ca575185..000000000 --- a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import ArtistIndexOverviewOptionsModalContentConnector from './ArtistIndexOverviewOptionsModalContentConnector'; - -function ArtistIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) { - return ( - - - - ); -} - -ArtistIndexOverviewOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexOverviewOptionsModal; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.tsx b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.tsx new file mode 100644 index 000000000..bc999cee4 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistIndexOverviewOptionsModalContent from './ArtistIndexOverviewOptionsModalContent'; + +interface ArtistIndexOverviewOptionsModalProps { + isOpen: boolean; + onModalClose(...args: unknown[]): void; +} + +function ArtistIndexOverviewOptionsModal({ + isOpen, + onModalClose, + ...otherProps +}: ArtistIndexOverviewOptionsModalProps) { + return ( + + + + ); +} + +export default ArtistIndexOverviewOptionsModal; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js deleted file mode 100644 index 226f46a1b..000000000 --- a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js +++ /dev/null @@ -1,308 +0,0 @@ -import _ from 'lodash'; -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 } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -const posterSizeOptions = [ - { key: 'small', value: 'Small' }, - { key: 'medium', value: 'Medium' }, - { key: 'large', value: 'Large' } -]; - -class ArtistIndexOverviewOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - detailedProgressBar: props.detailedProgressBar, - size: props.size, - showMonitored: props.showMonitored, - showQualityProfile: props.showQualityProfile, - showLastAlbum: props.showLastAlbum, - showAdded: props.showAdded, - showAlbumCount: props.showAlbumCount, - showPath: props.showPath, - showSizeOnDisk: props.showSizeOnDisk, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - detailedProgressBar, - size, - showMonitored, - showQualityProfile, - showLastAlbum, - showAdded, - showAlbumCount, - showPath, - showSizeOnDisk, - showSearchAction - } = this.props; - - const state = {}; - - if (detailedProgressBar !== prevProps.detailedProgressBar) { - state.detailedProgressBar = detailedProgressBar; - } - - if (size !== prevProps.size) { - state.size = size; - } - - if (showMonitored !== prevProps.showMonitored) { - state.showMonitored = showMonitored; - } - - if (showQualityProfile !== prevProps.showQualityProfile) { - state.showQualityProfile = showQualityProfile; - } - - if (showLastAlbum !== prevProps.showLastAlbum) { - state.showLastAlbum = showLastAlbum; - } - - if (showAdded !== prevProps.showAdded) { - state.showAdded = showAdded; - } - - if (showAlbumCount !== prevProps.showAlbumCount) { - state.showAlbumCount = showAlbumCount; - } - - if (showPath !== prevProps.showPath) { - state.showPath = showPath; - } - - if (showSizeOnDisk !== prevProps.showSizeOnDisk) { - state.showSizeOnDisk = showSizeOnDisk; - } - - if (showSearchAction !== prevProps.showSearchAction) { - state.showSearchAction = showSearchAction; - } - - if (!_.isEmpty(state)) { - this.setState(state); - } - } - - // - // Listeners - - onChangeOverviewOption = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onChangeOverviewOption({ [name]: value }); - }); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - detailedProgressBar, - size, - showMonitored, - showQualityProfile, - showLastAlbum, - showAdded, - showAlbumCount, - showPath, - showSizeOnDisk, - showSearchAction - } = this.state; - - return ( - - - Overview Options - - - -
- - - {translate('PosterSize')} - - - - - - - - {translate('DetailedProgressBar')} - - - - - - - - {translate('ShowMonitored')} - - - - - - - - - {translate('ShowQualityProfile')} - - - - - - - - {translate('ShowLastAlbum')} - - - - - - - - {translate('ShowDateAdded')} - - - - - - - - {translate('ShowAlbumCount')} - - - - - - - - {translate('ShowPath')} - - - - - - - - {translate('ShowSizeOnDisk')} - - - - - - - - {translate('ShowSearch')} - - - - -
-
- - - - -
- ); - } -} - -ArtistIndexOverviewOptionsModalContent.propTypes = { - size: PropTypes.string.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showLastAlbum: PropTypes.bool.isRequired, - showAdded: PropTypes.bool.isRequired, - showAlbumCount: PropTypes.bool.isRequired, - showPath: PropTypes.bool.isRequired, - showSizeOnDisk: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onChangeOverviewOption: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexOverviewOptionsModalContent; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx new file mode 100644 index 000000000..4ab9391e3 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx @@ -0,0 +1,197 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +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 } from 'Helpers/Props'; +import { setArtistOverviewOption } from 'Store/Actions/artistIndexActions'; +import translate from 'Utilities/String/translate'; +import selectOverviewOptions from '../selectOverviewOptions'; + +const posterSizeOptions = [ + { + key: 'small', + get value() { + return translate('Small'); + }, + }, + { + key: 'medium', + get value() { + return translate('Medium'); + }, + }, + { + key: 'large', + get value() { + return translate('Large'); + }, + }, +]; + +interface ArtistIndexOverviewOptionsModalContentProps { + onModalClose(...args: unknown[]): void; +} + +function ArtistIndexOverviewOptionsModalContent( + props: ArtistIndexOverviewOptionsModalContentProps +) { + const { onModalClose } = props; + + const { + detailedProgressBar, + size, + showMonitored, + showQualityProfile, + showLastAlbum, + showAdded, + showAlbumCount, + showPath, + showSizeOnDisk, + showSearchAction, + } = useSelector(selectOverviewOptions); + + const dispatch = useDispatch(); + + const onOverviewOptionChange = useCallback( + ({ name, value }: { name: string; value: unknown }) => { + dispatch(setArtistOverviewOption({ [name]: value })); + }, + [dispatch] + ); + + return ( + + {translate('OverviewOptions')} + + +
+ + {translate('PosterSize')} + + + + + + {translate('DetailedProgressBar')} + + + + + + {translate('ShowMonitored')} + + + + + + {translate('ShowQualityProfile')} + + + + + + {translate('ShowLastAlbum')} + + + + + + {translate('ShowDateAdded')} + + + + + + {translate('ShowAlbumCount')} + + + + + + {translate('ShowPath')} + + + + + + {translate('ShowSizeOnDisk')} + + + + + + {translate('ShowSearch')} + + + +
+
+ + + + +
+ ); +} + +export default ArtistIndexOverviewOptionsModalContent; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js deleted file mode 100644 index 70c30dba6..000000000 --- a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js +++ /dev/null @@ -1,23 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setArtistOverviewOption } from 'Store/Actions/artistIndexActions'; -import ArtistIndexOverviewOptionsModalContent from './ArtistIndexOverviewOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex, - (artistIndex) => { - return artistIndex.overviewOptions; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onChangeOverviewOption(payload) { - dispatch(setArtistOverviewOption(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexOverviewOptionsModalContent); diff --git a/frontend/src/Artist/Index/Overview/selectOverviewOptions.ts b/frontend/src/Artist/Index/Overview/selectOverviewOptions.ts new file mode 100644 index 000000000..5875163c8 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/selectOverviewOptions.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const selectOverviewOptions = createSelector( + (state: AppState) => state.artistIndex.overviewOptions, + (overviewOptions) => overviewOptions +); + +export default selectOverviewOptions; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js deleted file mode 100644 index 455736ff1..000000000 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js +++ /dev/null @@ -1,305 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ArtistPoster from 'Artist/ArtistPoster'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; -import Label from 'Components/Label'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import { icons } from 'Helpers/Props'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import translate from 'Utilities/String/translate'; -import ArtistIndexPosterInfo from './ArtistIndexPosterInfo'; -import styles from './ArtistIndexPoster.css'; - -class ArtistIndexPoster extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - hasPosterError: false, - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - // - // Listeners - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - onPosterLoad = () => { - if (this.state.hasPosterError) { - this.setState({ hasPosterError: false }); - } - }; - - onPosterLoadError = () => { - if (!this.state.hasPosterError) { - this.setState({ hasPosterError: true }); - } - }; - - // - // Render - - render() { - const { - id, - artistName, - monitored, - foreignArtistId, - status, - nextAlbum, - lastAlbum, - statistics, - images, - posterWidth, - posterHeight, - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - qualityProfile, - showNextAlbum, - showSearchAction, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - isRefreshingArtist, - isSearchingArtist, - onRefreshArtistPress, - onSearchPress, - ...otherProps - } = this.props; - - const { - albumCount, - sizeOnDisk, - trackCount, - trackFileCount, - totalTrackCount - } = statistics; - - const { - hasPosterError, - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - const link = `/artist/${foreignArtistId}`; - - const elementStyle = { - width: `${posterWidth}px`, - height: `${posterHeight}px` - }; - - return ( -
-
-
- - - { - status === 'ended' && -
- } - - - - - { - hasPosterError && -
- {artistName} -
- } - - -
- - - - { - showTitle && -
- {artistName} -
- } - - { - showMonitored && -
- {monitored ? 'Monitored' : 'Unmonitored'} -
- } - - { - showQualityProfile && -
- {qualityProfile.name} -
- } - - { - showNextAlbum && !!nextAlbum?.releaseDate && -
- { - getRelativeDate( - nextAlbum.releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } -
- } - - - - - -
-
- ); - } -} - -ArtistIndexPoster.propTypes = { - id: PropTypes.number.isRequired, - artistName: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - foreignArtistId: PropTypes.string.isRequired, - nextAlbum: PropTypes.object, - lastAlbum: PropTypes.object, - statistics: PropTypes.object.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - posterWidth: PropTypes.number.isRequired, - posterHeight: PropTypes.number.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showTitle: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - qualityProfile: PropTypes.object.isRequired, - showNextAlbum: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isSearchingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired -}; - -ArtistIndexPoster.defaultProps = { - statistics: { - albumCount: 0, - trackCount: 0, - trackFileCount: 0, - totalTrackCount: 0 - } -}; - -export default ArtistIndexPoster; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx new file mode 100644 index 000000000..67c37c00d --- /dev/null +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx @@ -0,0 +1,263 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Statistics } from 'Artist/Artist'; +import ArtistPoster from 'Artist/ArtistPoster'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector'; +import ArtistIndexPosterInfo from 'Artist/Index/Posters/ArtistIndexPosterInfo'; +import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; +import ArtistIndexPosterSelect from 'Artist/Index/Select/ArtistIndexPosterSelect'; +import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import translate from 'Utilities/String/translate'; +import selectPosterOptions from './selectPosterOptions'; +import styles from './ArtistIndexPoster.css'; + +interface ArtistIndexPosterProps { + artistId: number; + sortKey: string; + isSelectMode: boolean; + posterWidth: number; + posterHeight: number; +} + +function ArtistIndexPoster(props: ArtistIndexPosterProps) { + const { artistId, sortKey, isSelectMode, posterWidth, posterHeight } = props; + + const { + artist, + qualityProfile, + metadataProfile, + isRefreshingArtist, + isSearchingArtist, + } = useSelector(createArtistIndexItemSelector(props.artistId)); + + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + showSearchAction, + } = useSelector(selectPosterOptions); + + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = + useSelector(createUISettingsSelector()); + + const { + artistName, + artistType, + monitored, + status, + path, + foreignArtistId, + nextAlbum, + added, + statistics = {} as Statistics, + images, + tags, + } = artist; + + const { + albumCount = 0, + trackCount = 0, + trackFileCount = 0, + totalTrackCount = 0, + sizeOnDisk = 0, + } = statistics; + + const dispatch = useDispatch(); + const [hasPosterError, setHasPosterError] = useState(false); + const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false); + const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: ARTIST_SEARCH, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onPosterLoadError = useCallback(() => { + setHasPosterError(true); + }, [setHasPosterError]); + + const onPosterLoad = useCallback(() => { + setHasPosterError(false); + }, [setHasPosterError]); + + const onEditArtistPress = useCallback(() => { + setIsEditArtistModalOpen(true); + }, [setIsEditArtistModalOpen]); + + const onEditArtistModalClose = useCallback(() => { + setIsEditArtistModalOpen(false); + }, [setIsEditArtistModalOpen]); + + const onDeleteArtistPress = useCallback(() => { + setIsEditArtistModalOpen(false); + setIsDeleteArtistModalOpen(true); + }, [setIsDeleteArtistModalOpen]); + + const onDeleteArtistModalClose = useCallback(() => { + setIsDeleteArtistModalOpen(false); + }, [setIsDeleteArtistModalOpen]); + + const link = `/artist/${foreignArtistId}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px`, + }; + + return ( +
+
+ {isSelectMode ? : null} + + + + {status === 'ended' ? ( +
+ ) : null} + + + + + {hasPosterError ? ( +
{artistName}
+ ) : null} + +
+ + + + {showTitle ? ( +
+ {artistName} +
+ ) : null} + + {showMonitored ? ( +
+ {monitored ? translate('Monitored') : translate('Unmonitored')} +
+ ) : null} + + {showQualityProfile && !!qualityProfile?.name ? ( +
+ {qualityProfile.name} +
+ ) : null} + + {showNextAlbum && !!nextAlbum?.releaseDate ? ( +
+ {getRelativeDate( + nextAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )} +
+ ) : null} + + + + + + +
+ ); +} + +export default ArtistIndexPoster; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.tsx similarity index 63% rename from frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js rename to frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.tsx index 20a34bffd..0d4ff9135 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.tsx @@ -1,16 +1,39 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import Album from 'Album/Album'; import TagListConnector from 'Components/TagListConnector'; +import MetadataProfile from 'typings/MetadataProfile'; +import QualityProfile from 'typings/QualityProfile'; 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 styles from './ArtistIndexPosterInfo.css'; -function ArtistIndexPosterInfo(props) { +interface ArtistIndexPosterInfoProps { + artistType?: string; + showQualityProfile: boolean; + qualityProfile?: QualityProfile; + metadataProfile?: MetadataProfile; + showNextAlbum: boolean; + nextAlbum?: Album; + lastAlbum?: Album; + added?: string; + albumCount: number; + path: string; + sizeOnDisk?: number; + tags?: number[]; + sortKey: string; + showRelativeDates: boolean; + shortDateFormat: string; + longDateFormat: string; + timeFormat: string; +} + +function ArtistIndexPosterInfo(props: ArtistIndexPosterInfoProps) { const { artistType, qualityProfile, + metadataProfile, showQualityProfile, showNextAlbum, nextAlbum, @@ -24,7 +47,7 @@ function ArtistIndexPosterInfo(props) { showRelativeDates, shortDateFormat, longDateFormat, - timeFormat + timeFormat, } = props; if (sortKey === 'artistType' && artistType) { @@ -35,7 +58,11 @@ function ArtistIndexPosterInfo(props) { ); } - if (sortKey === 'qualityProfileId' && !showQualityProfile) { + if ( + sortKey === 'qualityProfileId' && + !showQualityProfile && + !!qualityProfile?.name + ) { return (
{qualityProfile.name} @@ -43,6 +70,14 @@ function ArtistIndexPosterInfo(props) { ); } + if (sortKey === 'metadataProfileId' && !!metadataProfile?.name) { + return ( +
+ {metadataProfile.name} +
+ ); + } + if (sortKey === 'nextAlbum' && !showNextAlbum && !!nextAlbum?.releaseDate) { return (
- { - getRelativeDate( - nextAlbum.releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } + {getRelativeDate( + nextAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )}
); } @@ -78,17 +111,15 @@ function ArtistIndexPosterInfo(props) { timeFormat )}`} > - { - getRelativeDate( - lastAlbum.releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } + {getRelativeDate( + lastAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )}
); } @@ -100,7 +131,7 @@ function ArtistIndexPosterInfo(props) { showRelativeDates, { timeFormat, - timeForToday: false + timeForToday: false, } ); @@ -123,11 +154,7 @@ function ArtistIndexPosterInfo(props) { albums = translate('CountAlbums', { albumCount }); } - return ( -
- {albums} -
- ); + return
{albums}
; } if (sortKey === 'path') { @@ -146,12 +173,10 @@ function ArtistIndexPosterInfo(props) { ); } - if (sortKey === 'tags') { + if (sortKey === 'tags' && tags) { return (
- +
); } @@ -159,23 +184,4 @@ function ArtistIndexPosterInfo(props) { return null; } -ArtistIndexPosterInfo.propTypes = { - artistType: PropTypes.string, - qualityProfile: PropTypes.object.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showNextAlbum: PropTypes.bool.isRequired, - nextAlbum: PropTypes.object, - lastAlbum: PropTypes.object, - added: PropTypes.string, - albumCount: PropTypes.number.isRequired, - path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, - tags: PropTypes.arrayOf(PropTypes.number).isRequired, - sortKey: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - export default ArtistIndexPosterInfo; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js deleted file mode 100644 index 69df97c7c..000000000 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js +++ /dev/null @@ -1,351 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Grid, WindowScroller } from 'react-virtualized'; -import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; -import Measure from 'Components/Measure'; -import dimensions from 'Styles/Variables/dimensions'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import ArtistIndexPoster from './ArtistIndexPoster'; -import styles from './ArtistIndexPosters.css'; - -// Poster container dimensions -const columnPadding = parseInt(dimensions.artistIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); -const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); -const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); - -const additionalColumnCount = { - small: 3, - medium: 2, - large: 1 -}; - -function calculateColumnWidth(width, posterSize, isSmallScreen) { - const maxiumColumnWidth = isSmallScreen ? 172 : 182; - const columns = Math.floor(width / maxiumColumnWidth); - const remainder = width % maxiumColumnWidth; - - if (remainder === 0 && posterSize === 'large') { - return maxiumColumnWidth; - } - - return Math.floor(width / (columns + additionalColumnCount[posterSize])); -} - -function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions) { - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - showNextAlbum - } = posterOptions; - - const nextAiringHeight = 19; - - const heights = [ - posterHeight, - detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, - nextAiringHeight, - isSmallScreen ? columnPaddingSmallScreen : columnPadding - ]; - - if (showTitle) { - heights.push(19); - } - - if (showMonitored) { - heights.push(19); - } - - if (showQualityProfile) { - heights.push(19); - } - - if (showNextAlbum) { - heights.push(19); - } - - switch (sortKey) { - case 'artistType': - case 'lastAlbum': - case 'seasons': - case 'added': - case 'albumCount': - case 'path': - case 'sizeOnDisk': - case 'tags': - heights.push(19); - break; - case 'qualityProfileId': - if (!showQualityProfile) { - heights.push(19); - } - break; - case 'nextAlbum': - if (!showNextAlbum) { - heights.push(19); - } - break; - default: - // No need to add a height of 0 - } - - return heights.reduce((acc, height) => acc + height, 0); -} - -function calculatePosterHeight(posterWidth) { - return Math.ceil(posterWidth); -} - -class ArtistIndexPosters extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - width: 0, - columnWidth: 182, - columnCount: 1, - posterWidth: 238, - posterHeight: 238, - rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}), - scrollRestored: false - }; - - this._isInitialized = false; - this._grid = null; - this._padding = props.isSmallScreen ? columnPaddingSmallScreen : columnPadding; - } - - componentDidUpdate(prevProps, prevState) { - const { - items, - sortKey, - posterOptions, - jumpToCharacter, - scrollTop, - isSmallScreen - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight, - scrollRestored - } = this.state; - - if (prevProps.sortKey !== sortKey || - prevProps.posterOptions !== posterOptions) { - this.calculateGrid(width, isSmallScreen); - } - - if (this._grid && - (prevState.width !== width || - prevState.columnWidth !== columnWidth || - prevState.columnCount !== columnCount || - prevState.rowHeight !== rowHeight || - hasDifferentItemsOrOrder(prevProps.items, items))) { - // recomputeGridSize also forces Grid to discard its cache of rendered cells - this._grid.recomputeGridSize(); - } - - if (this._grid && scrollTop !== 0 && !scrollRestored) { - this.setState({ scrollRestored: true }); - this._grid.scrollToPosition({ scrollTop }); - } - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - const index = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (this._grid && index != null) { - const row = Math.floor(index / columnCount); - - this._grid.scrollToCell({ - rowIndex: row, - columnIndex: 0 - }); - } - } - } - - // - // Control - - setGridRef = (ref) => { - this._grid = ref; - }; - - calculateGrid = (width = this.state.width, isSmallScreen) => { - const { - sortKey, - posterOptions - } = this.props; - - const columnWidth = calculateColumnWidth(width, posterOptions.size, isSmallScreen); - const columnCount = Math.max(Math.floor(width / columnWidth), 1); - const posterWidth = columnWidth - this._padding * 2; - const posterHeight = calculatePosterHeight(posterWidth); - const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions); - - this.setState({ - width, - columnWidth, - columnCount, - posterWidth, - posterHeight, - rowHeight - }); - }; - - cellRenderer = ({ key, rowIndex, columnIndex, style }) => { - const { - items, - sortKey, - posterOptions, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat - } = this.props; - - const { - posterWidth, - posterHeight, - columnCount - } = this.state; - - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - showNextAlbum - } = posterOptions; - - const artist = items[rowIndex * columnCount + columnIndex]; - - if (!artist) { - return null; - } - - return ( -
- -
- ); - }; - - // - // Listeners - - onMeasure = ({ width }) => { - this.calculateGrid(width, this.props.isSmallScreen); - }; - - // - // Render - - render() { - const { - scroller, - items, - isSmallScreen - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight - } = this.state; - - const rowCount = Math.ceil(items.length / columnCount); - - return ( - - - {({ height, registerChild, onChildScroll, scrollTop }) => { - if (!height) { - return
; - } - - return ( -
- -
- ); - } - } - - - ); - } -} - -ArtistIndexPosters.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - posterOptions: PropTypes.object.isRequired, - jumpToCharacter: PropTypes.string, - scrollTop: PropTypes.number.isRequired, - scroller: PropTypes.instanceOf(Element).isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired -}; - -export default ArtistIndexPosters; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx new file mode 100644 index 000000000..c478ac1ae --- /dev/null +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx @@ -0,0 +1,313 @@ +import { throttle } from 'lodash'; +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'; +import SortDirection from 'Helpers/Props/SortDirection'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; + +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.artistIndexColumnPaddingSmallScreen +); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +const ADDITIONAL_COLUMN_COUNT: Record = { + small: 3, + medium: 2, + large: 1, +}; + +interface CellItemData { + layout: { + columnCount: number; + padding: number; + posterWidth: number; + posterHeight: number; + }; + items: Artist[]; + sortKey: string; + isSelectMode: boolean; +} + +interface ArtistIndexPostersProps { + items: Artist[]; + sortKey: string; + sortDirection?: SortDirection; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: RefObject; + isSelectMode: boolean; + isSmallScreen: boolean; +} + +const artistIndexSelector = createSelector( + (state: AppState) => state.artistIndex.posterOptions, + (posterOptions) => { + return { + posterOptions, + }; + } +); + +const Cell: React.FC> = ({ + columnIndex, + rowIndex, + style, + data, +}) => { + const { layout, items, sortKey, isSelectMode } = data; + const { columnCount, padding, posterWidth, posterHeight } = layout; + const index = rowIndex * columnCount + columnIndex; + + if (index >= items.length) { + return null; + } + + const artist = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +export default function ArtistIndexPosters(props: ArtistIndexPostersProps) { + const { + scrollerRef, + items, + sortKey, + jumpToCharacter, + isSelectMode, + isSmallScreen, + } = props; + + const { posterOptions } = useSelector(artistIndexSelector); + const ref = useRef(null); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const columnWidth = useMemo(() => { + const { width } = size; + const maximumColumnWidth = isSmallScreen ? 172 : 182; + const columns = Math.floor(width / maximumColumnWidth); + const remainder = width % maximumColumnWidth; + return remainder === 0 + ? maximumColumnWidth + : Math.floor( + width / (columns + ADDITIONAL_COLUMN_COUNT[posterOptions.size]) + ); + }, [isSmallScreen, posterOptions, size]); + + const columnCount = useMemo( + () => Math.max(Math.floor(size.width / columnWidth), 1), + [size, columnWidth] + ); + const padding = props.isSmallScreen + ? columnPaddingSmallScreen + : columnPadding; + const posterWidth = columnWidth - padding * 2; + const posterHeight = Math.ceil(posterWidth); + + const rowHeight = useMemo(() => { + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + } = posterOptions; + + const nextAiringHeight = 19; + + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + nextAiringHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding, + ]; + + if (showTitle) { + heights.push(19); + } + + if (showMonitored) { + heights.push(19); + } + + if (showQualityProfile) { + heights.push(19); + } + + if (showNextAlbum) { + heights.push(19); + } + + switch (sortKey) { + case 'artistType': + case 'metadataProfileId': + case 'lastAlbum': + case 'added': + case 'albumCount': + case 'path': + case 'sizeOnDisk': + case 'tags': + heights.push(19); + break; + case 'qualityProfileId': + if (!showQualityProfile) { + heights.push(19); + } + break; + case 'nextAlbum': + if (!showNextAlbum) { + heights.push(19); + } + break; + default: + // No need to add a height of 0 + } + + return heights.reduce((acc, height) => acc + height, 0); + }, [isSmallScreen, posterOptions, sortKey, posterHeight]); + + useEffect(() => { + const current = scrollerRef.current; + + if (isSmallScreen) { + const padding = bodyPaddingSmallScreen - 5; + const width = window.innerWidth - padding * 2; + const height = window.innerHeight; + + if (width !== size.width || height !== size.height) { + setSize({ + width, + height, + }); + } + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = bodyPadding - 5; + const finalWidth = width - padding * 2; + + if (Math.abs(size.width - finalWidth) < 20 || size.width === finalWidth) { + return; + } + + setSize({ + width: finalWidth, + height: window.innerHeight, + }); + } + }, [isSmallScreen, size, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollerRef = scrollerRef.current as HTMLElement; + const currentScrollListener = isSmallScreen ? window : currentScrollerRef; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + ref.current?.scrollTo({ scrollLeft: 0, scrollTop }); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, ref, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const rowIndex = Math.floor(index / columnCount); + + const scrollTop = rowIndex * rowHeight + padding; + + ref.current?.scrollTo({ scrollLeft: 0, scrollTop }); + scrollerRef.current?.scrollTo(0, scrollTop); + } + } + }, [ + jumpToCharacter, + rowHeight, + columnCount, + padding, + items, + scrollerRef, + ref, + ]); + + return ( +
+ + ref={ref} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + columnCount={columnCount} + columnWidth={columnWidth} + rowCount={Math.ceil(items.length / columnCount)} + rowHeight={rowHeight} + itemData={{ + layout: { + columnCount, + padding, + posterWidth, + posterHeight, + }, + items, + sortKey, + isSelectMode, + }} + > + {Cell} + +
+ ); +} diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js b/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js deleted file mode 100644 index bff8bef81..000000000 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import ArtistIndexPosters from './ArtistIndexPosters'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex.posterOptions, - createUISettingsSelector(), - createDimensionsSelector(), - (posterOptions, uiSettings, dimensions) => { - return { - posterOptions, - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat, - isSmallScreen: dimensions.isSmallScreen - }; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexPosters); diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js deleted file mode 100644 index e1b0a257a..000000000 --- a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import ArtistIndexPosterOptionsModalContentConnector from './ArtistIndexPosterOptionsModalContentConnector'; - -function ArtistIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) { - return ( - - - - ); -} - -ArtistIndexPosterOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexPosterOptionsModal; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.tsx b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.tsx new file mode 100644 index 000000000..69368807a --- /dev/null +++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistIndexPosterOptionsModalContent from './ArtistIndexPosterOptionsModalContent'; + +interface ArtistIndexPosterOptionsModalProps { + isOpen: boolean; + onModalClose(...args: unknown[]): unknown; +} + +function ArtistIndexPosterOptionsModal({ + isOpen, + onModalClose, +}: ArtistIndexPosterOptionsModalProps) { + return ( + + + + ); +} + +export default ArtistIndexPosterOptionsModal; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js deleted file mode 100644 index d0bc50baa..000000000 --- a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js +++ /dev/null @@ -1,248 +0,0 @@ -import _ from 'lodash'; -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 } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -const posterSizeOptions = [ - { key: 'small', value: 'Small' }, - { key: 'medium', value: 'Medium' }, - { key: 'large', value: 'Large' } -]; - -class ArtistIndexPosterOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - detailedProgressBar: props.detailedProgressBar, - size: props.size, - showTitle: props.showTitle, - showMonitored: props.showMonitored, - showQualityProfile: props.showQualityProfile, - showNextAlbum: props.showNextAlbum, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showNextAlbum, - showSearchAction - } = this.props; - - const state = {}; - - if (detailedProgressBar !== prevProps.detailedProgressBar) { - state.detailedProgressBar = detailedProgressBar; - } - - if (size !== prevProps.size) { - state.size = size; - } - - if (showTitle !== prevProps.showTitle) { - state.showTitle = showTitle; - } - - if (showMonitored !== prevProps.showMonitored) { - state.showMonitored = showMonitored; - } - - if (showQualityProfile !== prevProps.showQualityProfile) { - state.showQualityProfile = showQualityProfile; - } - - if (showNextAlbum !== prevProps.showNextAlbum) { - state.showNextAlbum = showNextAlbum; - } - - if (showSearchAction !== prevProps.showSearchAction) { - state.showSearchAction = showSearchAction; - } - - if (!_.isEmpty(state)) { - this.setState(state); - } - } - - // - // Listeners - - onChangePosterOption = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onChangePosterOption({ [name]: value }); - }); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showNextAlbum, - showSearchAction - } = this.state; - - return ( - - - Poster Options - - - -
- - - {translate('PosterSize')} - - - - - - - - {translate('DetailedProgressBar')} - - - - - - - - {translate('ShowName')} - - - - - - - - {translate('ShowMonitored')} - - - - - - - - {translate('ShowQualityProfile')} - - - - - - - - {translate('ShowNextAlbum')} - - - - - - - - {translate('ShowSearch')} - - - - -
-
- - - - -
- ); - } -} - -ArtistIndexPosterOptionsModalContent.propTypes = { - size: PropTypes.string.isRequired, - showTitle: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showNextAlbum: PropTypes.bool.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onChangePosterOption: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexPosterOptionsModalContent; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx new file mode 100644 index 000000000..2560d855a --- /dev/null +++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx @@ -0,0 +1,167 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import selectPosterOptions from 'Artist/Index/Posters/selectPosterOptions'; +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 } from 'Helpers/Props'; +import { setArtistPosterOption } from 'Store/Actions/artistIndexActions'; +import translate from 'Utilities/String/translate'; + +const posterSizeOptions = [ + { + key: 'small', + get value() { + return translate('Small'); + }, + }, + { + key: 'medium', + get value() { + return translate('Medium'); + }, + }, + { + key: 'large', + get value() { + return translate('Large'); + }, + }, +]; + +interface ArtistIndexPosterOptionsModalContentProps { + onModalClose(...args: unknown[]): unknown; +} + +function ArtistIndexPosterOptionsModalContent( + props: ArtistIndexPosterOptionsModalContentProps +) { + const { onModalClose } = props; + + const posterOptions = useSelector(selectPosterOptions); + + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + showSearchAction, + } = posterOptions; + + const dispatch = useDispatch(); + + const onPosterOptionChange = useCallback( + ({ name, value }: { name: string; value: unknown }) => { + dispatch(setArtistPosterOption({ [name]: value })); + }, + [dispatch] + ); + + return ( + + {translate('PosterOptions')} + + +
+ + {translate('PosterSize')} + + + + + + {translate('DetailedProgressBar')} + + + + + + {translate('ShowName')} + + + + + + {translate('ShowMonitored')} + + + + + + {translate('ShowQualityProfile')} + + + + + + {translate('ShowNextAlbum')} + + + + + + {translate('ShowSearch')} + + + +
+
+ + + + +
+ ); +} + +export default ArtistIndexPosterOptionsModalContent; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js deleted file mode 100644 index 72af268ad..000000000 --- a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js +++ /dev/null @@ -1,23 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setArtistPosterOption } from 'Store/Actions/artistIndexActions'; -import ArtistIndexPosterOptionsModalContent from './ArtistIndexPosterOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex, - (artistIndex) => { - return artistIndex.posterOptions; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onChangePosterOption(payload) { - dispatch(setArtistPosterOption(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexPosterOptionsModalContent); diff --git a/frontend/src/Artist/Index/Posters/selectPosterOptions.ts b/frontend/src/Artist/Index/Posters/selectPosterOptions.ts new file mode 100644 index 000000000..1a53a0add --- /dev/null +++ b/frontend/src/Artist/Index/Posters/selectPosterOptions.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const selectPosterOptions = createSelector( + (state: AppState) => state.artistIndex.posterOptions, + (posterOptions) => posterOptions +); + +export default selectPosterOptions; diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css index ce5313877..9b5777117 100644 --- a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css +++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css @@ -4,7 +4,6 @@ border-radius: 0; background-color: #5b5b5b; color: var(--white); - transition: width 200ms ease; } .progressBar { diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js deleted file mode 100644 index 27d5c6f77..000000000 --- a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js +++ /dev/null @@ -1,48 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ProgressBar from 'Components/ProgressBar'; -import { sizes } from 'Helpers/Props'; -import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; -import translate from 'Utilities/String/translate'; -import styles from './ArtistIndexProgressBar.css'; - -function ArtistIndexProgressBar(props) { - const { - monitored, - status, - trackCount, - trackFileCount, - totalTrackCount, - posterWidth, - detailedProgressBar - } = props; - - const progress = trackCount ? trackFileCount / trackCount * 100 : 100; - const text = `${trackFileCount} / ${trackCount}`; - - return ( - - ); -} - -ArtistIndexProgressBar.propTypes = { - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - trackCount: PropTypes.number.isRequired, - trackFileCount: PropTypes.number.isRequired, - totalTrackCount: PropTypes.number.isRequired, - posterWidth: PropTypes.number.isRequired, - detailedProgressBar: PropTypes.bool.isRequired -}; - -export default ArtistIndexProgressBar; diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.tsx b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.tsx new file mode 100644 index 000000000..2a8167b99 --- /dev/null +++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.tsx @@ -0,0 +1,72 @@ +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'; +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; + width: number; + detailedProgressBar: boolean; + isStandalone: boolean; +} + +function ArtistIndexProgressBar(props: ArtistIndexProgressBarProps) { + const { + artistId, + monitored, + status, + trackCount, + trackFileCount, + totalTrackCount, + 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 = newDownloads + ? `${trackFileCount} + ${newDownloads} / ${trackCount}` + : `${trackFileCount} / ${trackCount}`; + + return ( + 0 + )} + size={detailedProgressBar ? sizes.MEDIUM : sizes.SMALL} + showText={detailedProgressBar} + text={text} + title={translate('ArtistProgressBarText', { + trackFileCount, + trackCount, + totalTrackCount, + downloadingCount: queueDetails.count, + })} + width={width} + /> + ); +} + +export default ArtistIndexProgressBar; diff --git a/frontend/src/Artist/Index/Select/AlbumStudio/AlbumDetails.css b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumDetails.css new file mode 100644 index 000000000..5f6ee37c1 --- /dev/null +++ b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumDetails.css @@ -0,0 +1,10 @@ +.albums { + display: flex; + flex-wrap: wrap; +} + +.truncated { + align-self: center; + flex: 0 0 100%; + padding: 4px 6px; +} diff --git a/frontend/src/AlbumStudio/AlbumStudioRow.css.d.ts b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumDetails.css.d.ts similarity index 69% rename from frontend/src/AlbumStudio/AlbumStudioRow.css.d.ts rename to frontend/src/Artist/Index/Select/AlbumStudio/AlbumDetails.css.d.ts index 9f9ac8eac..13c576f2c 100644 --- a/frontend/src/AlbumStudio/AlbumStudioRow.css.d.ts +++ b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumDetails.css.d.ts @@ -2,10 +2,7 @@ // Please do not change this file! interface CssExports { 'albums': string; - 'cell': string; - 'selectCell': string; - 'status': string; - 'title': string; + 'truncated': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Artist/Index/Select/AlbumStudio/AlbumDetails.tsx b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumDetails.tsx new file mode 100644 index 000000000..255bd9ba4 --- /dev/null +++ b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumDetails.tsx @@ -0,0 +1,89 @@ +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'; +import { clearAlbums, fetchAlbums } from 'Store/Actions/albumActions'; +import createArtistAlbumsSelector from 'Store/Selectors/createArtistAlbumsSelector'; +import translate from 'Utilities/String/translate'; +import AlbumStudioAlbum from './AlbumStudioAlbum'; +import styles from './AlbumDetails.css'; + +interface AlbumDetailsProps { + artistId: number; +} + +function AlbumDetails(props: AlbumDetailsProps) { + const { artistId } = props; + + const { + isFetching, + isPopulated, + error, + items: albums, + } = useSelector(createArtistAlbumsSelector(artistId)); + + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchAlbums({ artistId })); + + return () => { + dispatch(clearAlbums()); + }; + }, [dispatch, artistId]); + + const latestAlbums = useMemo(() => { + const sortedAlbums = _.orderBy(albums, 'releaseDate', 'desc'); + + return sortedAlbums.slice(0, 20); + }, [albums]); + + return ( +
+ {isFetching ? : null} + + {!isFetching && error ? ( + {translate('AlbumsLoadError')} + ) : null} + + {isPopulated && !error + ? latestAlbums.map((album) => { + const { + id: albumId, + title, + disambiguation, + albumType, + monitored, + statistics = {} as Statistics, + isSaving = false, + } = album; + + return ( + + ); + }) + : null} + + {latestAlbums.length < albums.length ? ( +
+ {translate('AlbumStudioTruncated')} +
+ ) : null} +
+ ); +} + +export default AlbumDetails; diff --git a/frontend/src/AlbumStudio/AlbumStudioAlbum.css b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumStudioAlbum.css similarity index 100% rename from frontend/src/AlbumStudio/AlbumStudioAlbum.css rename to frontend/src/Artist/Index/Select/AlbumStudio/AlbumStudioAlbum.css diff --git a/frontend/src/AlbumStudio/AlbumStudioAlbum.css.d.ts b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumStudioAlbum.css.d.ts similarity index 100% rename from frontend/src/AlbumStudio/AlbumStudioAlbum.css.d.ts rename to frontend/src/Artist/Index/Select/AlbumStudio/AlbumStudioAlbum.css.d.ts diff --git a/frontend/src/Artist/Index/Select/AlbumStudio/AlbumStudioAlbum.tsx b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumStudioAlbum.tsx new file mode 100644 index 000000000..3e7e0578f --- /dev/null +++ b/frontend/src/Artist/Index/Select/AlbumStudio/AlbumStudioAlbum.tsx @@ -0,0 +1,87 @@ +import classNames from 'classnames'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { Statistics } from 'Album/Album'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import { toggleAlbumsMonitored } from 'Store/Actions/albumActions'; +import translate from 'Utilities/String/translate'; +import styles from './AlbumStudioAlbum.css'; + +interface AlbumStudioAlbumProps { + artistId: number; + albumId: number; + title: string; + disambiguation?: string; + albumType: string; + monitored: boolean; + statistics: Statistics; + isSaving: boolean; +} + +function AlbumStudioAlbum(props: AlbumStudioAlbumProps) { + const { + albumId, + title, + disambiguation, + albumType, + monitored, + statistics = { + trackFileCount: 0, + totalTrackCount: 0, + percentOfTracks: 0, + }, + isSaving = false, + } = props; + + const { + trackFileCount = 0, + totalTrackCount = 0, + percentOfTracks = 0, + } = statistics; + + const dispatch = useDispatch(); + const onAlbumMonitoredPress = useCallback(() => { + dispatch( + toggleAlbumsMonitored({ + albumIds: [albumId], + monitored: !monitored, + }) + ); + }, [albumId, monitored, dispatch]); + + return ( +
+
+ + + + {disambiguation ? `${title} (${disambiguation})` : `${title}`} + +
+ +
+ {albumType} +
+ +
+ {totalTrackCount === 0 ? '0/0' : `${trackFileCount}/${totalTrackCount}`} +
+
+ ); +} + +export default AlbumStudioAlbum; diff --git a/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModal.tsx b/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModal.tsx new file mode 100644 index 000000000..b48717af0 --- /dev/null +++ b/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ChangeMonitoringModalContent from './ChangeMonitoringModalContent'; + +interface ChangeMonitoringModalProps { + isOpen: boolean; + artistIds: number[]; + onSavePress(monitor: string): void; + onModalClose(): void; +} + +function ChangeMonitoringModal(props: ChangeMonitoringModalProps) { + const { isOpen, artistIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ChangeMonitoringModal; diff --git a/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModalContent.css b/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModalContent.css new file mode 100644 index 000000000..29dc69dc4 --- /dev/null +++ b/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModalContent.css @@ -0,0 +1,26 @@ +.labelIcon { + margin-left: 8px; +} + +.message { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 30px; +} + +.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/Artist/Index/Select/AlbumStudio/ChangeMonitoringModalContent.css.d.ts b/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModalContent.css.d.ts new file mode 100644 index 000000000..4c59f6545 --- /dev/null +++ b/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModalContent.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'labelIcon': string; + 'message': string; + 'modalFooter': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModalContent.tsx b/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModalContent.tsx new file mode 100644 index 000000000..b3c2abbbe --- /dev/null +++ b/frontend/src/Artist/Index/Select/AlbumStudio/ChangeMonitoringModalContent.tsx @@ -0,0 +1,96 @@ +import React, { useCallback, useState } from 'react'; +import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent'; +import Alert from 'Components/Alert'; +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 Icon from 'Components/Icon'; +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 Popover from 'Components/Tooltip/Popover'; +import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ChangeMonitoringModalContent.css'; + +const NO_CHANGE = 'noChange'; + +interface ChangeMonitoringModalContentProps { + artistIds: number[]; + saveError?: object; + onSavePress(monitor: string): void; + onModalClose(): void; +} + +function ChangeMonitoringModalContent( + props: ChangeMonitoringModalContentProps +) { + const { artistIds, onSavePress, onModalClose, ...otherProps } = props; + + const [monitor, setMonitor] = useState(NO_CHANGE); + + const onInputChange = useCallback( + ({ value }: { value: string }) => { + setMonitor(value); + }, + [setMonitor] + ); + + const onSavePressWrapper = useCallback(() => { + onSavePress(monitor); + }, [monitor, onSavePress]); + + const selectedCount = artistIds.length; + + return ( + + {translate('MonitorArtists')} + + + + {translate('MonitorAlbumExistingOnlyWarning')} + + +
+ + + {translate('MonitorExistingAlbums')} + + } + title={translate('MonitoringOptions')} + body={} + position={tooltipPositions.RIGHT} + /> + + + + +
+
+ + +
+ {translate('CountArtistsSelected', { count: selectedCount })} +
+ +
+ + + +
+
+
+ ); +} + +export default ChangeMonitoringModalContent; diff --git a/frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.css b/frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.css new file mode 100644 index 000000000..eccb80f87 --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.css @@ -0,0 +1,38 @@ +.checkButton { + position: absolute; + top: 0; + left: 0; + z-index: 3; + width: 36px; + height: 36px; +} + +.checkContainer { + position: absolute; + top: 8px; + left: 8px; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: var(--defaultColor); +} + +.selected { + color: var(--lidarrGreen); +} + +.unselected { + color: var(--white); +} + +.checkButton { + &:hover { + .selected { + color: var(--white); + } + + .unselected { + color: var(--lidarrGreen); + } + } +} diff --git a/frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.css.d.ts b/frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.css.d.ts new file mode 100644 index 000000000..d4de0b5f5 --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'checkButton': string; + 'checkContainer': string; + 'selected': string; + 'unselected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.tsx b/frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.tsx new file mode 100644 index 000000000..86b41e8ba --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexPosterSelect.tsx @@ -0,0 +1,45 @@ +import React, { SyntheticEvent, useCallback } from 'react'; +import { useSelect } from 'App/SelectContext'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import { icons } from 'Helpers/Props'; +import styles from './ArtistIndexPosterSelect.css'; + +interface ArtistIndexPosterSelectProps { + artistId: number; +} + +function ArtistIndexPosterSelect(props: ArtistIndexPosterSelectProps) { + const { artistId } = props; + const [selectState, selectDispatch] = useSelect(); + const isSelected = selectState.selectedState[artistId]; + + const onSelectPress = useCallback( + (event: SyntheticEvent) => { + const nativeEvent = event.nativeEvent as PointerEvent; + const shiftKey = nativeEvent.shiftKey; + + selectDispatch({ + type: 'toggleSelected', + id: artistId, + isSelected: !isSelected, + shiftKey, + }); + }, + [artistId, isSelected, selectDispatch] + ); + + return ( + + + + + + ); +} + +export default ArtistIndexPosterSelect; diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectAllButton.tsx b/frontend/src/Artist/Index/Select/ArtistIndexSelectAllButton.tsx new file mode 100644 index 000000000..2b3e9c01c --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectAllButton.tsx @@ -0,0 +1,40 @@ +import React, { useCallback } from 'react'; +import { useSelect } from 'App/SelectContext'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import { icons } from 'Helpers/Props'; + +interface ArtistIndexSelectAllButtonProps { + label: string; + isSelectMode: boolean; + overflowComponent: React.FunctionComponent; +} + +function ArtistIndexSelectAllButton(props: ArtistIndexSelectAllButtonProps) { + const { isSelectMode } = props; + const [selectState, selectDispatch] = useSelect(); + const { allSelected, allUnselected } = selectState; + + let icon = icons.SQUARE_MINUS; + + if (allSelected) { + icon = icons.CHECK_SQUARE; + } else if (allUnselected) { + icon = icons.SQUARE; + } + + const onPress = useCallback(() => { + selectDispatch({ + type: allSelected ? 'unselectAll' : 'selectAll', + }); + }, [allSelected, selectDispatch]); + + return isSelectMode ? ( + + ) : null; +} + +export default ArtistIndexSelectAllButton; diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectAllMenuItem.tsx b/frontend/src/Artist/Index/Select/ArtistIndexSelectAllMenuItem.tsx new file mode 100644 index 000000000..2340b65b6 --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectAllMenuItem.tsx @@ -0,0 +1,41 @@ +import React, { useCallback } from 'react'; +import { useSelect } from 'App/SelectContext'; +import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem'; +import { icons } from 'Helpers/Props'; + +interface ArtistIndexSelectAllMenuItemProps { + label: string; + isSelectMode: boolean; +} + +function ArtistIndexSelectAllMenuItem( + props: ArtistIndexSelectAllMenuItemProps +) { + const { isSelectMode } = props; + const [selectState, selectDispatch] = useSelect(); + const { allSelected, allUnselected } = selectState; + + let iconName = icons.SQUARE_MINUS; + + if (allSelected) { + iconName = icons.CHECK_SQUARE; + } else if (allUnselected) { + iconName = icons.SQUARE; + } + + const onPressWrapper = useCallback(() => { + selectDispatch({ + type: allSelected ? 'unselectAll' : 'selectAll', + }); + }, [allSelected, selectDispatch]); + + return isSelectMode ? ( + + ) : null; +} + +export default ArtistIndexSelectAllMenuItem; diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.css b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.css new file mode 100644 index 000000000..d385923ef --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.css @@ -0,0 +1,72 @@ +.footer { + composes: contentFooter from '~Components/Page/PageContentFooter.css'; + + align-items: center; +} + +.buttons { + display: flex; +} + +.actionButtons, +.deleteButtons { + display: flex; + gap: 10px; +} + +.deleteButtons { + margin-left: 50px; +} + +.selected { + display: flex; + justify-content: flex-end; + flex-grow: 1; + font-weight: bold; +} + +@media only screen and (max-width: $breakpointMedium) { + .buttons { + justify-content: center; + width: 100%; + } + + .selected { + justify-content: center; + margin-bottom: 20px; + width: 100%; + order: -1; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .footer { + display: flex; + flex-direction: column; + } + + .buttons { + flex-direction: column; + margin-top: 20px; + gap: 20px; + } + + .actionButtons { + flex-wrap: wrap; + } + + .actionButtons, + .deleteButtons { + display: flex; + justify-content: center; + } + + .deleteButtons { + margin-left: 0; + } + + .selected { + justify-content: center; + order: -1; + } +} diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.css.d.ts b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.css.d.ts new file mode 100644 index 000000000..7f02229e3 --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'actionButtons': string; + 'buttons': string; + 'deleteButtons': string; + 'footer': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.tsx b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.tsx new file mode 100644 index 000000000..f0569d607 --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectFooter.tsx @@ -0,0 +1,300 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import { useSelect } from 'App/SelectContext'; +import AppState from 'App/State/AppState'; +import { RENAME_ARTIST, RETAG_ARTIST } from 'Commands/commandNames'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { kinds } from 'Helpers/Props'; +import { + saveArtistEditor, + updateArtistsMonitor, +} from 'Store/Actions/artistActions'; +import { fetchRootFolders } from 'Store/Actions/settingsActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import ChangeMonitoringModal from './AlbumStudio/ChangeMonitoringModal'; +import RetagArtistModal from './AudioTags/RetagArtistModal'; +import DeleteArtistModal from './Delete/DeleteArtistModal'; +import EditArtistModal from './Edit/EditArtistModal'; +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) => { + const { isSaving, isDeleting, deleteError } = artist; + + return { + isSaving, + isDeleting, + deleteError, + }; + } +); + +function ArtistIndexSelectFooter() { + const { isSaving, isDeleting, deleteError } = + useSelector(artistEditorSelector); + + const isOrganizingArtist = useSelector( + createCommandExecutingSelector(RENAME_ARTIST) + ); + const isRetaggingArtist = useSelector( + createCommandExecutingSelector(RETAG_ARTIST) + ); + + const dispatch = useDispatch(); + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false); + const [isRetaggingModalOpen, setIsRetaggingModalOpen] = useState(false); + const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); + const [isMonitoringModalOpen, setIsMonitoringModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isSavingArtist, setIsSavingArtist] = useState(false); + const [isSavingTags, setIsSavingTags] = useState(false); + const [isSavingMonitoring, setIsSavingMonitoring] = useState(false); + const previousIsDeleting = usePrevious(isDeleting); + + const [selectState, selectDispatch] = useSelect(); + const { selectedState } = selectState; + + const artistIds = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const selectedCount = artistIds.length; + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onSavePress = useCallback( + (payload: SavePayload) => { + setIsSavingArtist(true); + setIsEditModalOpen(false); + + dispatch( + saveArtistEditor({ + ...payload, + artistIds, + }) + ); + }, + [artistIds, dispatch] + ); + + const onOrganizePress = useCallback(() => { + setIsOrganizeModalOpen(true); + }, [setIsOrganizeModalOpen]); + + const onOrganizeModalClose = useCallback(() => { + setIsOrganizeModalOpen(false); + }, [setIsOrganizeModalOpen]); + + const onRetagPress = useCallback(() => { + setIsRetaggingModalOpen(true); + }, [setIsRetaggingModalOpen]); + + const onRetagModalClose = useCallback(() => { + setIsRetaggingModalOpen(false); + }, [setIsRetaggingModalOpen]); + + const onTagsPress = useCallback(() => { + setIsTagsModalOpen(true); + }, [setIsTagsModalOpen]); + + const onTagsModalClose = useCallback(() => { + setIsTagsModalOpen(false); + }, [setIsTagsModalOpen]); + + const onApplyTagsPress = useCallback( + (tags: number[], applyTags: string) => { + setIsSavingTags(true); + setIsTagsModalOpen(false); + + dispatch( + saveArtistEditor({ + artistIds, + tags, + applyTags, + }) + ); + }, + [artistIds, dispatch] + ); + + const onMonitoringPress = useCallback(() => { + setIsMonitoringModalOpen(true); + }, [setIsMonitoringModalOpen]); + + const onMonitoringClose = useCallback(() => { + setIsMonitoringModalOpen(false); + }, [setIsMonitoringModalOpen]); + + const onMonitoringSavePress = useCallback( + (monitor: string) => { + setIsSavingMonitoring(true); + setIsMonitoringModalOpen(false); + + dispatch( + updateArtistsMonitor({ + artistIds, + monitor, + }) + ); + }, + [artistIds, dispatch] + ); + + const onDeletePress = useCallback(() => { + setIsDeleteModalOpen(true); + }, [setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, []); + + useEffect(() => { + if (!isSaving) { + setIsSavingArtist(false); + setIsSavingTags(false); + setIsSavingMonitoring(false); + } + }, [isSaving]); + + useEffect(() => { + if (previousIsDeleting && !isDeleting && !deleteError) { + selectDispatch({ type: 'unselectAll' }); + } + }, [previousIsDeleting, isDeleting, deleteError, selectDispatch]); + + useEffect(() => { + dispatch(fetchRootFolders()); + }, [dispatch]); + + const anySelected = selectedCount > 0; + + return ( + +
+
+ + {translate('Edit')} + + + + {translate('RenameFiles')} + + + + {translate('WriteMetadataTags')} + + + + {translate('SetAppTags')} + + + + {translate('UpdateMonitoring')} + +
+ +
+ + {translate('Delete')} + +
+
+ +
+ {translate('CountArtistsSelected', { count: selectedCount })} +
+ + + + + + + + + + + + +
+ ); +} + +export default ArtistIndexSelectFooter; diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectModeButton.tsx b/frontend/src/Artist/Index/Select/ArtistIndexSelectModeButton.tsx new file mode 100644 index 000000000..8679bba99 --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectModeButton.tsx @@ -0,0 +1,37 @@ +import { IconDefinition } from '@fortawesome/fontawesome-common-types'; +import React, { useCallback } from 'react'; +import { useSelect } from 'App/SelectContext'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; + +interface ArtistIndexSelectModeButtonProps { + label: string; + iconName: IconDefinition; + isSelectMode: boolean; + overflowComponent: React.FunctionComponent; + onPress: () => void; +} + +function ArtistIndexSelectModeButton(props: ArtistIndexSelectModeButtonProps) { + const { label, iconName, isSelectMode, onPress } = props; + const [, selectDispatch] = useSelect(); + + const onPressWrapper = useCallback(() => { + if (isSelectMode) { + selectDispatch({ + type: 'reset', + }); + } + + onPress(); + }, [isSelectMode, onPress, selectDispatch]); + + return ( + + ); +} + +export default ArtistIndexSelectModeButton; diff --git a/frontend/src/Artist/Index/Select/ArtistIndexSelectModeMenuItem.tsx b/frontend/src/Artist/Index/Select/ArtistIndexSelectModeMenuItem.tsx new file mode 100644 index 000000000..b5a7a6de4 --- /dev/null +++ b/frontend/src/Artist/Index/Select/ArtistIndexSelectModeMenuItem.tsx @@ -0,0 +1,38 @@ +import { IconDefinition } from '@fortawesome/fontawesome-common-types'; +import React, { useCallback } from 'react'; +import { useSelect } from 'App/SelectContext'; +import PageToolbarOverflowMenuItem from 'Components/Page/Toolbar/PageToolbarOverflowMenuItem'; + +interface ArtistIndexSelectModeMenuItemProps { + label: string; + iconName: IconDefinition; + isSelectMode: boolean; + onPress: () => void; +} + +function ArtistIndexSelectModeMenuItem( + props: ArtistIndexSelectModeMenuItemProps +) { + const { label, iconName, isSelectMode, onPress } = props; + const [, selectDispatch] = useSelect(); + + const onPressWrapper = useCallback(() => { + if (isSelectMode) { + selectDispatch({ + type: 'reset', + }); + } + + onPress(); + }, [isSelectMode, onPress, selectDispatch]); + + return ( + + ); +} + +export default ArtistIndexSelectModeMenuItem; diff --git a/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModal.tsx b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModal.tsx new file mode 100644 index 000000000..5d5f1fb6a --- /dev/null +++ b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import RetagArtistModalContent from './RetagArtistModalContent'; + +interface RetagArtistModalProps { + isOpen: boolean; + artistIds: number[]; + onModalClose: () => void; +} + +function RetagArtistModal(props: RetagArtistModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default RetagArtistModal; diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.css b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.css similarity index 100% rename from frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.css rename to frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.css diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.css.d.ts b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.css.d.ts similarity index 100% rename from frontend/src/Artist/Editor/AudioTags/RetagArtistModalContent.css.d.ts rename to frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.css.d.ts diff --git a/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.tsx b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.tsx new file mode 100644 index 000000000..b67ee60aa --- /dev/null +++ b/frontend/src/Artist/Index/Select/AudioTags/RetagArtistModalContent.tsx @@ -0,0 +1,91 @@ +import { orderBy } from 'lodash'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Artist from 'Artist/Artist'; +import { RETAG_ARTIST } from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import Icon from 'Components/Icon'; +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 { icons, kinds } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import translate from 'Utilities/String/translate'; +import styles from './RetagArtistModalContent.css'; + +interface RetagArtistModalContentProps { + artistIds: number[]; + onModalClose: () => void; +} + +function RetagArtistModalContent(props: RetagArtistModalContentProps) { + const { artistIds, onModalClose } = props; + + const allArtists: Artist[] = useSelector(createAllArtistSelector()); + const dispatch = useDispatch(); + + const artistNames = useMemo(() => { + 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']); + + return sorted.map((a) => a.artistName); + }, [artistIds, allArtists]); + + const onRetagPress = useCallback(() => { + dispatch( + executeCommand({ + name: RETAG_ARTIST, + artistIds, + }) + ); + + onModalClose(); + }, [artistIds, onModalClose, dispatch]); + + return ( + + {translate('RetagSelectedArtists')} + + + + Tip: To preview the tags that will be written, select "Cancel", then + select any artist name and use the + + + +
+ Are you sure you want to retag all files in the {artistNames.length}{' '} + selected artist? +
+ +
    + {artistNames.map((artistName) => { + return
  • {artistName}
  • ; + })} +
+
+ + + + + + +
+ ); +} + +export default RetagArtistModalContent; diff --git a/frontend/src/Artist/Index/Select/Delete/DeleteArtistModal.tsx b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModal.tsx new file mode 100644 index 000000000..c909d7406 --- /dev/null +++ b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModal.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DeleteArtistModalContent from './DeleteArtistModalContent'; + +interface DeleteArtistModalProps { + isOpen: boolean; + artistIds: number[]; + onModalClose(): void; +} + +function DeleteArtistModal(props: DeleteArtistModalProps) { + const { isOpen, artistIds, onModalClose } = props; + + return ( + + + + ); +} + +export default DeleteArtistModal; diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.css similarity index 100% rename from frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css rename to frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.css diff --git a/frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css.d.ts b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.css.d.ts similarity index 100% rename from frontend/src/Artist/Editor/Delete/DeleteArtistModalContent.css.d.ts rename to frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.css.d.ts diff --git a/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.tsx b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.tsx new file mode 100644 index 000000000..4accc9f0e --- /dev/null +++ b/frontend/src/Artist/Index/Select/Delete/DeleteArtistModalContent.tsx @@ -0,0 +1,166 @@ +import { orderBy } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import Artist from 'Artist/Artist'; +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 } 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'; + +interface DeleteArtistModalContentProps { + artistIds: number[]; + onModalClose(): void; +} + +const selectDeleteOptions = createSelector( + (state: AppState) => state.artist.deleteOptions, + (deleteOptions) => deleteOptions +); + +function DeleteArtistModalContent(props: DeleteArtistModalContentProps) { + const { artistIds, onModalClose } = props; + + const { addImportListExclusion } = useSelector(selectDeleteOptions); + const allArtists: Artist[] = useSelector(createAllArtistSelector()); + const dispatch = useDispatch(); + + const [deleteFiles, setDeleteFiles] = useState(false); + + const artists = useMemo((): Artist[] => { + const artistList = artistIds.map((id) => { + return allArtists.find((a) => a.id === id); + }) as Artist[]; + + return orderBy(artistList, ['sortName']); + }, [artistIds, allArtists]); + + const onDeleteFilesChange = useCallback( + ({ value }: CheckInputChanged) => { + setDeleteFiles(value); + }, + [setDeleteFiles] + ); + + const onDeleteOptionChange = useCallback( + ({ name, value }: { name: string; value: boolean }) => { + dispatch( + setDeleteOption({ + [name]: value, + }) + ); + }, + [dispatch] + ); + + const onDeleteArtistConfirmed = useCallback(() => { + setDeleteFiles(false); + + dispatch( + bulkDeleteArtist({ + artistIds, + deleteFiles, + addImportListExclusion, + }) + ); + + onModalClose(); + }, [ + artistIds, + deleteFiles, + addImportListExclusion, + setDeleteFiles, + dispatch, + onModalClose, + ]); + + return ( + + {translate('DeleteSelectedArtists')} + + +
+ + {translate('AddListExclusion')} + + + + + + + {artists.length > 1 + ? translate('DeleteArtistFolders') + : translate('DeleteArtistFolder')} + + + 1 + ? translate('DeleteArtistFoldersHelpText') + : translate('DeleteArtistFolderHelpText') + } + kind={kinds.DANGER} + onChange={onDeleteFilesChange} + /> + +
+ +
+ {deleteFiles + ? translate('DeleteArtistFolderCountWithFilesConfirmation', { + count: artists.length, + }) + : translate('DeleteArtistFolderCountConfirmation', { + count: artists.length, + })} +
+ +
    + {artists.map((a) => { + return ( +
  • + {a.artistName} + + {deleteFiles && ( + + -{a.path} + + )} +
  • + ); + })} +
+
+ + + + + + +
+ ); +} + +export default DeleteArtistModalContent; diff --git a/frontend/src/Artist/Index/Select/Edit/EditArtistModal.tsx b/frontend/src/Artist/Index/Select/Edit/EditArtistModal.tsx new file mode 100644 index 000000000..bdb6726be --- /dev/null +++ b/frontend/src/Artist/Index/Select/Edit/EditArtistModal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EditArtistModalContent from './EditArtistModalContent'; + +interface EditArtistModalProps { + isOpen: boolean; + artistIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function EditArtistModal(props: EditArtistModalProps) { + const { isOpen, artistIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default EditArtistModal; diff --git a/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.css b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.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/AlbumStudio/AlbumStudioFooter.css.d.ts b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.css.d.ts similarity index 67% rename from frontend/src/AlbumStudio/AlbumStudioFooter.css.d.ts rename to frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.css.d.ts index 83e60938b..cbf2d6328 100644 --- a/frontend/src/AlbumStudio/AlbumStudioFooter.css.d.ts +++ b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.css.d.ts @@ -1,9 +1,8 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'inputContainer': string; - 'label': string; - 'updateSelectedButton': string; + 'modalFooter': string; + 'selected': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.tsx b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.tsx new file mode 100644 index 000000000..993be8ce5 --- /dev/null +++ b/frontend/src/Artist/Index/Select/Edit/EditArtistModalContent.tsx @@ -0,0 +1,264 @@ +import React, { useCallback, useState } from 'react'; +import MoveArtistModal from 'Artist/MoveArtist/MoveArtistModal'; +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 './EditArtistModalContent.css'; + +interface SavePayload { + monitored?: boolean; + monitorNewItems?: string; + qualityProfileId?: number; + metadataProfileId?: number; + rootFolderPath?: string; + moveFiles?: boolean; +} + +interface EditArtistModalContentProps { + artistIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const monitoredOptions = [ + { + key: NO_CHANGE, + get value() { + return translate('NoChange'); + }, + isDisabled: true, + }, + { + key: 'monitored', + get value() { + return translate('Monitored'); + }, + }, + { + key: 'unmonitored', + get value() { + return translate('Unmonitored'); + }, + }, +]; + +function EditArtistModalContent(props: EditArtistModalContentProps) { + const { artistIds, onSavePress, onModalClose } = props; + + const [monitored, setMonitored] = useState(NO_CHANGE); + const [monitorNewItems, setMonitorNewItems] = useState(NO_CHANGE); + const [qualityProfileId, setQualityProfileId] = useState( + NO_CHANGE + ); + const [metadataProfileId, setMetadataProfileId] = useState( + NO_CHANGE + ); + const [rootFolderPath, setRootFolderPath] = useState(NO_CHANGE); + const [isConfirmMoveModalOpen, setIsConfirmMoveModalOpen] = useState(false); + + const save = useCallback( + (moveFiles: boolean) => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (monitored !== NO_CHANGE) { + hasChanges = true; + payload.monitored = monitored === 'monitored'; + } + + if (monitorNewItems !== NO_CHANGE) { + hasChanges = true; + payload.monitorNewItems = monitorNewItems; + } + + if (qualityProfileId !== NO_CHANGE) { + hasChanges = true; + payload.qualityProfileId = qualityProfileId as number; + } + + if (metadataProfileId !== NO_CHANGE) { + hasChanges = true; + payload.metadataProfileId = metadataProfileId as number; + } + + if (rootFolderPath !== NO_CHANGE) { + hasChanges = true; + payload.rootFolderPath = rootFolderPath; + payload.moveFiles = moveFiles; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, + [ + monitored, + monitorNewItems, + qualityProfileId, + metadataProfileId, + rootFolderPath, + onSavePress, + onModalClose, + ] + ); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'monitored': + setMonitored(value); + break; + case 'monitorNewItems': + setMonitorNewItems(value); + break; + case 'qualityProfileId': + setQualityProfileId(value); + break; + case 'metadataProfileId': + setMetadataProfileId(value); + break; + case 'rootFolderPath': + setRootFolderPath(value); + break; + default: + console.warn('EditArtistModalContent Unknown Input'); + } + }, + [setMonitored] + ); + + const onSavePressWrapper = useCallback(() => { + if (rootFolderPath === NO_CHANGE) { + save(false); + } else { + setIsConfirmMoveModalOpen(true); + } + }, [rootFolderPath, save]); + + const onCancelPress = useCallback(() => { + setIsConfirmMoveModalOpen(false); + }, [setIsConfirmMoveModalOpen]); + + const onDoNotMoveArtistPress = useCallback(() => { + setIsConfirmMoveModalOpen(false); + save(false); + }, [setIsConfirmMoveModalOpen, save]); + + const onMoveArtistPress = useCallback(() => { + setIsConfirmMoveModalOpen(false); + save(true); + }, [setIsConfirmMoveModalOpen, save]); + + const selectedCount = artistIds.length; + + return ( + + {translate('EditSelectedArtists')} + + + + {translate('Monitored')} + + + + + + {translate('MonitorNewItems')} + + + + + + {translate('QualityProfile')} + + + + + + {translate('MetadataProfile')} + + + + + + {translate('RootFolder')} + + + + + + +
+ {translate('CountArtistsSelected', { count: selectedCount })} +
+ +
+ + + +
+
+ + +
+ ); +} + +export default EditArtistModalContent; diff --git a/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModal.tsx b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModal.tsx new file mode 100644 index 000000000..bec35222b --- /dev/null +++ b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import OrganizeArtistModalContent from './OrganizeArtistModalContent'; + +interface OrganizeArtistModalProps { + isOpen: boolean; + artistIds: number[]; + onModalClose: () => void; +} + +function OrganizeArtistModal(props: OrganizeArtistModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default OrganizeArtistModal; diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.css b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.css similarity index 100% rename from frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.css rename to frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.css diff --git a/frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.css.d.ts b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.css.d.ts similarity index 100% rename from frontend/src/Artist/Editor/Organize/OrganizeArtistModalContent.css.d.ts rename to frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.css.d.ts diff --git a/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.tsx b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.tsx new file mode 100644 index 000000000..8184abba7 --- /dev/null +++ b/frontend/src/Artist/Index/Select/Organize/OrganizeArtistModalContent.tsx @@ -0,0 +1,91 @@ +import { orderBy } from 'lodash'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Artist from 'Artist/Artist'; +import { RENAME_ARTIST } from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import Icon from 'Components/Icon'; +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 { icons, kinds } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import translate from 'Utilities/String/translate'; +import styles from './OrganizeArtistModalContent.css'; + +interface OrganizeArtistModalContentProps { + artistIds: number[]; + onModalClose: () => void; +} + +function OrganizeArtistModalContent(props: OrganizeArtistModalContentProps) { + const { artistIds, onModalClose } = props; + + const allArtists: Artist[] = useSelector(createAllArtistSelector()); + const dispatch = useDispatch(); + + const artistNames = useMemo(() => { + 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']); + + return sorted.map((a) => a.artistName); + }, [artistIds, allArtists]); + + const onOrganizePress = useCallback(() => { + dispatch( + executeCommand({ + name: RENAME_ARTIST, + artistIds, + }) + ); + + onModalClose(); + }, [artistIds, onModalClose, dispatch]); + + return ( + + {translate('OrganizeSelectedArtists')} + + + + Tip: To preview a rename, select "Cancel", then select any artist name + and use the + + + +
+ Are you sure you want to organize all files in the{' '} + {artistNames.length} selected artist? +
+ +
    + {artistNames.map((artistName) => { + return
  • {artistName}
  • ; + })} +
+
+ + + + + + +
+ ); +} + +export default OrganizeArtistModalContent; diff --git a/frontend/src/Artist/Index/Select/Tags/TagsModal.tsx b/frontend/src/Artist/Index/Select/Tags/TagsModal.tsx new file mode 100644 index 000000000..8635867e4 --- /dev/null +++ b/frontend/src/Artist/Index/Select/Tags/TagsModal.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContent from './TagsModalContent'; + +interface TagsModalProps { + isOpen: boolean; + artistIds: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModal(props: TagsModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default TagsModal; diff --git a/frontend/src/Artist/Editor/Tags/TagsModalContent.css b/frontend/src/Artist/Index/Select/Tags/TagsModalContent.css similarity index 100% rename from frontend/src/Artist/Editor/Tags/TagsModalContent.css rename to frontend/src/Artist/Index/Select/Tags/TagsModalContent.css diff --git a/frontend/src/Artist/Editor/Tags/TagsModalContent.css.d.ts b/frontend/src/Artist/Index/Select/Tags/TagsModalContent.css.d.ts similarity index 100% rename from frontend/src/Artist/Editor/Tags/TagsModalContent.css.d.ts rename to frontend/src/Artist/Index/Select/Tags/TagsModalContent.css.d.ts diff --git a/frontend/src/Artist/Index/Select/Tags/TagsModalContent.tsx b/frontend/src/Artist/Index/Select/Tags/TagsModalContent.tsx new file mode 100644 index 000000000..95a7eaae2 --- /dev/null +++ b/frontend/src/Artist/Index/Select/Tags/TagsModalContent.tsx @@ -0,0 +1,189 @@ +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'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Label from 'Components/Label'; +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, sizes } from 'Helpers/Props'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import translate from 'Utilities/String/translate'; +import styles from './TagsModalContent.css'; + +interface TagsModalContentProps { + artistIds: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModalContent(props: TagsModalContentProps) { + const { artistIds, onModalClose, onApplyTagsPress } = props; + + const allArtists: Artist[] = useSelector(createAllArtistSelector()); + const tagList: Tag[] = useSelector(createTagsSelector()); + + const [tags, setTags] = useState([]); + const [applyTags, setApplyTags] = useState('add'); + + const artistTags = useMemo(() => { + const tags = artistIds.reduce((acc: number[], id) => { + const a = allArtists.find((a) => a.id === id); + + if (a) { + acc.push(...a.tags); + } + + return acc; + }, []); + + return uniq(tags); + }, [artistIds, allArtists]); + + const onTagsChange = useCallback( + ({ value }: { value: number[] }) => { + setTags(value); + }, + [setTags] + ); + + const onApplyTagsChange = useCallback( + ({ value }: { value: string }) => { + setApplyTags(value); + }, + [setApplyTags] + ); + + const onApplyPress = useCallback(() => { + onApplyTagsPress(tags, applyTags); + }, [tags, applyTags, onApplyTagsPress]); + + const applyTagsOptions = [ + { + key: 'add', + value: translate('Add'), + }, + { + key: 'remove', + value: translate('Remove'), + }, + { + key: 'replace', + value: translate('Replace'), + }, + ]; + + return ( + + {translate('Tags')} + + +
+ + {translate('Tags')} + + + + + + {translate('ApplyTags')} + + + + + + {translate('Result')} + +
+ {artistTags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + const removeTag = + (applyTags === 'remove' && tags.indexOf(id) > -1) || + (applyTags === 'replace' && tags.indexOf(id) === -1); + + return ( + + ); + })} + + {(applyTags === 'add' || applyTags === 'replace') && + tags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + if (artistTags.indexOf(id) > -1) { + return null; + } + + return ( + + ); + })} +
+
+
+
+ + + + + + +
+ ); +} + +export default TagsModalContent; diff --git a/frontend/src/Artist/Index/Table/AlbumsCell.css b/frontend/src/Artist/Index/Table/AlbumsCell.css new file mode 100644 index 000000000..307c5d406 --- /dev/null +++ b/frontend/src/Artist/Index/Table/AlbumsCell.css @@ -0,0 +1,4 @@ +.albumCount { + width: 100%; + cursor: default; +} diff --git a/frontend/src/AlbumStudio/AlbumStudioTableHeader.css.d.ts b/frontend/src/Artist/Index/Table/AlbumsCell.css.d.ts similarity index 81% rename from frontend/src/AlbumStudio/AlbumStudioTableHeader.css.d.ts rename to frontend/src/Artist/Index/Table/AlbumsCell.css.d.ts index 3978a6c39..93d667287 100644 --- a/frontend/src/AlbumStudio/AlbumStudioTableHeader.css.d.ts +++ b/frontend/src/Artist/Index/Table/AlbumsCell.css.d.ts @@ -2,8 +2,6 @@ // Please do not change this file! interface CssExports { 'albumCount': string; - 'sortName': string; - 'status': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Artist/Index/Table/AlbumsCell.tsx b/frontend/src/Artist/Index/Table/AlbumsCell.tsx new file mode 100644 index 000000000..b42298b5d --- /dev/null +++ b/frontend/src/Artist/Index/Table/AlbumsCell.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import AlbumDetails from 'Artist/Index/Select/AlbumStudio/AlbumDetails'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import TooltipPosition from 'Helpers/Props/TooltipPosition'; +import translate from 'Utilities/String/translate'; +import styles from './AlbumsCell.css'; + +interface SeriesStatusCellProps { + className: string; + artistId: number; + albumCount: number; + isSelectMode: boolean; +} + +function AlbumsCell(props: SeriesStatusCellProps) { + const { className, artistId, albumCount, isSelectMode, ...otherProps } = + props; + + return ( + + {isSelectMode && albumCount > 0 ? ( + } + position={TooltipPosition.Left} + canFlip={true} + /> + ) : ( + albumCount + )} + + ); +} + +export default AlbumsCell; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js b/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js deleted file mode 100644 index a2a3c8dab..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js +++ /dev/null @@ -1,103 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import IconButton from 'Components/Link/IconButton'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import { icons } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -class ArtistIndexActionsCell extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - // - // Listeners - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - isRefreshingArtist, - onRefreshArtistPress, - ...otherProps - } = this.props; - - const { - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - return ( - - - - - - - - - - ); - } -} - -ArtistIndexActionsCell.propTypes = { - id: PropTypes.number.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired -}; - -export default ArtistIndexActionsCell; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.js b/frontend/src/Artist/Index/Table/ArtistIndexHeader.js deleted file mode 100644 index 7054bbaf3..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexHeader.js +++ /dev/null @@ -1,86 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import IconButton from 'Components/Link/IconButton'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; -import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; -import { icons } from 'Helpers/Props'; -import ArtistIndexTableOptionsConnector from './ArtistIndexTableOptionsConnector'; -import hasGrowableColumns from './hasGrowableColumns'; -import styles from './ArtistIndexHeader.css'; - -function ArtistIndexHeader(props) { - const { - showBanners, - columns, - onTableOptionChange, - ...otherProps - } = props; - - return ( - - { - columns.map((column) => { - const { - name, - label, - isSortable, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'actions') { - return ( - - - - - - - ); - } - - return ( - - {typeof label === 'function' ? label() : label} - - ); - }) - } - - ); -} - -ArtistIndexHeader.propTypes = { - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onTableOptionChange: PropTypes.func.isRequired, - showBanners: PropTypes.bool.isRequired -}; - -export default ArtistIndexHeader; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js deleted file mode 100644 index 37ddd9ef3..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js +++ /dev/null @@ -1,13 +0,0 @@ -import { connect } from 'react-redux'; -import { setArtistTableOption } from 'Store/Actions/artistIndexActions'; -import ArtistIndexHeader from './ArtistIndexHeader'; - -function createMapDispatchToProps(dispatch, props) { - return { - onTableOptionChange(payload) { - dispatch(setArtistTableOption(payload)); - } - }; -} - -export default connect(undefined, createMapDispatchToProps)(ArtistIndexHeader); diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.css b/frontend/src/Artist/Index/Table/ArtistIndexRow.css 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.js b/frontend/src/Artist/Index/Table/ArtistIndexRow.js deleted file mode 100644 index 0dc5585ca..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexRow.js +++ /dev/null @@ -1,487 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import AlbumTitleLink from 'Album/AlbumTitleLink'; -import ArtistBanner from 'Artist/ArtistBanner'; -import ArtistNameLink from 'Artist/ArtistNameLink'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -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 TagListConnector from 'Components/TagListConnector'; -import { icons } from 'Helpers/Props'; -import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; -import formatBytes from 'Utilities/Number/formatBytes'; -import translate from 'Utilities/String/translate'; -import ArtistStatusCell from './ArtistStatusCell'; -import hasGrowableColumns from './hasGrowableColumns'; -import styles from './ArtistIndexRow.css'; - -class ArtistIndexRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - hasBannerError: false, - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - onUseSceneNumberingChange = () => { - // Mock handler to satisfy `onChange` being required for `CheckInput`. - // - }; - - onBannerLoad = () => { - if (this.state.hasBannerError) { - this.setState({ hasBannerError: false }); - } - }; - - onBannerLoadError = () => { - if (!this.state.hasBannerError) { - this.setState({ hasBannerError: true }); - } - }; - - // - // Render - - render() { - const { - id, - monitored, - status, - artistName, - foreignArtistId, - artistType, - qualityProfile, - metadataProfile, - nextAlbum, - lastAlbum, - added, - statistics, - genres, - ratings, - path, - tags, - images, - isSaving, - showBanners, - showSearchAction, - columns, - isRefreshingArtist, - isSearchingArtist, - onRefreshArtistPress, - onSearchPress, - onMonitoredPress - } = this.props; - - const { - albumCount, - trackCount, - trackFileCount, - totalTrackCount, - sizeOnDisk - } = statistics; - - const { - hasBannerError, - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - return ( - <> - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'status') { - return ( - - ); - } - - if (name === 'sortName') { - return ( - - { - showBanners ? - - - - { - hasBannerError && -
- {artistName} -
- } - : - - - } -
- ); - } - - if (name === 'artistType') { - return ( - - {artistType} - - ); - } - - if (name === 'qualityProfileId') { - return ( - - {qualityProfile.name} - - ); - } - - if (name === 'metadataProfileId') { - return ( - - {metadataProfile.name} - - ); - } - - if (name === 'nextAlbum') { - if (nextAlbum) { - return ( - - - - ); - } - return ( - - None - - ); - } - - if (name === 'lastAlbum') { - if (lastAlbum) { - return ( - - - - ); - } - return ( - - None - - ); - } - - if (name === 'added') { - return ( - - ); - } - - if (name === 'albumCount') { - return ( - - {albumCount} - - ); - } - - if (name === 'trackProgress') { - const progress = trackCount ? trackFileCount / trackCount * 100 : 100; - - return ( - - - - ); - } - - if (name === 'trackCount') { - return ( - - {totalTrackCount} - - ); - } - - if (name === 'path') { - return ( - - {path} - - ); - } - - if (name === 'sizeOnDisk') { - return ( - - {formatBytes(sizeOnDisk)} - - ); - } - - if (name === 'genres') { - const joinedGenres = genres.join(', '); - - return ( - - - {joinedGenres} - - - ); - } - - if (name === 'ratings') { - return ( - - - - ); - } - - if (name === 'tags') { - return ( - - - - ); - } - - if (name === 'actions') { - return ( - - - - { - showSearchAction && - - } - - - - ); - } - - return null; - }) - } - - - - - - ); - } -} - -ArtistIndexRow.propTypes = { - id: PropTypes.number.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - artistName: PropTypes.string.isRequired, - foreignArtistId: PropTypes.string.isRequired, - artistType: PropTypes.string, - qualityProfile: PropTypes.object.isRequired, - metadataProfile: PropTypes.object.isRequired, - nextAlbum: PropTypes.object, - lastAlbum: PropTypes.object, - added: PropTypes.string, - statistics: PropTypes.object.isRequired, - latestAlbum: PropTypes.object, - path: PropTypes.string.isRequired, - genres: PropTypes.arrayOf(PropTypes.string).isRequired, - ratings: PropTypes.object.isRequired, - tags: PropTypes.arrayOf(PropTypes.number).isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - isSaving: PropTypes.bool.isRequired, - showBanners: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isSearchingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired, - onMonitoredPress: PropTypes.func.isRequired -}; - -ArtistIndexRow.defaultProps = { - statistics: { - albumCount: 0, - trackCount: 0, - trackFileCount: 0, - totalTrackCount: 0 - }, - genres: [], - tags: [] -}; - -export default ArtistIndexRow; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.tsx b/frontend/src/Artist/Index/Table/ArtistIndexRow.tsx new file mode 100644 index 000000000..0398f5502 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.tsx @@ -0,0 +1,422 @@ +import classNames from 'classnames'; +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AlbumTitleLink from 'Album/AlbumTitleLink'; +import { useSelect } from 'App/SelectContext'; +import { Statistics } from 'Artist/Artist'; +import ArtistBanner from 'Artist/ArtistBanner'; +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 RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; +import Column from 'Components/Table/Column'; +import TagListConnector from 'Components/TagListConnector'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +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'; +import selectTableOptions from './selectTableOptions'; +import styles from './ArtistIndexRow.css'; + +interface ArtistIndexRowProps { + artistId: number; + sortKey: string; + columns: Column[]; + isSelectMode: boolean; +} + +function ArtistIndexRow(props: ArtistIndexRowProps) { + const { artistId, columns, isSelectMode } = props; + + const { + artist, + qualityProfile, + metadataProfile, + isRefreshingArtist, + isSearchingArtist, + } = useSelector(createArtistIndexItemSelector(props.artistId)); + + const { showBanners, showSearchAction } = useSelector(selectTableOptions); + + const { + artistName, + foreignArtistId, + monitored, + status, + path, + monitorNewItems, + nextAlbum, + lastAlbum, + added, + statistics = {} as Statistics, + images, + artistType, + genres = [], + ratings, + tags = [], + isSaving = false, + } = artist; + + const { + albumCount = 0, + trackCount = 0, + trackFileCount = 0, + totalTrackCount = 0, + sizeOnDisk = 0, + } = statistics; + + const dispatch = useDispatch(); + const [hasBannerError, setHasBannerError] = useState(false); + const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false); + const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false); + const [selectState, selectDispatch] = useSelect(); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: ARTIST_SEARCH, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onBannerLoadError = useCallback(() => { + setHasBannerError(true); + }, [setHasBannerError]); + + const onBannerLoad = useCallback(() => { + setHasBannerError(false); + }, [setHasBannerError]); + + const onEditArtistPress = useCallback(() => { + setIsEditArtistModalOpen(true); + }, [setIsEditArtistModalOpen]); + + const onEditArtistModalClose = useCallback(() => { + setIsEditArtistModalOpen(false); + }, [setIsEditArtistModalOpen]); + + const onDeleteArtistPress = useCallback(() => { + setIsEditArtistModalOpen(false); + setIsDeleteArtistModalOpen(true); + }, [setIsDeleteArtistModalOpen]); + + const onDeleteArtistModalClose = useCallback(() => { + setIsDeleteArtistModalOpen(false); + }, [setIsDeleteArtistModalOpen]); + + const onSelectedChange = useCallback( + ({ id, value, shiftKey }: SelectStateInputProps) => { + selectDispatch({ + type: 'toggleSelected', + id, + isSelected: value, + shiftKey, + }); + }, + [selectDispatch] + ); + + return ( + <> + {isSelectMode ? ( + + ) : null} + + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'sortName') { + return ( + + {showBanners ? ( + + + + {hasBannerError && ( +
{artistName}
+ )} + + ) : ( + + )} +
+ ); + } + + if (name === 'artistType') { + return ( + + {artistType} + + ); + } + + if (name === 'qualityProfileId') { + return ( + + {qualityProfile?.name ?? ''} + + ); + } + + if (name === 'metadataProfileId') { + return ( + + {metadataProfile?.name ?? ''} + + ); + } + + if (name === 'monitorNewItems') { + return ( + + {translate(firstCharToUpper(monitorNewItems))} + + ); + } + + if (name === 'nextAlbum') { + if (nextAlbum) { + return ( + + + + ); + } + return ( + + {translate('None')} + + ); + } + + if (name === 'lastAlbum') { + if (lastAlbum) { + return ( + + + + ); + } + return ( + + {translate('None')} + + ); + } + + if (name === 'added') { + return ( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore ts(2739) + + ); + } + + if (name === 'albumCount') { + return ( + + ); + } + + if (name === 'trackProgress') { + return ( + + + + ); + } + + if (name === 'trackCount') { + return ( + + {totalTrackCount} + + ); + } + + if (name === 'path') { + return ( + + {path} + + ); + } + + if (name === 'sizeOnDisk') { + return ( + + {formatBytes(sizeOnDisk)} + + ); + } + + if (name === 'genres') { + const joinedGenres = genres.join(', '); + + return ( + + {joinedGenres} + + ); + } + + if (name === 'ratings') { + return ( + + + + ); + } + + if (name === 'tags') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + + + {showSearchAction ? ( + + ) : null} + + + + ); + } + + return null; + })} + + + + + + ); +} + +export default ArtistIndexRow; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.css b/frontend/src/Artist/Index/Table/ArtistIndexTable.css index 23ab127b5..0bfc5fec4 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTable.css +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.css @@ -1,5 +1,11 @@ -.tableContainer { - composes: tableContainer from '~Components/Table/VirtualTable.css'; - - flex: 1 0 auto; +.tableScroller { + position: relative; +} + +.row { + transition: background-color 500ms; + + &:hover { + background-color: var(--tableRowHoverBackgroundColor); + } } diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.css.d.ts b/frontend/src/Artist/Index/Table/ArtistIndexTable.css.d.ts index fbc2e3b9a..ff35c263f 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTable.css.d.ts +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.css.d.ts @@ -1,7 +1,8 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'tableContainer': string; + 'row': string; + 'tableScroller': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.js b/frontend/src/Artist/Index/Table/ArtistIndexTable.js deleted file mode 100644 index 00f6a80d1..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexTable.js +++ /dev/null @@ -1,134 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; -import VirtualTable from 'Components/Table/VirtualTable'; -import VirtualTableRow from 'Components/Table/VirtualTableRow'; -import { sortDirections } from 'Helpers/Props'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import ArtistIndexHeaderConnector from './ArtistIndexHeaderConnector'; -import ArtistIndexRow from './ArtistIndexRow'; -import styles from './ArtistIndexTable.css'; - -class ArtistIndexTable extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - scrollIndex: null - }; - } - - componentDidUpdate(prevProps) { - const { - items, - jumpToCharacter - } = this.props; - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - - const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (scrollIndex != null) { - this.setState({ scrollIndex }); - } - } else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) { - this.setState({ scrollIndex: null }); - } - } - - // - // Control - - rowRenderer = ({ key, rowIndex, style }) => { - const { - items, - columns, - showBanners, - isSaving - } = this.props; - - const artist = items[rowIndex]; - - return ( - - - - ); - }; - - // - // Render - - render() { - const { - items, - columns, - sortKey, - sortDirection, - showBanners, - isSmallScreen, - onSortPress, - scroller, - scrollTop - } = this.props; - - return ( - - } - columns={columns} - sortKey={sortKey} - sortDirection={sortDirection} - /> - ); - } -} - -ArtistIndexTable.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - sortDirection: PropTypes.oneOf(sortDirections.all), - showBanners: PropTypes.bool.isRequired, - isSaving: PropTypes.bool.isRequired, - jumpToCharacter: PropTypes.string, - scrollTop: PropTypes.number, - scroller: PropTypes.instanceOf(Element).isRequired, - isSmallScreen: PropTypes.bool.isRequired, - onSortPress: PropTypes.func.isRequired -}; - -export default ArtistIndexTable; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.tsx b/frontend/src/Artist/Index/Table/ArtistIndexTable.tsx new file mode 100644 index 000000000..c3c8044ce --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.tsx @@ -0,0 +1,215 @@ +import { throttle } from 'lodash'; +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'; +import Scroller from 'Components/Scroller/Scroller'; +import Column from 'Components/Table/Column'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import ScrollDirection from 'Helpers/Props/ScrollDirection'; +import SortDirection from 'Helpers/Props/SortDirection'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import selectTableOptions from './selectTableOptions'; +import styles from './ArtistIndexTable.css'; + +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); + +interface RowItemData { + items: Artist[]; + sortKey: string; + columns: Column[]; + isSelectMode: boolean; +} + +interface ArtistIndexTableProps { + items: Artist[]; + sortKey: string; + sortDirection?: SortDirection; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: RefObject; + isSelectMode: boolean; + isSmallScreen: boolean; +} + +const columnsSelector = createSelector( + (state: AppState) => state.artistIndex.columns, + (columns) => columns +); + +const Row: React.FC> = ({ + index, + style, + data, +}) => { + const { items, sortKey, columns, isSelectMode } = data; + + if (index >= items.length) { + return null; + } + + const artist = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +function ArtistIndexTable(props: ArtistIndexTableProps) { + const { + items, + sortKey, + sortDirection, + jumpToCharacter, + isSelectMode, + isSmallScreen, + scrollerRef, + } = props; + + const columns = useSelector(columnsSelector); + const { showBanners } = useSelector(selectTableOptions); + const listRef = useRef>(null); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + const rowHeight = useMemo(() => { + return showBanners ? 70 : 38; + }, [showBanners]); + + useEffect(() => { + const current = scrollerRef?.current as HTMLElement; + + if (isSmallScreen) { + setSize({ + width: windowWidth, + height: windowHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = + (isSmallScreen ? bodyPaddingSmallScreen : bodyPadding) - 5; + + setSize({ + width: width - padding * 2, + height: windowHeight, + }); + } + }, [isSmallScreen, windowWidth, windowHeight, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollerRef = scrollerRef.current as HTMLElement; + const currentScrollListener = isSmallScreen ? window : currentScrollerRef; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + listRef.current?.scrollTo(scrollTop); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, listRef, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + let scrollTop = index * rowHeight; + + // If the offset is zero go to the top, otherwise offset + // by the approximate size of the header + padding (37 + 20). + if (scrollTop > 0) { + const offset = 57; + + scrollTop += offset; + } + + listRef.current?.scrollTo(scrollTop); + scrollerRef?.current?.scrollTo(0, scrollTop); + } + } + }, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]); + + return ( +
+ + + + ref={listRef} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + itemCount={items.length} + itemSize={rowHeight} + itemData={{ + items, + sortKey, + columns, + isSelectMode, + }} + > + {Row} + + +
+ ); +} + +export default ArtistIndexTable; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js deleted file mode 100644 index 3a97425cc..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js +++ /dev/null @@ -1,29 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setArtistSort } from 'Store/Actions/artistIndexActions'; -import ArtistIndexTable from './ArtistIndexTable'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app.dimensions, - (state) => state.artistIndex.tableOptions, - (state) => state.artistIndex.columns, - (dimensions, tableOptions, columns) => { - return { - isSmallScreen: dimensions.isSmallScreen, - showBanners: tableOptions.showBanners, - columns - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onSortPress(sortKey) { - dispatch(setArtistSort({ sortKey })); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexTable); diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css similarity index 98% rename from frontend/src/Artist/Index/Table/ArtistIndexHeader.css rename to frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css index 6da0be920..7ea4e94aa 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexHeader.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/ArtistIndexHeader.css.d.ts b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css.d.ts similarity index 95% rename from frontend/src/Artist/Index/Table/ArtistIndexHeader.css.d.ts rename to frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css.d.ts index 4d9dcd20b..467b401bb 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexHeader.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 new file mode 100644 index 000000000..1b325c225 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.tsx @@ -0,0 +1,121 @@ +import classNames from 'classnames'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelect } from 'App/SelectContext'; +import ArtistIndexTableOptions from 'Artist/Index/Table/ArtistIndexTableOptions'; +import IconButton from 'Components/Link/IconButton'; +import Column from 'Components/Table/Column'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell'; +import { icons } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; +import { + setArtistSort, + setArtistTableOption, +} from 'Store/Actions/artistIndexActions'; +import { CheckInputChanged } from 'typings/inputs'; +import hasGrowableColumns from './hasGrowableColumns'; +import styles from './ArtistIndexTableHeader.css'; + +interface ArtistIndexTableHeaderProps { + showBanners: boolean; + columns: Column[]; + sortKey?: string; + sortDirection?: SortDirection; + isSelectMode: boolean; +} + +function ArtistIndexTableHeader(props: ArtistIndexTableHeaderProps) { + const { showBanners, columns, sortKey, sortDirection, isSelectMode } = props; + const dispatch = useDispatch(); + const [selectState, selectDispatch] = useSelect(); + + const onSortPress = useCallback( + (value: string) => { + dispatch(setArtistSort({ sortKey: value })); + }, + [dispatch] + ); + + const onTableOptionChange = useCallback( + (payload: unknown) => { + dispatch(setArtistTableOption(payload)); + }, + [dispatch] + ); + + const onSelectAllChange = useCallback( + ({ value }: CheckInputChanged) => { + selectDispatch({ + type: value ? 'selectAll' : 'unselectAll', + }); + }, + [selectDispatch] + ); + + return ( + + {isSelectMode ? ( + + ) : null} + + {columns.map((column) => { + const { name, label, isSortable, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'actions') { + return ( + + + + + + ); + } + + return ( + + {typeof label === 'function' ? label() : label} + + ); + })} + + ); +} + +export default ArtistIndexTableHeader; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js deleted file mode 100644 index 6fd619ad0..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js +++ /dev/null @@ -1,105 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import { inputTypes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -class ArtistIndexTableOptions extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - showBanners: props.showBanners, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - showBanners, - showSearchAction - } = this.props; - - if ( - showBanners !== prevProps.showBanners || - showSearchAction !== prevProps.showSearchAction - ) { - this.setState({ - showBanners, - showSearchAction - }); - } - } - - // - // Listeners - - onTableOptionChange = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onTableOptionChange({ - tableOptions: { - ...this.state, - [name]: value - } - }); - }); - }; - - // - // Render - - render() { - const { - showBanners, - showSearchAction - } = this.state; - - return ( - - - - {translate('ShowBanners')} - - - - - - - - {translate('ShowSearch')} - - - - - - ); - } -} - -ArtistIndexTableOptions.propTypes = { - showBanners: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onTableOptionChange: PropTypes.func.isRequired -}; - -export default ArtistIndexTableOptions; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.tsx b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.tsx new file mode 100644 index 000000000..9c10d859d --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.tsx @@ -0,0 +1,63 @@ +import React, { Fragment, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import { inputTypes } from 'Helpers/Props'; +import { CheckInputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; +import selectTableOptions from './selectTableOptions'; + +interface ArtistIndexTableOptionsProps { + onTableOptionChange(...args: unknown[]): unknown; +} + +function ArtistIndexTableOptions(props: ArtistIndexTableOptionsProps) { + const { onTableOptionChange } = props; + + const tableOptions = useSelector(selectTableOptions); + + const { showBanners, showSearchAction } = tableOptions; + + const onTableOptionChangeWrapper = useCallback( + ({ name, value }: CheckInputChanged) => { + onTableOptionChange({ + tableOptions: { + ...tableOptions, + [name]: value, + }, + }); + }, + [tableOptions, onTableOptionChange] + ); + + return ( + + + {translate('ShowBanners')} + + + + + + {translate('ShowSearch')} + + + + + ); +} + +export default ArtistIndexTableOptions; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js deleted file mode 100644 index 0a1607cf2..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js +++ /dev/null @@ -1,14 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import ArtistIndexTableOptions from './ArtistIndexTableOptions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex.tableOptions, - (tableOptions) => { - return tableOptions; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexTableOptions); diff --git a/frontend/src/Artist/Index/Table/ArtistStatusCell.js b/frontend/src/Artist/Index/Table/ArtistStatusCell.js deleted file mode 100644 index 1f163a473..000000000 --- a/frontend/src/Artist/Index/Table/ArtistStatusCell.js +++ /dev/null @@ -1,61 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import MonitorToggleButton from 'Components/MonitorToggleButton'; -import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; -import { icons } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import styles from './ArtistStatusCell.css'; - -function ArtistStatusCell(props) { - const { - className, - artistType, - monitored, - status, - isSaving, - onMonitoredPress, - component: Component, - ...otherProps - } = props; - - const endedString = artistType === 'Person' ? 'Deceased' : 'Inactive'; - - return ( - - - - - - ); -} - -ArtistStatusCell.propTypes = { - className: PropTypes.string.isRequired, - artistType: PropTypes.string, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - isSaving: PropTypes.bool.isRequired, - onMonitoredPress: PropTypes.func.isRequired, - component: PropTypes.elementType -}; - -ArtistStatusCell.defaultProps = { - className: styles.status, - component: VirtualTableRowCell -}; - -export default ArtistStatusCell; diff --git a/frontend/src/Artist/Index/Table/ArtistStatusCell.tsx b/frontend/src/Artist/Index/Table/ArtistStatusCell.tsx new file mode 100644 index 000000000..00c7ae4c8 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistStatusCell.tsx @@ -0,0 +1,75 @@ +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import Icon from 'Components/Icon'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; +import { icons } from 'Helpers/Props'; +import { toggleArtistMonitored } from 'Store/Actions/artistActions'; +import translate from 'Utilities/String/translate'; +import styles from './ArtistStatusCell.css'; + +interface ArtistStatusCellProps { + className: string; + artistId: number; + artistType?: string; + monitored: boolean; + status: string; + isSelectMode: boolean; + isSaving: boolean; + component?: React.ElementType; +} + +function ArtistStatusCell(props: ArtistStatusCellProps) { + const { + className, + artistId, + artistType, + monitored, + status, + isSelectMode, + isSaving, + component: Component = VirtualTableRowCell, + ...otherProps + } = props; + + const endedString = + artistType === 'Person' ? translate('Deceased') : translate('Inactive'); + const dispatch = useDispatch(); + + const onMonitoredPress = useCallback(() => { + dispatch(toggleArtistMonitored({ artistId, monitored: !monitored })); + }, [artistId, monitored, dispatch]); + + return ( + + {isSelectMode ? ( + + ) : ( + + )} + + + + ); +} + +export default ArtistStatusCell; diff --git a/frontend/src/Artist/Index/Table/hasGrowableColumns.js b/frontend/src/Artist/Index/Table/hasGrowableColumns.js deleted file mode 100644 index 994436d9f..000000000 --- a/frontend/src/Artist/Index/Table/hasGrowableColumns.js +++ /dev/null @@ -1,16 +0,0 @@ -const growableColumns = [ - 'qualityProfileId', - 'path', - 'tags' -]; - -export default function hasGrowableColumns(columns) { - return columns.some((column) => { - const { - name, - isVisible - } = column; - - return growableColumns.includes(name) && isVisible; - }); -} diff --git a/frontend/src/Artist/Index/Table/hasGrowableColumns.ts b/frontend/src/Artist/Index/Table/hasGrowableColumns.ts new file mode 100644 index 000000000..ed0cc6c58 --- /dev/null +++ b/frontend/src/Artist/Index/Table/hasGrowableColumns.ts @@ -0,0 +1,11 @@ +import Column from 'Components/Table/Column'; + +const growableColumns = ['qualityProfileId', 'path', 'tags']; + +export default function hasGrowableColumns(columns: Column[]) { + return columns.some((column) => { + const { name, isVisible } = column; + + return growableColumns.includes(name) && isVisible; + }); +} diff --git a/frontend/src/Artist/Index/Table/selectTableOptions.ts b/frontend/src/Artist/Index/Table/selectTableOptions.ts new file mode 100644 index 000000000..b6a2a6a94 --- /dev/null +++ b/frontend/src/Artist/Index/Table/selectTableOptions.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const selectTableOptions = createSelector( + (state: AppState) => state.artistIndex.tableOptions, + (tableOptions) => tableOptions +); + +export default selectTableOptions; diff --git a/frontend/src/Artist/Index/createArtistIndexItemSelector.ts b/frontend/src/Artist/Index/createArtistIndexItemSelector.ts new file mode 100644 index 000000000..4388a3aeb --- /dev/null +++ b/frontend/src/Artist/Index/createArtistIndexItemSelector.ts @@ -0,0 +1,45 @@ +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'; +import { createArtistSelectorForHook } from 'Store/Selectors/createArtistSelector'; +import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; + +function createArtistIndexItemSelector(artistId: number) { + return createSelector( + createArtistSelectorForHook(artistId), + createArtistQualityProfileSelector(artistId), + createArtistMetadataProfileSelector(artistId), + createExecutingCommandsSelector(), + ( + artist: Artist, + qualityProfile, + metadataProfile, + executingCommands: Command[] + ) => { + const isRefreshingArtist = executingCommands.some((command) => { + return ( + command.name === REFRESH_ARTIST && command.body.artistId === artistId + ); + }); + + const isSearchingArtist = executingCommands.some((command) => { + return ( + command.name === ARTIST_SEARCH && command.body.artistId === artistId + ); + }); + + return { + artist, + qualityProfile, + metadataProfile, + isRefreshingArtist, + isSearchingArtist, + }; + } + ); +} + +export default createArtistIndexItemSelector; 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/MonitoringOptions/MonitoringOptionModalConnector.js b/frontend/src/Artist/MonitoringOptions/MonitoringOptionModalConnector.js new file mode 100644 index 000000000..6ea9e8290 --- /dev/null +++ b/frontend/src/Artist/MonitoringOptions/MonitoringOptionModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import MonitoringOptionsModal from './MonitoringOptionsModal'; + +const mapDispatchToProps = { + clearPendingChanges +}; + +class MonitoringOptionsModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'artist' }); + this.props.onModalClose(); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +MonitoringOptionsModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(MonitoringOptionsModalConnector); diff --git a/frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModal.js similarity index 55% rename from frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js rename to frontend/src/Artist/MonitoringOptions/MonitoringOptionsModal.js index 636ca6618..4071e7f9a 100644 --- a/frontend/src/Artist/Editor/AudioTags/RetagArtistModal.js +++ b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModal.js @@ -1,21 +1,15 @@ import PropTypes from 'prop-types'; import React from 'react'; import Modal from 'Components/Modal/Modal'; -import RetagArtistModalContentConnector from './RetagArtistModalContentConnector'; - -function RetagArtistModal(props) { - const { - isOpen, - onModalClose, - ...otherProps - } = props; +import MonitoringOptionsModalContentConnector from './MonitoringOptionsModalContentConnector'; +function MonitoringOptionsModal({ isOpen, onModalClose, ...otherProps }) { return ( - @@ -23,9 +17,9 @@ function RetagArtistModal(props) { ); } -RetagArtistModal.propTypes = { +MonitoringOptionsModal.propTypes = { isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; -export default RetagArtistModal; +export default MonitoringOptionsModal; diff --git a/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css new file mode 100644 index 000000000..3e9e3ffd0 --- /dev/null +++ b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css @@ -0,0 +1,9 @@ +.labelIcon { + margin-left: 8px; +} + +.message { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 30px; +} diff --git a/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css.d.ts b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css.d.ts new file mode 100644 index 000000000..af0f6cd46 --- /dev/null +++ b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'labelIcon': string; + 'message': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.js b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.js index 230b22f2a..b1550d39e 100644 --- a/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.js +++ b/frontend/src/Artist/MonitoringOptions/MonitoringOptionsModalContent.js @@ -1,18 +1,22 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent'; import Alert from 'Components/Alert'; 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 Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; 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 } from 'Helpers/Props'; +import Popover from 'Components/Tooltip/Popover'; +import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; +import styles from './MonitoringOptionsModalContent.css'; const NO_CHANGE = 'noChange'; @@ -51,8 +55,7 @@ class MonitoringOptionsModalContent extends Component { onSavePress = () => { const { - onSavePress, - isSaving + onSavePress } = this.props; const { monitor @@ -61,14 +64,6 @@ class MonitoringOptionsModalContent extends Component { if (monitor !== NO_CHANGE) { onSavePress({ monitor }); } - - if (!isSaving) { - this.onModalClose(); - } - }; - - onModalClose = () => { - this.props.onModalClose(); }; // @@ -89,19 +84,31 @@ class MonitoringOptionsModalContent extends Component { return ( - {translate('MonitorAlbum')} + {translate('MonitorArtist')} - -
- {translate('MonitorAlbumExistingOnlyWarning')} -
+ + {translate('MonitorAlbumExistingOnlyWarning')}
- {translate('Monitoring')} + + {translate('MonitorExistingAlbums')} + + + } + title={translate('MonitoringOptions')} + body={} + position={tooltipPositions.RIGHT} + /> + state.artist, + (artistState) => { + const { + isSaving, + saveError + } = artistState; + + return { + isSaving, + saveError + }; + } + ); +} + +const mapDispatchToProps = { + dispatchUpdateMonitoringOptions: updateArtistsMonitor +}; + +class MonitoringOptionsModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(true); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ name, value }); + }; + + onSavePress = ({ monitor }) => { + this.props.dispatchUpdateMonitoringOptions({ + artistIds: [this.props.artistId], + monitor, + shouldFetchAlbumsAfterUpdate: true + }); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +MonitoringOptionsModalContentConnector.propTypes = { + artistId: PropTypes.number.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + dispatchUpdateMonitoringOptions: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MonitoringOptionsModalContentConnector); diff --git a/frontend/src/Artist/MoveArtist/MoveArtistModal.js b/frontend/src/Artist/MoveArtist/MoveArtistModal.js index 9d6e54c81..1bad51151 100644 --- a/frontend/src/Artist/MoveArtist/MoveArtistModal.js +++ b/frontend/src/Artist/MoveArtist/MoveArtistModal.js @@ -15,6 +15,7 @@ function MoveArtistModal(props) { destinationPath, destinationRootFolder, isOpen, + onModalClose, onSavePress, onMoveArtistPress } = props; @@ -33,11 +34,11 @@ function MoveArtistModal(props) { isOpen={isOpen} size={sizes.MEDIUM} closeOnBackgroundClick={false} - onModalClose={onSavePress} + onModalClose={onModalClose} > Move Files @@ -76,6 +77,7 @@ MoveArtistModal.propTypes = { destinationPath: PropTypes.string, destinationRootFolder: PropTypes.string, isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired, onSavePress: PropTypes.func.isRequired, onMoveArtistPress: PropTypes.func.isRequired }; 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/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js index 079b3e780..17221eb34 100644 --- a/frontend/src/Calendar/CalendarConnector.js +++ b/frontend/src/Calendar/CalendarConnector.js @@ -47,7 +47,7 @@ class CalendarConnector extends Component { gotoCalendarToday } = this.props; - registerPagePopulator(this.repopulate); + registerPagePopulator(this.repopulate, ['trackFileUpdated', 'trackFileDeleted']); if (useCurrentPage) { fetchCalendar(); 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/DescriptionList/DescriptionListItemDescription.css b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css index b23415a76..786123fb7 100644 --- a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css @@ -1,9 +1,7 @@ -.description { - line-height: $lineHeight; -} - .description { margin-left: 0; + line-height: $lineHeight; + overflow-wrap: break-word; } @media (min-width: 768px) { 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 new file mode 100644 index 000000000..486027f35 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/ArtistFilterBuilderRowValue.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import Artist from 'Artist/Artist'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import sortByProp from 'Utilities/Array/sortByProp'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; +import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; + +function ArtistFilterBuilderRowValue(props: FilterBuilderRowValueProps) { + const allArtists: Artist[] = useSelector(createAllArtistSelector()); + + const tagList = allArtists + .map((artist) => ({ id: artist.id, name: artist.artistName })) + .sort(sortByProp('name')); + + return ; +} + +export default ArtistFilterBuilderRowValue; 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 0560b76b0..77dad7173 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -3,15 +3,19 @@ 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'; 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'; @@ -57,11 +61,17 @@ function getRowValueConnector(selectedFilterBuilderProp) { case filterBuilderValueTypes.DATE: return DateFilterBuilderRowValue; + case filterBuilderValueTypes.HISTORY_EVENT_TYPE: + return HistoryEventTypeFilterBuilderRowValue; + case filterBuilderValueTypes.INDEXER: return IndexerFilterBuilderRowValueConnector; case filterBuilderValueTypes.METADATA_PROFILE: - return MetadataProfileFilterBuilderRowValueConnector; + return MetadataProfileFilterBuilderRowValue; + + case filterBuilderValueTypes.MONITOR_NEW_ITEMS: + return MonitorNewItemsFilterBuilderRowValue; case filterBuilderValueTypes.PROTOCOL: return ProtocolFilterBuilderRowValue; @@ -70,7 +80,10 @@ function getRowValueConnector(selectedFilterBuilderProp) { return QualityFilterBuilderRowValueConnector; case filterBuilderValueTypes.QUALITY_PROFILE: - return QualityProfileFilterBuilderRowValueConnector; + return QualityProfileFilterBuilderRowValue; + + case filterBuilderValueTypes.ARTIST: + return ArtistFilterBuilderRowValue; case filterBuilderValueTypes.ARTIST_STATUS: return ArtistStatusFilterBuilderRowValue; @@ -212,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/FilterBuilderRowValueProps.ts b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueProps.ts new file mode 100644 index 000000000..5bf9e5785 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueProps.ts @@ -0,0 +1,16 @@ +import { FilterBuilderProp } from 'App/State/AppState'; + +interface FilterBuilderRowOnChangeProps { + name: string; + value: unknown[]; +} + +interface FilterBuilderRowValueProps { + filterType?: string; + filterValue: string | number | object | string[] | number[] | object[]; + selectedFilterBuilderProp: FilterBuilderProp; + sectionItem: unknown[]; + onChange: (payload: FilterBuilderRowOnChangeProps) => void; +} + +export default FilterBuilderRowValueProps; diff --git a/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx new file mode 100644 index 000000000..1b3b369be --- /dev/null +++ b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import translate from 'Utilities/String/translate'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; +import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; + +const EVENT_TYPE_OPTIONS = [ + { + id: 1, + get name() { + return translate('Grabbed'); + }, + }, + { + id: 3, + get name() { + return translate('TrackImported'); + }, + }, + { + id: 4, + get name() { + return translate('DownloadFailed'); + }, + }, + { + id: 7, + get name() { + return translate('ImportCompleteFailed'); + }, + }, + { + id: 8, + get name() { + return translate('DownloadImported'); + }, + }, + { + id: 5, + get name() { + return translate('Deleted'); + }, + }, + { + id: 6, + get name() { + return translate('Renamed'); + }, + }, + { + id: 9, + get name() { + return translate('Retagged'); + }, + }, + { + id: 7, + get name() { + return translate('Ignored'); + }, + }, +]; + +function HistoryEventTypeFilterBuilderRowValue( + props: FilterBuilderRowValueProps +) { + return ; +} + +export default HistoryEventTypeFilterBuilderRowValue; 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 116bd3e8b..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'; @@ -30,22 +31,24 @@ function CustomFiltersModalContent(props) { { - customFilters.map((customFilter) => { - return ( - - ); - }) + customFilters + .sort((a, b) => sortByProp(a, b, 'label')) + .map((customFilter) => { + return ( + + ); + }) }
diff --git a/frontend/src/Components/Form/ArtistTagInput.tsx b/frontend/src/Components/Form/ArtistTagInput.tsx new file mode 100644 index 000000000..3edb46ec4 --- /dev/null +++ b/frontend/src/Components/Form/ArtistTagInput.tsx @@ -0,0 +1,53 @@ +import React, { useCallback } from 'react'; +import TagInputConnector from './TagInputConnector'; + +interface ArtistTagInputProps { + name: string; + value: number | number[]; + onChange: ({ + name, + value, + }: { + name: string; + value: number | number[]; + }) => 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 7e6b0c7c9..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, @@ -289,7 +298,9 @@ FormInputGroup.propTypes = { autoFocus: PropTypes.bool, includeNoChange: PropTypes.bool, 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/FormLabel.css b/frontend/src/Components/Form/FormLabel.css index 074b6091d..54a4678e8 100644 --- a/frontend/src/Components/Form/FormLabel.css +++ b/frontend/src/Components/Form/FormLabel.css @@ -2,8 +2,10 @@ display: flex; justify-content: flex-end; margin-right: $formLabelRightMarginWidth; + padding-top: 8px; + min-height: 35px; + text-align: end; font-weight: bold; - line-height: 35px; } .hasError { 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 e7c1535c1..6e6aad5f9 100644 --- a/frontend/src/Components/Form/MetadataProfileSelectInputConnector.js +++ b/frontend/src/Components/Form/MetadataProfileSelectInputConnector.js @@ -5,19 +5,18 @@ 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, (state, { includeNone }) => includeNone, (metadataProfiles, includeNoChange, includeNoChangeDisabled = true, includeMixed, includeNone) => { - const profiles = metadataProfiles.items.filter((item) => item.name !== metadataProfileNames.NONE); const noneProfile = metadataProfiles.items.find((item) => item.name === metadataProfileNames.NONE); @@ -39,7 +38,7 @@ function createMapStateToProps() { values.unshift({ key: 'noChange', value: translate('NoChange'), - disabled: includeNoChangeDisabled + isDisabled: includeNoChangeDisabled }); } @@ -47,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 637a80c1c..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': @@ -37,6 +41,12 @@ function getType({ type, selectOptionsProviderAction }) { return inputTypes.TEXT; case 'oAuth': return inputTypes.OAUTH; + case 'rootFolder': + return inputTypes.ROOT_FOLDER_SELECT; + case 'qualityProfile': + return inputTypes.QUALITY_PROFILE_SELECT; + case 'metadataProfile': + return inputTypes.METADATA_PROFILE_SELECT; default: return inputTypes.TEXT; } 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 62077d68a..dcc2b88a6 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputConnector.js +++ b/frontend/src/Components/Form/RootFolderSelectInputConnector.js @@ -2,17 +2,19 @@ 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, - (rootFolders, value, includeMissingValue, includeNoChange) => { + (state, { includeNoChangeDisabled }) => includeNoChangeDisabled, + (rootFolders, value, includeMissingValue, includeNoChange, includeNoChangeDisabled = true) => { const values = rootFolders.items.map((rootFolder) => { return { key: rootFolder.path, @@ -26,9 +28,8 @@ function createMapStateToProps() { if (includeNoChange) { values.unshift({ key: 'noChange', - value: '', - name: 'No Change', - isDisabled: true, + value: 'No Change', + isDisabled: includeNoChangeDisabled, isMissing: false }); } @@ -46,7 +47,6 @@ function createMapStateToProps() { values.push({ key: '', value: '', - name: '', isDisabled: true, isHidden: true }); @@ -54,8 +54,7 @@ function createMapStateToProps() { values.push({ key: ADD_NEW_KEY, - value: '', - name: 'Add a new path' + value: 'Add a new path' }); return { 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/Page.js b/frontend/src/Components/Page/Page.js index eac6d709f..4b24a8231 100644 --- a/frontend/src/Components/Page/Page.js +++ b/frontend/src/Components/Page/Page.js @@ -4,6 +4,7 @@ import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector'; import ColorImpairedContext from 'App/ColorImpairedContext'; import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector'; import SignalRConnector from 'Components/SignalRConnector'; +import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal'; import locationShape from 'Helpers/Props/Shapes/locationShape'; import PageHeader from './Header/PageHeader'; import PageSidebar from './Sidebar/PageSidebar'; @@ -75,6 +76,7 @@ class Page extends Component { isSmallScreen, isSidebarVisible, enableColorImpairedMode, + authenticationEnabled, onSidebarToggle, onSidebarVisibleChange } = this.props; @@ -108,6 +110,10 @@ class Page extends Component { isOpen={this.state.isConnectionLostModalOpen} onModalClose={this.onConnectionLostModalClose} /> + +
); @@ -123,6 +129,7 @@ Page.propTypes = { isUpdated: PropTypes.bool.isRequired, isDisconnected: PropTypes.bool.isRequired, enableColorImpairedMode: PropTypes.bool.isRequired, + authenticationEnabled: PropTypes.bool.isRequired, onResize: PropTypes.func.isRequired, onSidebarToggle: PropTypes.func.isRequired, onSidebarVisibleChange: PropTypes.func.isRequired diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index b13695f17..c84099a5e 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -6,10 +6,18 @@ 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'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; import ErrorPage from './ErrorPage'; import LoadingPage from './LoadingPage'; import Page from './Page'; @@ -43,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, @@ -50,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, @@ -60,10 +71,12 @@ const selectIsPopulated = createSelector( qualityProfilesIsPopulated, metadataProfilesIsPopulated, importListsIsPopulated, + indexerFlagsIsPopulated, systemStatusIsPopulated, translationsIsPopulated ) => { return ( + artistsIsPopulated && customFiltersIsPopulated && tagsIsPopulated && uiSettingsIsPopulated && @@ -71,6 +84,7 @@ const selectIsPopulated = createSelector( qualityProfilesIsPopulated && metadataProfilesIsPopulated && importListsIsPopulated && + indexerFlagsIsPopulated && systemStatusIsPopulated && translationsIsPopulated ); @@ -78,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, @@ -85,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, @@ -95,10 +112,12 @@ const selectErrors = createSelector( qualityProfilesError, metadataProfilesError, importListsError, + indexerFlagsError, systemStatusError, translationsError ) => { const hasError = !!( + artistsError || customFiltersError || tagsError || uiSettingsError || @@ -106,6 +125,7 @@ const selectErrors = createSelector( qualityProfilesError || metadataProfilesError || importListsError || + indexerFlagsError || systemStatusError || translationsError ); @@ -119,6 +139,7 @@ const selectErrors = createSelector( qualityProfilesError, metadataProfilesError, importListsError, + indexerFlagsError, systemStatusError, translationsError }; @@ -132,18 +153,21 @@ function createMapStateToProps() { selectErrors, selectAppProps, createDimensionsSelector(), + createSystemStatusSelector(), ( enableColorImpairedMode, isPopulated, errors, app, - dimensions + dimensions, + systemStatus ) => { return { ...app, ...errors, isPopulated, isSmallScreen: dimensions.isSmallScreen, + authenticationEnabled: systemStatus.authentication !== 'none', enableColorImpairedMode }; } @@ -173,6 +197,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchImportLists() { dispatch(fetchImportLists()); }, + dispatchFetchIndexerFlags() { + dispatch(fetchIndexerFlags()); + }, dispatchFetchUISettings() { dispatch(fetchUISettings()); }, @@ -213,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(); @@ -239,6 +267,7 @@ class PageConnector extends Component { dispatchFetchQualityProfiles, dispatchFetchMetadataProfiles, dispatchFetchImportLists, + dispatchFetchIndexerFlags, dispatchFetchUISettings, dispatchFetchStatus, dispatchFetchTranslations, @@ -280,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.js b/frontend/src/Components/Page/PageContentBody.js deleted file mode 100644 index 1c93e575b..000000000 --- a/frontend/src/Components/Page/PageContentBody.js +++ /dev/null @@ -1,61 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Scroller from 'Components/Scroller/Scroller'; -import { scrollDirections } from 'Helpers/Props'; -import { isLocked } from 'Utilities/scrollLock'; -import styles from './PageContentBody.css'; - -class PageContentBody extends Component { - - // - // Listeners - - onScroll = (props) => { - const { onScroll } = this.props; - - if (this.props.onScroll && !isLocked()) { - onScroll(props); - } - }; - - // - // Render - - render() { - const { - className, - innerClassName, - children, - dispatch, - ...otherProps - } = this.props; - - return ( - -
- {children} -
-
- ); - } -} - -PageContentBody.propTypes = { - className: PropTypes.string, - innerClassName: PropTypes.string, - children: PropTypes.node.isRequired, - onScroll: PropTypes.func, - dispatch: PropTypes.func -}; - -PageContentBody.defaultProps = { - className: styles.contentBody, - innerClassName: styles.innerContentBody -}; - -export default PageContentBody; diff --git a/frontend/src/Components/Page/PageContentBody.tsx b/frontend/src/Components/Page/PageContentBody.tsx new file mode 100644 index 000000000..ce9b0e7e4 --- /dev/null +++ b/frontend/src/Components/Page/PageContentBody.tsx @@ -0,0 +1,48 @@ +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'; + +interface PageContentBodyProps { + className?: string; + innerClassName?: string; + children: ReactNode; + initialScrollTop?: number; + onScroll?: (payload: OnScroll) => void; +} + +const PageContentBody = forwardRef( + (props: PageContentBodyProps, ref: ForwardedRef) => { + const { + className = styles.contentBody, + innerClassName = styles.innerContentBody, + children, + onScroll, + ...otherProps + } = props; + + const onScrollWrapper = useCallback( + (payload: OnScroll) => { + if (onScroll && !isLocked()) { + onScroll(payload); + } + }, + [onScroll] + ); + + return ( + +
{children}
+
+ ); + } +); + +export default PageContentBody; diff --git a/frontend/src/Components/Page/PageContentFooter.css b/frontend/src/Components/Page/PageContentFooter.css index 4709af871..61c63064a 100644 --- a/frontend/src/Components/Page/PageContentFooter.css +++ b/frontend/src/Components/Page/PageContentFooter.css @@ -8,14 +8,6 @@ @media only screen and (max-width: $breakpointSmall) { .contentFooter { display: block; - - div { - margin-top: 10px; - - &:first-child { - margin-top: 0; - } - } } } diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index 583a604bd..d6db8d612 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -29,14 +29,6 @@ const links = [ title: () => translate('AddNew'), to: '/add/search' }, - { - title: () => translate('MassEditor'), - to: '/artisteditor' - }, - { - title: () => translate('AlbumStudio'), - to: '/albumstudio' - }, { title: () => translate('UnmappedFiles'), to: '/unmapped' @@ -137,7 +129,7 @@ const links = [ to: '/settings/general' }, { - title: () => translate('UI'), + title: () => translate('Ui'), to: '/settings/ui' } ] diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js index 2d179396a..c93603aa9 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js @@ -45,7 +45,8 @@ PageToolbarButton.propTypes = { iconName: PropTypes.object.isRequired, spinningName: PropTypes.object, isSpinning: PropTypes.bool, - isDisabled: PropTypes.bool + isDisabled: PropTypes.bool, + onPress: PropTypes.func }; PageToolbarButton.defaultProps = { diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css new file mode 100644 index 000000000..b3cae8163 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css @@ -0,0 +1,3 @@ +.icon { + margin-right: 8px; +} diff --git a/frontend/src/Components/Form/PasswordInput.css.d.ts b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css.d.ts similarity index 89% rename from frontend/src/Components/Form/PasswordInput.css.d.ts rename to frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css.d.ts index 774807ef4..2c598cbee 100644 --- a/frontend/src/Components/Form/PasswordInput.css.d.ts +++ b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.css.d.ts @@ -1,7 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'input': string; + 'icon': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx new file mode 100644 index 000000000..c97eb2a91 --- /dev/null +++ b/frontend/src/Components/Page/Toolbar/PageToolbarOverflowMenuItem.tsx @@ -0,0 +1,41 @@ +import { IconDefinition } from '@fortawesome/fontawesome-common-types'; +import React from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import styles from './PageToolbarOverflowMenuItem.css'; + +interface PageToolbarOverflowMenuItemProps { + iconName: IconDefinition; + spinningName?: IconDefinition; + isDisabled?: boolean; + isSpinning?: boolean; + showIndicator?: boolean; + label: string; + text?: string; + onPress: () => void; +} + +function PageToolbarOverflowMenuItem(props: PageToolbarOverflowMenuItemProps) { + const { + iconName, + spinningName, + label, + isDisabled, + isSpinning = false, + ...otherProps + } = props; + + return ( + + + {label} + + ); +} + +export default PageToolbarOverflowMenuItem; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js index d64d11435..2d4aca718 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js @@ -4,12 +4,11 @@ import React, { Component } from 'react'; import Measure from 'Components/Measure'; import Menu from 'Components/Menu/Menu'; import MenuContent from 'Components/Menu/MenuContent'; -import MenuItem from 'Components/Menu/MenuItem'; import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; -import SpinnerIcon from 'Components/SpinnerIcon'; import { forEach } from 'Helpers/elementChildren'; import { align, icons } from 'Helpers/Props'; import dimensions from 'Styles/Variables/dimensions'; +import PageToolbarOverflowMenuItem from './PageToolbarOverflowMenuItem'; import styles from './PageToolbarSection.css'; const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth); @@ -168,28 +167,15 @@ class PageToolbarSection extends Component { { overflowItems.map((item) => { const { - iconName, - spinningName, label, - isDisabled, - isSpinning, - ...otherProps + overflowComponent: OverflowComponent = PageToolbarOverflowMenuItem } = item; return ( - - - {label} - + {...item} + /> ); }) } diff --git a/frontend/src/Components/Scroller/Scroller.js b/frontend/src/Components/Scroller/Scroller.js deleted file mode 100644 index 205f1aadd..000000000 --- a/frontend/src/Components/Scroller/Scroller.js +++ /dev/null @@ -1,95 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { scrollDirections } from 'Helpers/Props'; -import styles from './Scroller.css'; - -class Scroller extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scroller = null; - } - - componentDidMount() { - const { - scrollDirection, - autoFocus, - scrollTop - } = this.props; - - if (this.props.scrollTop != null) { - this._scroller.scrollTop = scrollTop; - } - - if (autoFocus && scrollDirection !== scrollDirections.NONE) { - this._scroller.focus({ preventScroll: true }); - } - } - - // - // Control - - _setScrollerRef = (ref) => { - this._scroller = ref; - - this.props.registerScroller(ref); - }; - - // - // Render - - render() { - const { - className, - scrollDirection, - autoScroll, - children, - scrollTop, - onScroll, - registerScroller, - ...otherProps - } = this.props; - - return ( -
- {children} -
- ); - } - -} - -Scroller.propTypes = { - className: PropTypes.string, - scrollDirection: PropTypes.oneOf(scrollDirections.all).isRequired, - autoFocus: PropTypes.bool.isRequired, - autoScroll: PropTypes.bool.isRequired, - scrollTop: PropTypes.number, - children: PropTypes.node, - onScroll: PropTypes.func, - registerScroller: PropTypes.func -}; - -Scroller.defaultProps = { - scrollDirection: scrollDirections.VERTICAL, - autoFocus: true, - autoScroll: true, - registerScroller: () => { /* no-op */ } -}; - -export default Scroller; diff --git a/frontend/src/Components/Scroller/Scroller.tsx b/frontend/src/Components/Scroller/Scroller.tsx new file mode 100644 index 000000000..37b16eebd --- /dev/null +++ b/frontend/src/Components/Scroller/Scroller.tsx @@ -0,0 +1,102 @@ +import classNames from 'classnames'; +import { throttle } from 'lodash'; +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; + autoFocus?: boolean; + autoScroll?: boolean; + scrollTop?: number; + initialScrollTop?: number; + children?: ReactNode; + onScroll?: (payload: OnScroll) => void; +} + +const Scroller = forwardRef( + (props: ScrollerProps, ref: ForwardedRef) => { + const { + className, + autoFocus = false, + autoScroll = true, + scrollDirection = ScrollDirection.Vertical, + children, + scrollTop, + initialScrollTop, + onScroll, + ...otherProps + } = props; + + const internalRef = useRef(); + const currentRef = (ref as MutableRefObject) ?? internalRef; + + useEffect( + () => { + if (initialScrollTop != null) { + currentRef.current.scrollTop = initialScrollTop; + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + if (scrollTop != null) { + currentRef.current.scrollTop = scrollTop; + } + + if (autoFocus && scrollDirection !== ScrollDirection.None) { + currentRef.current.focus({ preventScroll: true }); + } + }, [autoFocus, currentRef, scrollDirection, scrollTop]); + + useEffect(() => { + const div = currentRef.current; + + const handleScroll = throttle(() => { + const scrollLeft = div.scrollLeft; + const scrollTop = div.scrollTop; + + onScroll?.({ scrollLeft, scrollTop }); + }, 10); + + div.addEventListener('scroll', handleScroll); + + return () => { + div.removeEventListener('scroll', handleScroll); + }; + }, [currentRef, onScroll]); + + return ( +
+ {children} +
+ ); + } +); + +export default Scroller; diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index 966097730..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); @@ -216,16 +216,66 @@ class SignalRConnector extends Component { this.props.dispatchUpdateItem({ section, ...body.resource }); } else if (body.action === 'deleted') { this.props.dispatchRemoveItem({ section, id: body.resource.id }); + + repopulatePage('trackFileDeleted'); } // Repopulate the page to handle recently imported file 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'; @@ -264,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 }); @@ -274,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/SpinnerIcon.js b/frontend/src/Components/SpinnerIcon.js index d21674d9e..5ae03ee66 100644 --- a/frontend/src/Components/SpinnerIcon.js +++ b/frontend/src/Components/SpinnerIcon.js @@ -21,6 +21,7 @@ function SpinnerIcon(props) { } SpinnerIcon.propTypes = { + className: PropTypes.string, name: PropTypes.object.isRequired, spinningName: PropTypes.object.isRequired, isSpinning: PropTypes.bool.isRequired 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.js b/frontend/src/Components/withScrollPosition.js deleted file mode 100644 index bb089b8b0..000000000 --- a/frontend/src/Components/withScrollPosition.js +++ /dev/null @@ -1,30 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import scrollPositions from 'Store/scrollPositions'; - -function withScrollPosition(WrappedComponent, scrollPositionKey) { - function ScrollPosition(props) { - const { - history - } = props; - - const scrollTop = history.action === 'POP' || (history.location.state && history.location.state.restoreScrollPosition) ? - scrollPositions[scrollPositionKey] : - 0; - - return ( - - ); - } - - ScrollPosition.propTypes = { - history: PropTypes.object.isRequired - }; - - return ScrollPosition; -} - -export default withScrollPosition; diff --git a/frontend/src/Components/withScrollPosition.tsx b/frontend/src/Components/withScrollPosition.tsx new file mode 100644 index 000000000..f688a6253 --- /dev/null +++ b/frontend/src/Components/withScrollPosition.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import scrollPositions from 'Store/scrollPositions'; + +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' ? scrollPositions[scrollPositionKey] : 0; + + return ; + } + + return ScrollPosition; +} + +export default withScrollPosition; 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 cff971235..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": "standalone" -} 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/Diag/ConsoleApi.js b/frontend/src/Diag/ConsoleApi.js index 117b54104..502c243cc 100644 --- a/frontend/src/Diag/ConsoleApi.js +++ b/frontend/src/Diag/ConsoleApi.js @@ -81,7 +81,8 @@ class ResourceApi { class ConsoleApi { constructor() { - this.series = new ResourceApi(this, '/artist'); + this.artist = new ResourceApi(this, '/artist'); + this.album = new ResourceApi(this, '/album'); } resource(url) { diff --git a/frontend/src/FirstRun/AuthenticationRequiredModal.js b/frontend/src/FirstRun/AuthenticationRequiredModal.js new file mode 100644 index 000000000..caa855cb7 --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModal.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import AuthenticationRequiredModalContentConnector from './AuthenticationRequiredModalContentConnector'; + +function onModalClose() { + // No-op +} + +function AuthenticationRequiredModal(props) { + const { + isOpen + } = props; + + return ( + + + + ); +} + +AuthenticationRequiredModal.propTypes = { + isOpen: PropTypes.bool.isRequired +}; + +export default AuthenticationRequiredModal; diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.css b/frontend/src/FirstRun/AuthenticationRequiredModalContent.css new file mode 100644 index 000000000..bbc6704e6 --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.css @@ -0,0 +1,5 @@ +.authRequiredAlert { + composes: alert from '~Components/Alert.css'; + + margin-bottom: 20px; +} diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.css.d.ts b/frontend/src/FirstRun/AuthenticationRequiredModalContent.css.d.ts new file mode 100644 index 000000000..9454d5428 --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'authRequiredAlert': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js new file mode 100644 index 000000000..568cc66af --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js @@ -0,0 +1,170 @@ +import PropTypes from 'prop-types'; +import React, { useEffect, useRef } from 'react'; +import Alert from 'Components/Alert'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +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 { inputTypes, kinds } from 'Helpers/Props'; +import { authenticationMethodOptions, authenticationRequiredOptions } from 'Settings/General/SecuritySettings'; +import translate from 'Utilities/String/translate'; +import styles from './AuthenticationRequiredModalContent.css'; + +function onModalClose() { + // No-op +} + +function AuthenticationRequiredModalContent(props) { + const { + isPopulated, + error, + isSaving, + settings, + onInputChange, + onSavePress, + dispatchFetchStatus + } = props; + + const { + authenticationMethod, + authenticationRequired, + username, + password, + passwordConfirmation + } = settings; + + const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; + + const didMount = useRef(false); + + useEffect(() => { + if (!isSaving && didMount.current) { + dispatchFetchStatus(); + } + + didMount.current = true; + }, [isSaving, dispatchFetchStatus]); + + return ( + + + {translate('AuthenticationRequired')} + + + + + {translate('AuthenticationRequiredWarning')} + + + { + isPopulated && !error ? +
+ + {translate('AuthenticationMethod')} + + + + + + {translate('AuthenticationRequired')} + + + + + + {translate('Username')} + + + + + + {translate('Password')} + + + + + + {translate('PasswordConfirmation')} + + + +
: + null + } + + { + !isPopulated && !error ? : null + } +
+ + + + {translate('Save')} + + +
+ ); +} + +AuthenticationRequiredModalContent.propTypes = { + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + dispatchFetchStatus: PropTypes.func.isRequired +}; + +export default AuthenticationRequiredModalContent; diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContentConnector.js b/frontend/src/FirstRun/AuthenticationRequiredModalContentConnector.js new file mode 100644 index 000000000..6653a9d34 --- /dev/null +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContentConnector.js @@ -0,0 +1,86 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { fetchGeneralSettings, saveGeneralSettings, setGeneralSettingsValue } from 'Store/Actions/settingsActions'; +import { fetchStatus } from 'Store/Actions/systemActions'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import AuthenticationRequiredModalContent from './AuthenticationRequiredModalContent'; + +const SECTION = 'general'; + +function createMapStateToProps() { + return createSelector( + createSettingsSectionSelector(SECTION), + (sectionSettings) => { + return { + ...sectionSettings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchClearPendingChanges: clearPendingChanges, + dispatchSetGeneralSettingsValue: setGeneralSettingsValue, + dispatchSaveGeneralSettings: saveGeneralSettings, + dispatchFetchGeneralSettings: fetchGeneralSettings, + dispatchFetchStatus: fetchStatus +}; + +class AuthenticationRequiredModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.dispatchFetchGeneralSettings(); + } + + componentWillUnmount() { + this.props.dispatchClearPendingChanges({ section: `settings.${SECTION}` }); + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetGeneralSettingsValue({ name, value }); + }; + + onSavePress = () => { + this.props.dispatchSaveGeneralSettings(); + }; + + // + // Render + + render() { + const { + dispatchClearPendingChanges, + dispatchFetchGeneralSettings, + dispatchSetGeneralSettingsValue, + dispatchSaveGeneralSettings, + ...otherProps + } = this.props; + + return ( + + ); + } +} + +AuthenticationRequiredModalContentConnector.propTypes = { + dispatchClearPendingChanges: PropTypes.func.isRequired, + dispatchFetchGeneralSettings: PropTypes.func.isRequired, + dispatchSetGeneralSettingsValue: PropTypes.func.isRequired, + dispatchSaveGeneralSettings: PropTypes.func.isRequired, + dispatchFetchStatus: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AuthenticationRequiredModalContentConnector); diff --git a/frontend/src/Helpers/Hooks/useMeasure.ts b/frontend/src/Helpers/Hooks/useMeasure.ts new file mode 100644 index 000000000..7b36b2844 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useMeasure.ts @@ -0,0 +1,21 @@ +import { ResizeObserver as ResizeObserverPolyfill } from '@juggle/resize-observer'; +import { + default as useMeasureHook, + Options, + RectReadOnly, +} from 'react-use-measure'; + +const ResizeObserver = window.ResizeObserver || ResizeObserverPolyfill; + +export type Measurements = RectReadOnly; + +function useMeasure( + options?: Omit +): ReturnType { + return useMeasureHook({ + polyfill: ResizeObserver, + ...options, + }); +} + +export default useMeasure; 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/ScrollDirection.ts b/frontend/src/Helpers/Props/ScrollDirection.ts new file mode 100644 index 000000000..0da932d22 --- /dev/null +++ b/frontend/src/Helpers/Props/ScrollDirection.ts @@ -0,0 +1,8 @@ +enum ScrollDirection { + Horizontal = 'horizontal', + Vertical = 'vertical', + None = 'none', + Both = 'both', +} + +export default ScrollDirection; diff --git a/frontend/src/Helpers/Props/filterBuilderTypes.js b/frontend/src/Helpers/Props/filterBuilderTypes.js index f2ef6f2f8..c0806fabc 100644 --- a/frontend/src/Helpers/Props/filterBuilderTypes.js +++ b/frontend/src/Helpers/Props/filterBuilderTypes.js @@ -1,14 +1,18 @@ import * as filterTypes from './filterTypes'; export const ARRAY = 'array'; +export const CONTAINS = 'contains'; export const DATE = 'date'; +export const EQUAL = 'equal'; export const EXACT = 'exact'; export const NUMBER = 'number'; export const STRING = 'string'; export const all = [ ARRAY, + CONTAINS, DATE, + EQUAL, EXACT, NUMBER, STRING @@ -20,6 +24,10 @@ export const possibleFilterTypes = { { key: filterTypes.NOT_CONTAINS, value: 'does not contain' } ], + [CONTAINS]: [ + { key: filterTypes.CONTAINS, value: 'contains' } + ], + [DATE]: [ { key: filterTypes.LESS_THAN, value: 'is before' }, { key: filterTypes.GREATER_THAN, value: 'is after' }, @@ -29,6 +37,10 @@ export const possibleFilterTypes = { { key: filterTypes.NOT_IN_NEXT, value: 'not in the next' } ], + [EQUAL]: [ + { key: filterTypes.EQUAL, value: 'is' } + ], + [EXACT]: [ { key: filterTypes.EQUAL, value: 'is' }, { key: filterTypes.NOT_EQUAL, value: 'is not' } diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js index 42df49eda..005ea0b7a 100644 --- a/frontend/src/Helpers/Props/filterBuilderValueTypes.js +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js @@ -2,10 +2,13 @@ export const BOOL = 'bool'; export const BYTES = 'bytes'; export const DATE = 'date'; 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'; +export const ARTIST = 'artist'; export const ARTIST_STATUS = 'artistStatus'; export const TAG = 'tag'; diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 29fa87780..aa9c23145 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -15,7 +15,8 @@ import { faHdd as farHdd, faKeyboard as farKeyboard, faObjectGroup as farObjectGroup, - faObjectUngroup as farObjectUngroup + faObjectUngroup as farObjectUngroup, + faSquare as farSquare } from '@fortawesome/free-regular-svg-icons'; // // Solid @@ -31,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, @@ -54,10 +56,12 @@ import { faEye as fasEye, faFastBackward as fasFastBackward, faFastForward as fasFastForward, + faFileCircleQuestion as fasFileCircleQuestion, faFileExport as fasFileExport, faFileImport as fasFileImport, faFileInvoice as farFileInvoice, faFilter as fasFilter, + faFlag as fasFlag, faFolderOpen as fasFolderOpen, faForward as fasForward, faHeart as fasHeart, @@ -90,6 +94,8 @@ import { faSortDown as fasSortDown, faSortUp as fasSortUp, faSpinner as fasSpinner, + faSquareCheck as fasSquareCheck, + faSquareMinus as fasSquareMinus, faStar as fasStar, faStop as fasStop, faSync as fasSync, @@ -128,6 +134,7 @@ export const CARET_DOWN = fasCaretDown; export const CHECK = fasCheck; export const CHECK_INDETERMINATE = fasMinus; export const CHECK_CIRCLE = fasCheckCircle; +export const CHECK_SQUARE = fasSquareCheck; export const CIRCLE = fasCircle; export const CIRCLE_OUTLINE = farCircle; export const CLEAR = fasTrashAlt; @@ -150,8 +157,10 @@ export const EXPORT = fasFileExport; export const EXTERNAL_LINK = fasExternalLinkAlt; export const FATAL = fasTimesCircle; export const FILE = farFile; -export const FILEIMPORT = fasFileImport; +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; @@ -179,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; @@ -205,6 +215,8 @@ export const SORT = fasSort; export const SORT_ASCENDING = fasSortUp; export const SORT_DESCENDING = fasSortDown; export const SPINNER = fasSpinner; +export const SQUARE = farSquare; +export const SQUARE_MINUS = fasSquareMinus; export const STAR_FULL = fasStar; export const SUBTRACT = fasMinus; export const SYSTEM = fasLaptop; 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/Album/SelectAlbumRow.js b/frontend/src/InteractiveImport/Album/SelectAlbumRow.js index 68d50fb8b..6b50a1ce9 100644 --- a/frontend/src/InteractiveImport/Album/SelectAlbumRow.js +++ b/frontend/src/InteractiveImport/Album/SelectAlbumRow.js @@ -44,9 +44,9 @@ class SelectAlbumRow extends Component { } = this.props; const { - trackCount, - trackFileCount, - totalTrackCount + trackCount = 0, + trackFileCount = 0, + totalTrackCount = 0 } = statistics; const extendedTitle = disambiguation ? `${title} (${disambiguation})` : title; @@ -134,7 +134,8 @@ SelectAlbumRow.propTypes = { SelectAlbumRow.defaultProps = { statistics: { trackCount: 0, - trackFileCount: 0 + trackFileCount: 0, + totalTrackCount: 0 } }; 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/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts new file mode 100644 index 000000000..3fc49a060 --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + '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/InteractiveImport/Track/SelectTrackRow.js b/frontend/src/InteractiveImport/Track/SelectTrackRow.js index ea621877d..d0a902576 100644 --- a/frontend/src/InteractiveImport/Track/SelectTrackRow.js +++ b/frontend/src/InteractiveImport/Track/SelectTrackRow.js @@ -51,11 +51,11 @@ class SelectTrackRow extends Component { iconKind = kinds.DEFAULT; iconTip = 'Track missing from library and no import selected.'; } else if (importSelected && hasFile) { - iconName = icons.FILEIMPORT; + iconName = icons.FILE_IMPORT; iconKind = kinds.WARNING; iconTip = 'Warning: Existing track will be replaced by download.'; } else if (importSelected && !hasFile) { - iconName = icons.FILEIMPORT; + iconName = icons.FILE_IMPORT; iconKind = kinds.DEFAULT; iconTip = 'Track missing from library and selected for import.'; } 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 c1079c792..dad7242c8 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css @@ -22,6 +22,10 @@ text-align: center; } +.quality { + white-space: nowrap; +} + .customFormatScore { composes: cell from '~Components/Table/Cells/TableRowCell.css'; @@ -31,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 ce32f12bd..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, @@ -178,7 +180,7 @@ class InteractiveSearchRow extends Component { - + @@ -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/Quality/Quality.ts b/frontend/src/Quality/Quality.ts new file mode 100644 index 000000000..5be1475fc --- /dev/null +++ b/frontend/src/Quality/Quality.ts @@ -0,0 +1,17 @@ +export interface Revision { + version: number; + real: number; + isRepack: boolean; +} + +interface Quality { + id: number; + name: string; +} + +export interface QualityModel { + quality: Quality; + revision: Revision; +} + +export default Quality; 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/Search/Album/AddNewAlbumModalContent.js b/frontend/src/Search/Album/AddNewAlbumModalContent.js index 5c2c70135..2e461a5a0 100644 --- a/frontend/src/Search/Album/AddNewAlbumModalContent.js +++ b/frontend/src/Search/Album/AddNewAlbumModalContent.js @@ -15,26 +15,11 @@ import styles from './AddNewAlbumModalContent.css'; class AddNewAlbumModalContent extends Component { - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - searchForNewAlbum: false - }; - } - // // Listeners - onSearchForNewAlbumChange = ({ value }) => { - this.setState({ searchForNewAlbum: value }); - }; - onAddAlbumPress = () => { - this.props.onAddAlbumPress(this.state.searchForNewAlbum); + this.props.onAddAlbumPress(); }; // @@ -47,10 +32,12 @@ class AddNewAlbumModalContent extends Component { disambiguation, overview, images, + searchForNewAlbum, isAdding, isExistingArtist, isSmallScreen, onModalClose, + onInputChange, ...otherProps } = this.props; @@ -105,6 +92,7 @@ class AddNewAlbumModalContent extends Component { } @@ -115,15 +103,15 @@ class AddNewAlbumModalContent extends Component { @@ -133,7 +121,7 @@ class AddNewAlbumModalContent extends Component { isSpinning={isAdding} onPress={this.onAddAlbumPress} > - Add {albumTitle} + {translate('AddAlbumWithTitle', { albumTitle })} @@ -149,9 +137,11 @@ AddNewAlbumModalContent.propTypes = { images: PropTypes.arrayOf(PropTypes.object).isRequired, isAdding: PropTypes.bool.isRequired, addError: PropTypes.object, + searchForNewAlbum: PropTypes.object.isRequired, isExistingArtist: PropTypes.bool.isRequired, isSmallScreen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired, onAddAlbumPress: PropTypes.func.isRequired }; diff --git a/frontend/src/Search/Album/AddNewAlbumModalContentConnector.js b/frontend/src/Search/Album/AddNewAlbumModalContentConnector.js index dd7c03cab..e315b49cd 100644 --- a/frontend/src/Search/Album/AddNewAlbumModalContentConnector.js +++ b/frontend/src/Search/Album/AddNewAlbumModalContentConnector.js @@ -83,7 +83,7 @@ class AddNewAlbumModalContentConnector extends Component { this.props.setAddDefault({ [name]: value }); }; - onAddAlbumPress = (searchForNewAlbum) => { + onAddAlbumPress = () => { const { foreignAlbumId, rootFolderPath, @@ -91,6 +91,7 @@ class AddNewAlbumModalContentConnector extends Component { monitorNewItems, qualityProfileId, metadataProfileId, + searchForNewAlbum, tags } = this.props; @@ -101,8 +102,8 @@ class AddNewAlbumModalContentConnector extends Component { monitorNewItems: monitorNewItems.value, qualityProfileId: qualityProfileId.value, metadataProfileId: metadataProfileId.value, - tags: tags.value, - searchForNewAlbum + searchForNewAlbum: searchForNewAlbum.value, + tags: tags.value }); }; @@ -129,6 +130,7 @@ AddNewAlbumModalContentConnector.propTypes = { qualityProfileId: PropTypes.object, metadataProfileId: PropTypes.object, noneMetadataProfileId: PropTypes.number.isRequired, + searchForNewAlbum: PropTypes.object.isRequired, tags: PropTypes.object.isRequired, onModalClose: PropTypes.func.isRequired, setAddDefault: PropTypes.func.isRequired, diff --git a/frontend/src/Search/Artist/AddNewArtistModalContent.js b/frontend/src/Search/Artist/AddNewArtistModalContent.js index 83e7898ed..9719985f6 100644 --- a/frontend/src/Search/Artist/AddNewArtistModalContent.js +++ b/frontend/src/Search/Artist/AddNewArtistModalContent.js @@ -15,26 +15,11 @@ import styles from './AddNewArtistModalContent.css'; class AddNewArtistModalContent extends Component { - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - searchForMissingAlbums: false - }; - } - // // Listeners - onSearchForMissingAlbumsChange = ({ value }) => { - this.setState({ searchForMissingAlbums: value }); - }; - onAddArtistPress = () => { - this.props.onAddArtistPress(this.state.searchForMissingAlbums); + this.props.onAddArtistPress(); }; // @@ -46,9 +31,11 @@ class AddNewArtistModalContent extends Component { disambiguation, overview, images, + searchForMissingAlbums, isAdding, isSmallScreen, onModalClose, + onInputChange, ...otherProps } = this.props; @@ -96,6 +83,7 @@ class AddNewArtistModalContent extends Component { @@ -106,15 +94,15 @@ class AddNewArtistModalContent extends Component { @@ -124,7 +112,7 @@ class AddNewArtistModalContent extends Component { isSpinning={isAdding} onPress={this.onAddArtistPress} > - Add {artistName} + {translate('AddArtistWithName', { artistName })} @@ -139,9 +127,11 @@ AddNewArtistModalContent.propTypes = { images: PropTypes.arrayOf(PropTypes.object).isRequired, isAdding: PropTypes.bool.isRequired, addError: PropTypes.object, + searchForMissingAlbums: PropTypes.object.isRequired, isSmallScreen: PropTypes.bool.isRequired, isWindows: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired, onAddArtistPress: PropTypes.func.isRequired }; diff --git a/frontend/src/Search/Artist/AddNewArtistModalContentConnector.js b/frontend/src/Search/Artist/AddNewArtistModalContentConnector.js index 1b50869e5..a33c4bbf7 100644 --- a/frontend/src/Search/Artist/AddNewArtistModalContentConnector.js +++ b/frontend/src/Search/Artist/AddNewArtistModalContentConnector.js @@ -55,7 +55,7 @@ class AddNewArtistModalContentConnector extends Component { this.props.setAddDefault({ [name]: value }); }; - onAddArtistPress = (searchForMissingAlbums) => { + onAddArtistPress = () => { const { foreignArtistId, rootFolderPath, @@ -63,6 +63,7 @@ class AddNewArtistModalContentConnector extends Component { monitorNewItems, qualityProfileId, metadataProfileId, + searchForMissingAlbums, tags } = this.props; @@ -73,8 +74,8 @@ class AddNewArtistModalContentConnector extends Component { monitorNewItems: monitorNewItems.value, qualityProfileId: qualityProfileId.value, metadataProfileId: metadataProfileId.value, - tags: tags.value, - searchForMissingAlbums + searchForMissingAlbums: searchForMissingAlbums.value, + tags: tags.value }); }; @@ -99,6 +100,7 @@ AddNewArtistModalContentConnector.propTypes = { monitorNewItems: PropTypes.object.isRequired, qualityProfileId: PropTypes.object, metadataProfileId: PropTypes.object, + searchForMissingAlbums: PropTypes.object.isRequired, tags: PropTypes.object.isRequired, onModalClose: PropTypes.func.isRequired, setAddDefault: PropTypes.func.isRequired, diff --git a/frontend/src/Search/Artist/AddNewArtistSearchResult.js b/frontend/src/Search/Artist/AddNewArtistSearchResult.js index 8ad5f6061..83e287b5e 100644 --- a/frontend/src/Search/Artist/AddNewArtistSearchResult.js +++ b/frontend/src/Search/Artist/AddNewArtistSearchResult.js @@ -89,7 +89,7 @@ class AddNewArtistSearchResult extends Component { const linkProps = isExistingArtist ? { to: `/artist/${foreignArtistId}` } : { onPress: this.onPress }; - const endedString = artistType === 'Person' ? 'Deceased' : 'Inactive'; + const endedString = artistType === 'Person' ? translate('Deceased') : translate('Inactive'); const height = calculateHeight(230, isSmallScreen); 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 7e43dea12..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; @@ -139,7 +141,7 @@ 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 12f9efedb..b2c1208cb 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx @@ -10,6 +10,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 Column from 'Components/Table/Column'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import useSelectState from 'Helpers/Hooks/useSelectState'; @@ -17,6 +18,7 @@ import { kinds } from 'Helpers/Props'; import { bulkDeleteDownloadClients, bulkEditDownloadClients, + setManageDownloadClientsSort, } from 'Store/Actions/settingsActions'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import { SelectStateInputProps } from 'typings/props'; @@ -33,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps< typeof ManageDownloadClientsModalRow >['onSelectedChange']; -const COLUMNS = [ +const COLUMNS: Column[] = [ { name: 'name', label: () => translate('Name'), @@ -94,6 +96,8 @@ function ManageDownloadClientsModalContent( isSaving, error, items, + sortKey, + sortDirection, }: DownloadClientAppState = useSelector( createClientSideCollectionSelector('settings.downloadClients') ); @@ -114,6 +118,13 @@ function ManageDownloadClientsModalContent( const selectedCount = selectedIds.length; + const onSortPress = useCallback( + (value: string) => { + dispatch(setManageDownloadClientsSort({ sortKey: value })); + }, + [dispatch] + ); + const onDeletePress = useCallback(() => { setIsDeleteModalOpen(true); }, [setIsDeleteModalOpen]); @@ -207,9 +218,9 @@ function ManageDownloadClientsModalContent( {error ?
{errorMessage}
: null} - {isPopulated && !error && !items.length && ( + {isPopulated && !error && !items.length ? ( {translate('NoDownloadClientsFound')} - )} + ) : null} {isPopulated && !!items.length && !isFetching && !isFetching ? ( {items.map((item) => { diff --git a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js index 48d8f3410..262143590 100644 --- a/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js +++ b/frontend/src/Settings/DownloadClients/Options/DownloadClientOptions.js @@ -61,10 +61,12 @@ function DownloadClientOptions(props) { legend={translate('FailedDownloadHandling')} >
- - - {translate('Redownload')} - + + {translate('AutoRedownloadFailed')} + + { + settings.autoRedownloadFailed.value ? + + {translate('AutoRedownloadFailedFromInteractiveSearch')} + + + : + null + } + {translate('RemoveDownloadsAlert')} diff --git a/frontend/src/Settings/General/GeneralSettings.js b/frontend/src/Settings/General/GeneralSettings.js index 91e7287ee..39340c856 100644 --- a/frontend/src/Settings/General/GeneralSettings.js +++ b/frontend/src/Settings/General/GeneralSettings.js @@ -156,6 +156,7 @@ class GeneralSettings extends Component { /> 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/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js index bb20a9305..d48d5abf6 100644 --- a/frontend/src/Settings/General/SecuritySettings.js +++ b/frontend/src/Settings/General/SecuritySettings.js @@ -11,16 +11,69 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import { icons, inputTypes, kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; -const authenticationMethodOptions = [ - { key: 'none', value: 'None' }, - { key: 'basic', value: 'Basic (Browser Popup)' }, - { key: 'forms', value: 'Forms (Login Page)' } +export const authenticationMethodOptions = [ + { + key: 'none', + get value() { + return translate('None'); + }, + isDisabled: true + }, + { + key: 'external', + get value() { + return translate('External'); + }, + isHidden: true + }, + { + key: 'basic', + get value() { + return translate('AuthBasic'); + } + }, + { + key: 'forms', + get value() { + return translate('AuthForm'); + } + } +]; + +export const authenticationRequiredOptions = [ + { + key: 'enabled', + get value() { + return translate('Enabled'); + } + }, + { + key: 'disabledForLocalAddresses', + get value() { + return translate('DisabledForLocalAddresses'); + } + } ]; const certificateValidationOptions = [ - { key: 'enabled', value: 'Enabled' }, - { key: 'disabledForLocalAddresses', value: 'Disabled for Local Addresses' }, - { key: 'disabled', value: 'Disabled' } + { + key: 'enabled', + get value() { + return translate('Enabled'); + } + }, + { + key: 'disabledForLocalAddresses', + get value() { + return translate('DisabledForLocalAddresses'); + } + }, + { + key: 'disabled', + get value() { + return translate('Disabled'); + } + } ]; class SecuritySettings extends Component { @@ -68,8 +121,10 @@ class SecuritySettings extends Component { const { authenticationMethod, + authenticationRequired, username, password, + passwordConfirmation, apiKey, certificateValidation } = settings; @@ -88,13 +143,31 @@ class SecuritySettings extends Component { name="authenticationMethod" values={authenticationMethodOptions} helpText={translate('AuthenticationMethodHelpText')} + helpTextWarning={translate('AuthenticationRequiredWarning')} onChange={onInputChange} {...authenticationMethod} /> { - authenticationEnabled && + authenticationEnabled ? + + {translate('AuthenticationRequired')} + + + : + null + } + + { + authenticationEnabled ? {translate('Username')} @@ -106,11 +179,12 @@ class SecuritySettings extends Component { onChange={onInputChange} {...username} /> - + : + null } { - authenticationEnabled && + authenticationEnabled ? {translate('Password')} @@ -122,7 +196,23 @@ class SecuritySettings extends Component { onChange={onInputChange} {...password} /> - + : + null + } + + { + authenticationEnabled ? + + {translate('PasswordConfirmation')} + + + : + null } diff --git a/frontend/src/Settings/General/UpdateSettings.js b/frontend/src/Settings/General/UpdateSettings.js index 0d7c0cbba..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/ImportListExclusions/ImportListExclusion.css b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.css index 349ebc925..7c603a8b5 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.css +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusion.css @@ -8,11 +8,13 @@ } .artistName { - flex: 0 0 300px; + @add-mixin truncate; + + flex: 0 1 600px; } .foreignId { - flex: 0 0 400px; + flex: 0 0 290px; } .actions { diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css index 99e1c1e99..0e3f43824 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css @@ -4,12 +4,12 @@ font-weight: bold; } -.host { - flex: 0 0 300px; +.name { + flex: 0 1 600px; } -.path { - flex: 0 0 400px; +.foreignId { + flex: 0 0 290px; } .addImportListExclusion { diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css.d.ts b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css.d.ts index bf7ee773c..397310d60 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css.d.ts +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.css.d.ts @@ -3,9 +3,9 @@ interface CssExports { 'addButton': string; 'addImportListExclusion': string; - 'host': string; + 'foreignId': string; 'importListExclusionsHeader': string; - 'path': string; + 'name': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.js b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.js index fa1f5370a..12e3feabc 100644 --- a/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.js +++ b/frontend/src/Settings/ImportLists/ImportListExclusions/ImportListExclusions.js @@ -51,8 +51,10 @@ class ImportListExclusions extends Component { {...otherProps} >
-
{translate('Name')}
-
+
+ {translate('Name')} +
+
{translate('ForeignId')}
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 37c4a3153..997d1b566 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx @@ -10,6 +10,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 Column from 'Components/Table/Column'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import useSelectState from 'Helpers/Hooks/useSelectState'; @@ -17,6 +18,7 @@ import { kinds } from 'Helpers/Props'; import { bulkDeleteIndexers, bulkEditIndexers, + setManageIndexersSort, } from 'Store/Actions/settingsActions'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import { SelectStateInputProps } from 'typings/props'; @@ -33,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps< typeof ManageIndexersModalRow >['onSelectedChange']; -const COLUMNS = [ +const COLUMNS: Column[] = [ { name: 'name', label: () => translate('Name'), @@ -92,6 +94,8 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) { isSaving, error, items, + sortKey, + sortDirection, }: IndexerAppState = useSelector( createClientSideCollectionSelector('settings.indexers') ); @@ -112,6 +116,13 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) { const selectedCount = selectedIds.length; + const onSortPress = useCallback( + (value: string) => { + dispatch(setManageIndexersSort({ sortKey: value })); + }, + [dispatch] + ); + const onDeletePress = useCallback(() => { setIsDeleteModalOpen(true); }, [setIsDeleteModalOpen]); @@ -202,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 ? (
{items.map((item) => { diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js index d38bc7224..627263fff 100644 --- a/frontend/src/Settings/MediaManagement/MediaManagement.js +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -191,26 +191,21 @@ class MediaManagement extends Component {
- { - !isWindows && - - - {translate('SkipFreeSpaceCheck')} - + + {translate('SkipFreeSpaceCheck')} - - - } + + { - settings.importExtraFiles.value && + settings.importExtraFiles.value ? - - {translate('ImportExtraFiles')} - + {translate('ImportExtraFiles')} - + : null }
} diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js index 914e8d4c5..8f96fabfb 100644 --- a/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js +++ b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js @@ -1,4 +1,3 @@ -import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; @@ -15,11 +14,11 @@ function createMapStateToProps() { (state) => 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} - : + 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 f9d303498..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(odd) { + &:nth-child(3n + 1) { background-color: #ddd; } } -.handle { +.thumb { top: 1px; z-index: 0 !important; width: 18px; @@ -56,7 +56,7 @@ .kilobitsPerSecond { display: flex; justify-content: space-between; - flex: 0 0 250px; + flex: 0 0 400px; } .sizeInput { 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 a289631bc..48251abfb 100644 --- a/frontend/src/Settings/Quality/Definition/QualityDefinition.js +++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js @@ -50,21 +50,45 @@ class QualityDefinition extends Component { this.state = { sliderMinSize: getSliderValue(props.minSize, slider.min), - sliderMaxSize: getSliderValue(props.maxSize, slider.max) + sliderMaxSize: getSliderValue(props.maxSize, slider.max), + sliderPreferredSize: getSliderValue(props.preferredSize, (slider.max - 3)) }; } + // + // Control + + trackRenderer(props, state) { + return ( +
+ ); + } + + thumbRenderer(props, state) { + return ( +
+ ); + } + // // Listeners - onSliderChange = ([sliderMinSize, sliderMaxSize]) => { + onSliderChange = ([sliderMinSize, sliderPreferredSize, sliderMaxSize]) => { this.setState({ sliderMinSize, - sliderMaxSize + sliderMaxSize, + sliderPreferredSize }); this.props.onSizeChange({ minSize: roundNumber(Math.pow(sliderMinSize, 1.1)), + preferredSize: sliderPreferredSize === (slider.max - 3) ? null : roundNumber(Math.pow(sliderPreferredSize, 1.1)), maxSize: sliderMaxSize === slider.max ? null : roundNumber(Math.pow(sliderMaxSize, 1.1)) }); }; @@ -72,12 +96,14 @@ class QualityDefinition extends Component { onAfterSliderChange = () => { const { minSize, - maxSize + maxSize, + preferredSize } = this.props; this.setState({ sliderMiSize: getSliderValue(minSize, slider.min), - sliderMaxSize: getSliderValue(maxSize, slider.max) + sliderMaxSize: getSliderValue(maxSize, slider.max), + sliderPreferredSize: getSliderValue(preferredSize, (slider.max - 3)) // fix }); }; @@ -90,7 +116,22 @@ class QualityDefinition extends Component { this.props.onSizeChange({ minSize, - maxSize: this.props.maxSize + maxSize: this.props.maxSize, + preferredSize: this.props.preferredSize + }); + }; + + onPreferredSizeChange = ({ value }) => { + const preferredSize = value === (MAX - 3) ? null : getValue(value); + + this.setState({ + sliderPreferredSize: getSliderValue(preferredSize, slider.preferred) + }); + + this.props.onSizeChange({ + minSize: this.props.minSize, + maxSize: this.props.maxSize, + preferredSize }); }; @@ -103,7 +144,8 @@ class QualityDefinition extends Component { this.props.onSizeChange({ minSize: this.props.minSize, - maxSize + maxSize, + preferredSize: this.props.preferredSize }); }; @@ -117,20 +159,25 @@ class QualityDefinition extends Component { title, minSize, maxSize, + preferredSize, advancedSettings, onTitleChange } = this.props; const { sliderMinSize, - sliderMaxSize + sliderMaxSize, + sliderPreferredSize } = this.state; const minBytes = minSize * 128; - const maxBytes = maxSize && maxSize * 128; - const minRate = `${formatBytes(minBytes, true)}/s`; - const maxRate = maxBytes ? `${formatBytes(maxBytes, true)}/s` : 'Unlimited'; + + const preferredBytes = preferredSize * 128; + const preferredRate = preferredBytes ? `${formatBytes(preferredBytes, true)}/s` : translate('Unlimited'); + + const maxBytes = maxSize && maxSize * 128; + const maxRate = maxBytes ? `${formatBytes(maxBytes, true)}/s` : translate('Unlimited'); return (
@@ -148,16 +195,18 @@ class QualityDefinition extends Component {
@@ -172,7 +221,23 @@ class QualityDefinition extends Component { body={ + } + position={tooltipPositions.BOTTOM} + /> +
+ +
+ {preferredRate} + } + title={translate('PreferredSize')} + body={ + } position={tooltipPositions.BOTTOM} @@ -188,7 +253,7 @@ class QualityDefinition extends Component { body={ } position={tooltipPositions.BOTTOM} @@ -201,14 +266,14 @@ class QualityDefinition extends Component { advancedSettings &&
- Min + {translate('Min')}
- Max + {translate('Preferred')} + + +
+ +
+ {translate('Max')} { + onSizeChange = ({ minSize, maxSize, preferredSize }) => { const { id, minSize: currentMinSize, - maxSize: currentMaxSize + maxSize: currentMaxSize, + preferredSize: currentPreferredSize } = this.props; if (minSize !== currentMinSize) { @@ -37,6 +38,10 @@ class QualityDefinitionConnector extends Component { if (maxSize !== currentMaxSize) { this.props.setQualityDefinitionValue({ id, name: 'maxSize', value: maxSize }); } + + if (preferredSize !== currentPreferredSize) { + this.props.setQualityDefinitionValue({ id, name: 'preferredSize', value: preferredSize }); + } }; // @@ -57,6 +62,7 @@ QualityDefinitionConnector.propTypes = { id: PropTypes.number.isRequired, minSize: PropTypes.number, maxSize: PropTypes.number, + preferredSize: PropTypes.number, setQualityDefinitionValue: PropTypes.func.isRequired, clearPendingChanges: PropTypes.func.isRequired }; 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/AutoTagging.css b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css new file mode 100644 index 000000000..b1e2de95b --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css @@ -0,0 +1,38 @@ +.autoTagging { + composes: card from '~Components/Card.css'; + + width: 300px; +} + +.nameContainer { + display: flex; + justify-content: space-between; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.cloneButton { + composes: button from '~Components/Link/IconButton.css'; + + height: 36px; +} + +.formats { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} + +.tooltipLabel { + composes: label from '~Components/Label.css'; + + margin: 0; + border: none; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css.d.ts new file mode 100644 index 000000000..b6b665429 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'autoTagging': string; + 'cloneButton': string; + 'formats': string; + 'name': string; + 'nameContainer': string; + 'tooltipLabel': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js new file mode 100644 index 000000000..760273cb3 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js @@ -0,0 +1,136 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TagList from 'Components/TagList'; +import { icons, kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import EditAutoTaggingModal from './EditAutoTaggingModal'; +import styles from './AutoTagging.css'; + +export default function AutoTagging(props) { + const { + id, + name, + tags, + tagList, + specifications, + isDeleting, + onConfirmDeleteAutoTagging, + onCloneAutoTaggingPress + } = props; + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onDeletePress = useCallback(() => { + setIsEditModalOpen(false); + setIsDeleteModalOpen(true); + }, [setIsEditModalOpen, setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onConfirmDelete = useCallback(() => { + onConfirmDeleteAutoTagging(id); + }, [id, onConfirmDeleteAutoTagging]); + + const onClonePress = useCallback(() => { + onCloneAutoTaggingPress(id); + }, [id, onCloneAutoTaggingPress]); + + return ( + +
+
+ {name} +
+ +
+ +
+
+ + + +
+ { + specifications.map((item, index) => { + if (!item) { + return null; + } + + let kind = kinds.DEFAULT; + if (item.required) { + kind = kinds.SUCCESS; + } + if (item.negate) { + kind = kinds.DANGER; + } + + return ( + + ); + }) + } +
+ + + + +
+ ); +} + +AutoTagging.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + specifications: PropTypes.arrayOf(PropTypes.object).isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteAutoTagging: PropTypes.func.isRequired, + onCloneAutoTaggingPress: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css new file mode 100644 index 000000000..40950bd5f --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css @@ -0,0 +1,21 @@ +.autoTaggings { + display: flex; + flex-wrap: wrap; +} + +.addAutoTagging { + composes: autoTagging from '~./AutoTagging.css'; + + background-color: var(--cardAlternateBackgroundColor); + color: var(--gray); + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid var(--borderColor); + border-radius: 4px; + background-color: var(--cardCenterBackgroundColor); +} diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css.d.ts new file mode 100644 index 000000000..ef3094d3b --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'addAutoTagging': string; + 'autoTaggings': string; + 'center': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js new file mode 100644 index 000000000..005547bb7 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js @@ -0,0 +1,108 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Card from 'Components/Card'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import { icons } from 'Helpers/Props'; +import { + cloneAutoTagging, + deleteAutoTagging, + fetchAutoTaggings, + fetchRootFolders +} from 'Store/Actions/settingsActions'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import sortByProp from 'Utilities/Array/sortByProp'; +import translate from 'Utilities/String/translate'; +import AutoTagging from './AutoTagging'; +import EditAutoTaggingModal from './EditAutoTaggingModal'; +import styles from './AutoTaggings.css'; + +export default function AutoTaggings() { + const { + error, + items, + isDeleting, + isFetching, + isPopulated + } = useSelector( + createSortedSectionSelector('settings.autoTaggings', sortByProp('name')) + ); + + const tagList = useSelector(createTagsSelector()); + const dispatch = useDispatch(); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [tagsFromId, setTagsFromId] = useState(undefined); + + const onClonePress = useCallback((id) => { + dispatch(cloneAutoTagging({ id })); + + setTagsFromId(id); + setIsEditModalOpen(true); + }, [dispatch, setIsEditModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback((id) => { + dispatch(deleteAutoTagging({ id })); + }, [dispatch]); + + useEffect(() => { + dispatch(fetchAutoTaggings()); + dispatch(fetchRootFolders()); + }, [dispatch]); + + return ( +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + +
+
+ ); +} diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js new file mode 100644 index 000000000..c6f810785 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditAutoTaggingModalContent from './EditAutoTaggingModalContent'; + +export default function EditAutoTaggingModal(props) { + const { + isOpen, + onModalClose: onOriginalModalClose, + ...otherProps + } = props; + + const dispatch = useDispatch(); + const [height, setHeight] = useState('auto'); + + const onContentHeightChange = useCallback((h) => { + if (height === 'auto' || h > height) { + setHeight(h); + } + }, [height, setHeight]); + + const onModalClose = useCallback(() => { + dispatch(clearPendingChanges({ section: 'settings.autoTaggings' })); + onOriginalModalClose(); + }, [dispatch, onOriginalModalClose]); + + return ( + + + + ); +} + +EditAutoTaggingModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css new file mode 100644 index 000000000..d503b0af3 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css @@ -0,0 +1,32 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} + +.rightButtons { + justify-content: flex-end; + margin-right: auto; +} + +.addSpecification { + composes: autoTagging from '~./AutoTagging.css'; + + background-color: var(--cardAlternateBackgroundColor); + color: var(--gray); + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid var(--borderColor); + 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 new file mode 100644 index 000000000..2a7f6b41e --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'addSpecification': string; + 'autoTaggings': string; + 'center': string; + 'deleteButton': string; + 'rightButtons': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js new file mode 100644 index 000000000..01a5e846b --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js @@ -0,0 +1,269 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Card from 'Components/Card'; +import FieldSet from 'Components/FieldSet'; +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 Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +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, inputTypes, kinds } from 'Helpers/Props'; +import { + cloneAutoTaggingSpecification, + deleteAutoTaggingSpecification, + fetchAutoTaggingSpecifications, + saveAutoTagging, + setAutoTaggingValue +} from 'Store/Actions/settingsActions'; +import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector'; +import translate from 'Utilities/String/translate'; +import AddSpecificationModal from './Specifications/AddSpecificationModal'; +import EditSpecificationModal from './Specifications/EditSpecificationModal'; +import Specification from './Specifications/Specification'; +import styles from './EditAutoTaggingModalContent.css'; + +export default function EditAutoTaggingModalContent(props) { + const { + id, + tagsFromId, + onModalClose, + onDeleteAutoTaggingPress + } = props; + + const { + error, + item, + isFetching, + isSaving, + saveError, + validationErrors, + validationWarnings + } = useSelector(createProviderSettingsSelectorHook('autoTaggings', id)); + + const { + isPopulated: specificationsPopulated, + items: specifications + } = useSelector((state) => state.settings.autoTaggingSpecifications); + + const dispatch = useDispatch(); + const [isAddSpecificationModalOpen, setIsAddSpecificationModalOpen] = useState(false); + const [isEditSpecificationModalOpen, setIsEditSpecificationModalOpen] = useState(false); + // const [isImportAutoTaggingModalOpen, setIsImportAutoTaggingModalOpen] = useState(false); + + const onAddSpecificationPress = useCallback(() => { + setIsAddSpecificationModalOpen(true); + }, [setIsAddSpecificationModalOpen]); + + const onAddSpecificationModalClose = useCallback(({ specificationSelected = false } = {}) => { + setIsAddSpecificationModalOpen(false); + setIsEditSpecificationModalOpen(specificationSelected); + }, [setIsAddSpecificationModalOpen]); + + const onEditSpecificationModalClose = useCallback(() => { + setIsEditSpecificationModalOpen(false); + }, [setIsEditSpecificationModalOpen]); + + const onInputChange = useCallback(({ name, value }) => { + dispatch(setAutoTaggingValue({ name, value })); + }, [dispatch]); + + const onSavePress = useCallback(() => { + dispatch(saveAutoTagging({ id })); + }, [dispatch, id]); + + const onCloneSpecificationPress = useCallback((specId) => { + dispatch(cloneAutoTaggingSpecification({ id: specId })); + }, [dispatch]); + + const onConfirmDeleteSpecification = useCallback((specId) => { + dispatch(deleteAutoTaggingSpecification({ id: specId })); + }, [dispatch]); + + useEffect(() => { + dispatch(fetchAutoTaggingSpecifications({ id: tagsFromId || id })); + }, [id, tagsFromId, dispatch]); + + const isSavingRef = useRef(); + + useEffect(() => { + if (isSavingRef.current && !isSaving && !saveError) { + onModalClose(); + } + + isSavingRef.current = isSaving; + }, [isSaving, saveError, onModalClose]); + + const { + name, + removeTagsAutomatically, + tags + } = item; + + return ( + + + + {id ? translate('EditAutoTag') : translate('AddAutoTag')} + + + +
+ { + isFetching ? : null + } + + { + !isFetching && !!error ? +
+ {translate('AddAutoTagError')} +
: + null + } + + { + !isFetching && !error && specificationsPopulated ? +
+
+ + + {translate('Name')} + + + + + + + {translate('RemoveTagsAutomatically')} + + + + + + {translate('Tags')} + + + + + +
+
+ { + specifications.map((tag) => { + return ( + + ); + }) + } + + +
+ +
+
+
+
+ + + + + + {/* */} + +
: + null + } +
+
+ +
+ { + id ? + : + null + } + + {/* */} +
+ + + + + {translate('Save')} + +
+
+ ); +} + +EditAutoTaggingModalContent.propTypes = { + id: PropTypes.number, + tagsFromId: PropTypes.number, + onModalClose: PropTypes.func.isRequired, + onDeleteAutoTaggingPress: PropTypes.func +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css new file mode 100644 index 000000000..eabcae750 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css @@ -0,0 +1,44 @@ +.specification { + composes: card from '~Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from '~Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from '~Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css.d.ts new file mode 100644 index 000000000..7f8a93de9 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css.d.ts @@ -0,0 +1,13 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'actions': string; + 'name': string; + 'overlay': string; + 'presetsMenu': string; + 'presetsMenuButton': string; + 'specification': string; + 'underlay': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js new file mode 100644 index 000000000..f6f2b134e --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import { sizes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import AddSpecificationPresetMenuItem from './AddSpecificationPresetMenuItem'; +import styles from './AddSpecificationItem.css'; + +export default function AddSpecificationItem(props) { + const { + implementation, + implementationName, + infoLink, + presets, + onSpecificationSelect + } = props; + + const onWrappedSpecificationSelect = useCallback(() => { + onSpecificationSelect({ implementation }); + }, [implementation, onSpecificationSelect]); + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets ? + + + + + + + + { + presets.map((preset, index) => { + return ( + + ); + }) + } + + + : + null + } + + { + infoLink ? + : + null + } +
+
+
+ ); +} + +AddSpecificationItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string, + presets: PropTypes.arrayOf(PropTypes.object), + onSpecificationSelect: PropTypes.func.isRequired +}; diff --git a/frontend/src/Artist/Editor/Tags/TagsModal.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js similarity index 58% rename from frontend/src/Artist/Editor/Tags/TagsModal.js rename to frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js index 0f6c2d7ec..1a8c115f0 100644 --- a/frontend/src/Artist/Editor/Tags/TagsModal.js +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js @@ -1,21 +1,15 @@ import PropTypes from 'prop-types'; import React from 'react'; import Modal from 'Components/Modal/Modal'; -import TagsModalContentConnector from './TagsModalContentConnector'; - -function TagsModal(props) { - const { - isOpen, - onModalClose, - ...otherProps - } = props; +import AddSpecificationModalContent from './AddSpecificationModalContent'; +function AddSpecificationModal({ isOpen, onModalClose, ...otherProps }) { return ( - @@ -23,9 +17,9 @@ function TagsModal(props) { ); } -TagsModal.propTypes = { +AddSpecificationModal.propTypes = { isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; -export default TagsModal; +export default AddSpecificationModal; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css new file mode 100644 index 000000000..d51349ea9 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css @@ -0,0 +1,5 @@ +.specifications { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css.d.ts new file mode 100644 index 000000000..83fbf5804 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'specifications': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js new file mode 100644 index 000000000..454a2591a --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Alert from 'Components/Alert'; +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 { kinds } from 'Helpers/Props'; +import { + fetchAutoTaggingSpecificationSchema, + selectAutoTaggingSpecificationSchema +} from 'Store/Actions/settingsActions'; +import translate from 'Utilities/String/translate'; +import AddSpecificationItem from './AddSpecificationItem'; +import styles from './AddSpecificationModalContent.css'; + +export default function AddSpecificationModalContent(props) { + const { + onModalClose + } = props; + + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = useSelector( + (state) => state.settings.autoTaggingSpecifications + ); + + const dispatch = useDispatch(); + + const onSpecificationSelect = useCallback(({ implementation, name }) => { + dispatch(selectAutoTaggingSpecificationSchema({ implementation, presetName: name })); + onModalClose({ specificationSelected: true }); + }, [dispatch, onModalClose]); + + useEffect(() => { + dispatch(fetchAutoTaggingSpecificationSchema()); + }, [dispatch]); + + return ( + + + {translate('AddCondition')} + + + + { + isSchemaFetching ? : null + } + + { + !isSchemaFetching && !!schemaError ? +
+ {translate('AddConditionError')} +
: + null + } + + { + isSchemaPopulated && !schemaError ? +
+ + +
+ {translate('SupportedAutoTaggingProperties')} +
+
+ +
+ { + schema.map((specification) => { + return ( + + ); + }) + } +
+ +
: + null + } +
+ + + + +
+ ); +} + +AddSpecificationModalContent.propTypes = { + onModalClose: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationPresetMenuItem.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationPresetMenuItem.js new file mode 100644 index 000000000..b043ddf06 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationPresetMenuItem.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +export default function AddSpecificationPresetMenuItem(props) { + const { + name, + implementation, + onPress, + ...otherProps + } = props; + + const onWrappedPress = useCallback(() => { + onPress({ + name, + implementation + }); + }, [name, implementation, onPress]); + + return ( + + {name} + + ); +} + +AddSpecificationPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js new file mode 100644 index 000000000..16ed4daec --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditSpecificationModalContent from './EditSpecificationModalContent'; + +function EditSpecificationModal({ isOpen, onModalClose, ...otherProps }) { + const dispatch = useDispatch(); + + const onWrappedModalClose = useCallback(() => { + dispatch(clearPendingChanges({ section: 'settings.autoTaggingSpecifications' })); + onModalClose(); + }, [onModalClose, dispatch]); + + return ( + + + + ); +} + +EditSpecificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditSpecificationModal; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css new file mode 100644 index 000000000..a2b6014df --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css.d.ts new file mode 100644 index 000000000..c5f0ef8a7 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js new file mode 100644 index 000000000..04302729b --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js @@ -0,0 +1,190 @@ +import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Alert from 'Components/Alert'; +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 ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import Button from 'Components/Link/Button'; +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'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds } from 'Helpers/Props'; +import { + clearAutoTaggingSpecificationPending, + saveAutoTaggingSpecification, + setAutoTaggingSpecificationFieldValue, + setAutoTaggingSpecificationValue +} from 'Store/Actions/settingsActions'; +import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector'; +import translate from 'Utilities/String/translate'; +import styles from './EditSpecificationModalContent.css'; + +function EditSpecificationModalContent(props) { + const { + id, + onDeleteSpecificationPress, + onModalClose + } = props; + + const advancedSettings = useSelector((state) => state.settings.advancedSettings); + + const { + item, + ...otherFormProps + } = useSelector( + createProviderSettingsSelectorHook('autoTaggingSpecifications', id) + ); + + const dispatch = useDispatch(); + + const onInputChange = useCallback(({ name, value }) => { + dispatch(setAutoTaggingSpecificationValue({ name, value })); + }, [dispatch]); + + const onFieldChange = useCallback(({ name, value }) => { + dispatch(setAutoTaggingSpecificationFieldValue({ name, value })); + }, [dispatch]); + + const onCancelPress = useCallback(({ name, value }) => { + dispatch(clearAutoTaggingSpecificationPending()); + onModalClose(); + }, [dispatch, onModalClose]); + + const onSavePress = useCallback(({ name, value }) => { + dispatch(saveAutoTaggingSpecification({ id })); + onModalClose(); + }, [dispatch, id, onModalClose]); + + const { + implementationName, + name, + negate, + required, + fields + } = item; + + return ( + + + {id ? translate('EditConditionImplementation', { implementationName }) : translate('AddConditionImplementation', { implementationName })} + + + +
+ { + fields && fields.some((x) => x.label === 'Regular Expression') && + +
+ +
+
+ +
+
+ +
+
+ } + + + + {translate('Name')} + + + + + + { + fields && fields.map((field) => { + return ( + + ); + }) + } + + + + {translate('Negate')} + + + + + + + + {translate('Required')} + + + + + +
+ + { + id ? + : + null + } + + + + + {translate('Save')} + + +
+ ); +} + +EditSpecificationModalContent.propTypes = { + id: PropTypes.number, + onDeleteSpecificationPress: PropTypes.func, + onModalClose: PropTypes.func.isRequired +}; + +export default EditSpecificationModalContent; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContentConnector.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContentConnector.js new file mode 100644 index 000000000..8f27b74e0 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContentConnector.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { clearAutoTaggingSpecificationPending, saveAutoTaggingSpecification, setAutoTaggingSpecificationFieldValue, setAutoTaggingSpecificationValue } from 'Store/Actions/settingsActions'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import EditSpecificationModalContent from './EditSpecificationModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('autoTaggingSpecifications'), + (advancedSettings, specification) => { + return { + advancedSettings, + ...specification + }; + } + ); +} + +const mapDispatchToProps = { + setAutoTaggingSpecificationValue, + setAutoTaggingSpecificationFieldValue, + saveAutoTaggingSpecification, + clearAutoTaggingSpecificationPending +}; + +class EditSpecificationModalContentConnector extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setAutoTaggingSpecificationValue({ name, value }); + }; + + onFieldChange = ({ name, value }) => { + this.props.setAutoTaggingSpecificationFieldValue({ name, value }); + }; + + onCancelPress = () => { + this.props.clearAutoTaggingSpecificationPending(); + this.props.onModalClose(); + }; + + onSavePress = () => { + this.props.saveAutoTaggingSpecification({ id: this.props.id }); + this.props.onModalClose(); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +EditSpecificationModalContentConnector.propTypes = { + id: PropTypes.number, + item: PropTypes.object.isRequired, + setAutoTaggingSpecificationValue: PropTypes.func.isRequired, + setAutoTaggingSpecificationFieldValue: PropTypes.func.isRequired, + clearAutoTaggingSpecificationPending: PropTypes.func.isRequired, + saveAutoTaggingSpecification: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditSpecificationModalContentConnector); diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css new file mode 100644 index 000000000..e329fc313 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css @@ -0,0 +1,38 @@ +.autoTagging { + composes: card from '~Components/Card.css'; + + width: 300px; +} + +.nameContainer { + display: flex; + justify-content: space-between; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.cloneButton { + composes: button from '~Components/Link/IconButton.css'; + + height: 36px; +} + +.labels { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} + +.tooltipLabel { + composes: label from '~Components/Label.css'; + + margin: 0; + border: none; +} diff --git a/frontend/src/AlbumStudio/AlbumStudio.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css.d.ts similarity index 53% rename from frontend/src/AlbumStudio/AlbumStudio.css.d.ts rename to frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css.d.ts index 9937ea245..b3229d715 100644 --- a/frontend/src/AlbumStudio/AlbumStudio.css.d.ts +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css.d.ts @@ -1,10 +1,12 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'contentBody': string; - 'contentBodyContainer': string; - 'pageContentBodyWrapper': string; - 'tableInnerContentBody': string; + 'autoTagging': string; + 'cloneButton': string; + 'labels': string; + 'name': string; + 'nameContainer': string; + 'tooltipLabel': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js new file mode 100644 index 000000000..21977e160 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js @@ -0,0 +1,122 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import { icons, kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import EditSpecificationModal from './EditSpecificationModal'; +import styles from './Specification.css'; + +export default function Specification(props) { + const { + id, + implementationName, + name, + required, + negate, + onConfirmDeleteSpecification, + onCloneSpecificationPress + } = props; + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onDeletePress = useCallback(() => { + setIsEditModalOpen(false); + setIsDeleteModalOpen(true); + }, [setIsEditModalOpen, setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onConfirmDelete = useCallback(() => { + onConfirmDeleteSpecification(id); + }, [id, onConfirmDeleteSpecification]); + + const onClonePress = useCallback(() => { + onCloneSpecificationPress(id); + }, [id, onCloneSpecificationPress]); + + return ( + +
+
+ {name} +
+ + +
+ +
+ + + { + negate ? + : + null + } + + { + required ? + : + null + } +
+ + + + +
+ ); +} + +Specification.propTypes = { + id: PropTypes.number.isRequired, + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + negate: PropTypes.bool.isRequired, + required: PropTypes.bool.isRequired, + fields: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteSpecification: PropTypes.func.isRequired, + onCloneSpecificationPress: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js index 4473ddfef..78372d5a3 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js @@ -23,6 +23,7 @@ function TagDetailsModalContent(props) { releaseProfiles, indexers, downloadClients, + autoTags, onModalClose, onDeleteTagPress } = props; @@ -197,6 +198,22 @@ function TagDetailsModalContent(props) { : null } + + { + autoTags.length ? +
+ { + autoTags.map((item) => { + return ( +
+ {item.name} +
+ ); + }) + } +
: + null + } @@ -232,6 +249,7 @@ TagDetailsModalContent.propTypes = { releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired, indexers: PropTypes.arrayOf(PropTypes.object).isRequired, downloadClients: PropTypes.arrayOf(PropTypes.object).isRequired, + autoTags: PropTypes.arrayOf(PropTypes.object).isRequired, onModalClose: PropTypes.func.isRequired, onDeleteTagPress: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js index d2342d52d..ddd70b253 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js @@ -85,6 +85,14 @@ function createMatchingDownloadClientsSelector() { ); } +function createMatchingAutoTagsSelector() { + return createSelector( + (state, { autoTagIds }) => autoTagIds, + (state) => state.settings.autoTaggings.items, + findMatchingItems + ); +} + function createMapStateToProps() { return createSelector( createMatchingArtistSelector(), @@ -94,7 +102,8 @@ function createMapStateToProps() { createMatchingReleaseProfilesSelector(), createMatchingIndexersSelector(), createMatchingDownloadClientsSelector(), - (artist, delayProfiles, importLists, notifications, releaseProfiles, indexers, downloadClients) => { + createMatchingAutoTagsSelector(), + (artist, delayProfiles, importLists, notifications, releaseProfiles, indexers, downloadClients, autoTags) => { return { artist, delayProfiles, @@ -102,7 +111,8 @@ function createMapStateToProps() { notifications, releaseProfiles, indexers, - downloadClients + downloadClients, + autoTags }; } ); diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js index 9a0ff0bff..525bf5844 100644 --- a/frontend/src/Settings/Tags/Tag.js +++ b/frontend/src/Settings/Tags/Tag.js @@ -5,6 +5,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import TagDetailsModal from './Details/TagDetailsModal'; +import TagInUse from './TagInUse'; import styles from './Tag.css'; class Tag extends Component { @@ -57,9 +58,10 @@ class Tag extends Component { importListIds, notificationIds, restrictionIds, - artistIds, indexerIds, - downloadClientIds + downloadClientIds, + autoTagIds, + artistIds } = this.props; const { @@ -72,9 +74,10 @@ class Tag extends Component { importListIds.length || notificationIds.length || restrictionIds.length || - artistIds.length || indexerIds.length || - downloadClientIds.length + downloadClientIds.length || + autoTagIds.length || + artistIds.length ); return ( @@ -88,63 +91,56 @@ class Tag extends Component {
{ - isTagUsed && + isTagUsed ?
- { - artistIds.length ? -
- {artistIds.length} artists -
: - null - } + - { - delayProfileIds.length ? -
- {delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'} -
: - null - } + - { - importListIds.length ? -
- {importListIds.length} import list{importListIds.length > 1 && 's'} -
: - null - } + - { - notificationIds.length ? -
- {notificationIds.length} connection{notificationIds.length > 1 && 's'} -
: - null - } + - { - restrictionIds.length ? -
- {restrictionIds.length} restriction{restrictionIds.length > 1 && 's'} -
: - null - } - { - indexerIds.length ? -
- {indexerIds.length} indexer{indexerIds.length > 1 && 's'} -
: - null - } + - { - downloadClientIds.length ? -
- {downloadClientIds.length} download client{indexerIds.length > 1 && 's'} -
: - null - } -
+ + + + + +
: + null } { @@ -164,6 +160,7 @@ class Tag extends Component { restrictionIds={restrictionIds} indexerIds={indexerIds} downloadClientIds={downloadClientIds} + autoTagIds={autoTagIds} isOpen={isDetailsModalOpen} onModalClose={this.onDetailsModalClose} onDeleteTagPress={this.onDeleteTagPress} @@ -173,7 +170,7 @@ class Tag extends Component { isOpen={isDeleteTagModalOpen} kind={kinds.DANGER} title={translate('DeleteTag')} - message={translate('DeleteTagMessageText', [label])} + message={translate('DeleteTagMessageText', { label })} confirmLabel={translate('Delete')} onConfirm={this.onConfirmDeleteTag} onCancel={this.onDeleteTagModalClose} @@ -190,9 +187,10 @@ Tag.propTypes = { importListIds: PropTypes.arrayOf(PropTypes.number).isRequired, notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired, restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired, - artistIds: PropTypes.arrayOf(PropTypes.number).isRequired, indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired, downloadClientIds: PropTypes.arrayOf(PropTypes.number).isRequired, + autoTagIds: PropTypes.arrayOf(PropTypes.number).isRequired, + artistIds: PropTypes.arrayOf(PropTypes.number).isRequired, onConfirmDeleteTag: PropTypes.func.isRequired }; @@ -201,9 +199,10 @@ Tag.defaultProps = { importListIds: [], notificationIds: [], restrictionIds: [], - artistIds: [], indexerIds: [], - downloadClientIds: [] + downloadClientIds: [], + autoTagIds: [], + artistIds: [] }; export default Tag; diff --git a/frontend/src/Settings/Tags/TagInUse.js b/frontend/src/Settings/Tags/TagInUse.js new file mode 100644 index 000000000..27228fa2e --- /dev/null +++ b/frontend/src/Settings/Tags/TagInUse.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +export default function TagInUse(props) { + const { + label, + labelPlural, + count + } = props; + + if (count === 0) { + return null; + } + + if (count > 1 && labelPlural) { + return ( +
+ {count} {labelPlural.toLowerCase()} +
+ ); + } + + return ( +
+ {count} {label.toLowerCase()} +
+ ); +} + +TagInUse.propTypes = { + label: PropTypes.string.isRequired, + labelPlural: PropTypes.string, + count: PropTypes.number.isRequired +}; diff --git a/frontend/src/Settings/Tags/TagSettings.js b/frontend/src/Settings/Tags/TagSettings.js index ad2e499eb..ca8672603 100644 --- a/frontend/src/Settings/Tags/TagSettings.js +++ b/frontend/src/Settings/Tags/TagSettings.js @@ -3,6 +3,7 @@ import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; +import AutoTaggings from './AutoTagging/AutoTaggings'; import TagsConnector from './TagsConnector'; function TagSettings() { @@ -14,6 +15,7 @@ function TagSettings() { + ); 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 f6219406f..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')} language.key === settings.uiLanguage.value) ? + settings.uiLanguage.errors : + [ + ...settings.uiLanguage.errors, + { message: translate('InvalidUILanguage') } + ]} />
diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js index a80ee1e45..f5ef10a4d 100644 --- a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js +++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js @@ -6,6 +6,8 @@ import getSectionState from 'Utilities/State/getSectionState'; import { set, updateServerSideCollection } from '../baseActions'; function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter) { + const [baseSection] = section.split('.'); + return function(getState, payload, dispatch) { dispatch(set({ section, isFetching: true })); @@ -25,10 +27,13 @@ function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter const { selectedFilterKey, - filters, - customFilters + filters } = sectionState; + const customFilters = getState().customFilters.items.filter((customFilter) => { + return customFilter.type === section || customFilter.type === baseSection; + }); + const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters); selectedFilters.forEach((filter) => { @@ -37,7 +42,8 @@ function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter const promise = createAjaxRequest({ url, - data + data, + traditional: true }).request; promise.done((response) => { diff --git a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js index dfe29ace8..3de794bdf 100644 --- a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js +++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js @@ -7,7 +7,7 @@ function createRemoveItemHandler(section, url) { return function(getState, payload, dispatch) { const { id, - ...queryParams + queryParams } = payload; dispatch(set({ section, isDeleting: true })); diff --git a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js index ca26883fb..e35157dbd 100644 --- a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js +++ b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js @@ -1,8 +1,11 @@ +import $ from 'jquery'; +import _ from 'lodash'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import getProviderState from 'Utilities/State/getProviderState'; import { set } from '../baseActions'; const abortCurrentRequests = {}; +let lastTestData = null; export function createCancelTestProviderHandler(section) { return function(getState, payload, dispatch) { @@ -17,10 +20,25 @@ function createTestProviderHandler(section, url) { return function(getState, payload, dispatch) { dispatch(set({ section, isTesting: true })); - const testData = getProviderState(payload, getState, section); + const { + queryParams = {}, + ...otherPayload + } = payload; + + const testData = getProviderState({ ...otherPayload }, getState, section); + const params = { ...queryParams }; + + // If the user is re-testing the same provider without changes + // force it to be tested. + + if (_.isEqual(testData, lastTestData)) { + params.forceTest = true; + } + + lastTestData = testData; const ajaxOptions = { - url: `${url}/test`, + url: `${url}/test?${$.param(params, true)}`, method: 'POST', contentType: 'application/json', dataType: 'json', @@ -32,6 +50,8 @@ function createTestProviderHandler(section, url) { abortCurrentRequests[section] = abortRequest; request.done((data) => { + lastTestData = null; + dispatch(set({ section, isTesting: false, diff --git a/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js b/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js new file mode 100644 index 000000000..cfc919c7d --- /dev/null +++ b/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js @@ -0,0 +1,193 @@ +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createClearReducer from 'Store/Actions/Creators/Reducers/createClearReducer'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import { createThunk } from 'Store/thunks'; +import getNextId from 'Utilities/State/getNextId'; +import getProviderState from 'Utilities/State/getProviderState'; +import getSectionState from 'Utilities/State/getSectionState'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import { removeItem, set, update, updateItem } from '../baseActions'; + +// +// Variables + +const section = 'settings.autoTaggingSpecifications'; + +// +// Actions Types + +export const FETCH_AUTO_TAGGING_SPECIFICATIONS = 'settings/autoTaggingSpecifications/fetchAutoTaggingSpecifications'; +export const FETCH_AUTO_TAGGING_SPECIFICATION_SCHEMA = 'settings/autoTaggingSpecifications/fetchAutoTaggingSpecificationSchema'; +export const SELECT_AUTO_TAGGING_SPECIFICATION_SCHEMA = 'settings/autoTaggingSpecifications/selectAutoTaggingSpecificationSchema'; +export const SET_AUTO_TAGGING_SPECIFICATION_VALUE = 'settings/autoTaggingSpecifications/setAutoTaggingSpecificationValue'; +export const SET_AUTO_TAGGING_SPECIFICATION_FIELD_VALUE = 'settings/autoTaggingSpecifications/setAutoTaggingSpecificationFieldValue'; +export const SAVE_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/saveAutoTaggingSpecification'; +export const DELETE_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/deleteAutoTaggingSpecification'; +export const DELETE_ALL_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/deleteAllAutoTaggingSpecification'; +export const CLONE_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/cloneAutoTaggingSpecification'; +export const CLEAR_AUTO_TAGGING_SPECIFICATIONS = 'settings/autoTaggingSpecifications/clearAutoTaggingSpecifications'; +export const CLEAR_AUTO_TAGGING_SPECIFICATION_PENDING = 'settings/autoTaggingSpecifications/clearAutoTaggingSpecificationPending'; +// +// Action Creators + +export const fetchAutoTaggingSpecifications = createThunk(FETCH_AUTO_TAGGING_SPECIFICATIONS); +export const fetchAutoTaggingSpecificationSchema = createThunk(FETCH_AUTO_TAGGING_SPECIFICATION_SCHEMA); +export const selectAutoTaggingSpecificationSchema = createAction(SELECT_AUTO_TAGGING_SPECIFICATION_SCHEMA); + +export const saveAutoTaggingSpecification = createThunk(SAVE_AUTO_TAGGING_SPECIFICATION); +export const deleteAutoTaggingSpecification = createThunk(DELETE_AUTO_TAGGING_SPECIFICATION); +export const deleteAllAutoTaggingSpecification = createThunk(DELETE_ALL_AUTO_TAGGING_SPECIFICATION); + +export const setAutoTaggingSpecificationValue = createAction(SET_AUTO_TAGGING_SPECIFICATION_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setAutoTaggingSpecificationFieldValue = createAction(SET_AUTO_TAGGING_SPECIFICATION_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const cloneAutoTaggingSpecification = createAction(CLONE_AUTO_TAGGING_SPECIFICATION); + +export const clearAutoTaggingSpecification = createAction(CLEAR_AUTO_TAGGING_SPECIFICATIONS); + +export const clearAutoTaggingSpecificationPending = createThunk(CLEAR_AUTO_TAGGING_SPECIFICATION_PENDING); + +// +// Details + +export default { + + // + // State + + defaultState: { + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_AUTO_TAGGING_SPECIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/autoTagging/schema'), + + [FETCH_AUTO_TAGGING_SPECIFICATIONS]: (getState, payload, dispatch) => { + let tags = []; + if (payload.id) { + const cfState = getSectionState(getState(), 'settings.autoTaggings', true); + const cf = cfState.items[cfState.itemMap[payload.id]]; + tags = cf.specifications.map((tag, i) => { + return { + id: i + 1, + ...tag + }; + }); + } + + dispatch(batchActions([ + update({ section, data: tags }), + set({ + section, + isPopulated: true + }) + ])); + }, + + [SAVE_AUTO_TAGGING_SPECIFICATION]: (getState, payload, dispatch) => { + const { + id, + ...otherPayload + } = payload; + + const saveData = getProviderState({ id, ...otherPayload }, getState, section, false); + + // we have to set id since not actually posting to server yet + if (!saveData.id) { + saveData.id = getNextId(getState().settings.autoTaggingSpecifications.items); + } + + dispatch(batchActions([ + updateItem({ section, ...saveData }), + set({ + section, + pendingChanges: {} + }) + ])); + }, + + [DELETE_AUTO_TAGGING_SPECIFICATION]: (getState, payload, dispatch) => { + const id = payload.id; + return dispatch(removeItem({ section, id })); + }, + + [DELETE_ALL_AUTO_TAGGING_SPECIFICATION]: (getState, payload, dispatch) => { + return dispatch(set({ + section, + items: [] + })); + }, + + [CLEAR_AUTO_TAGGING_SPECIFICATION_PENDING]: (getState, payload, dispatch) => { + return dispatch(set({ + section, + pendingChanges: {} + })); + } + }, + + // + // Reducers + + reducers: { + [SET_AUTO_TAGGING_SPECIFICATION_VALUE]: createSetSettingValueReducer(section), + [SET_AUTO_TAGGING_SPECIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_AUTO_TAGGING_SPECIFICATION_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + return selectedSchema; + }); + }, + + [CLONE_AUTO_TAGGING_SPECIFICATION]: function(state, { payload }) { + const id = payload.id; + const newState = getSectionState(state, section); + const items = newState.items; + const item = items.find((i) => i.id === id); + const newId = getNextId(newState.items); + const newItem = { + ...item, + id: newId, + name: `${item.name} - Copy` + }; + newState.items = [...items, newItem]; + newState.itemMap[newId] = newState.items.length - 1; + + return updateSectionState(state, section, newState); + }, + + [CLEAR_AUTO_TAGGING_SPECIFICATIONS]: createClearReducer(section, { + isPopulated: false, + error: null, + items: [] + }) + } +}; diff --git a/frontend/src/Store/Actions/Settings/autoTaggings.js b/frontend/src/Store/Actions/Settings/autoTaggings.js new file mode 100644 index 000000000..35b3d4149 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/autoTaggings.js @@ -0,0 +1,109 @@ +import { createAction } from 'redux-actions'; +import { set } from 'Store/Actions/baseActions'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; +import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import { createThunk } from 'Store/thunks'; +import getSectionState from 'Utilities/State/getSectionState'; +import updateSectionState from 'Utilities/State/updateSectionState'; + +// +// Variables + +const section = 'settings.autoTaggings'; + +// +// Actions Types + +export const FETCH_AUTO_TAGGINGS = 'settings/autoTaggings/fetchAutoTaggings'; +export const SAVE_AUTO_TAGGING = 'settings/autoTaggings/saveAutoTagging'; +export const DELETE_AUTO_TAGGING = 'settings/autoTaggings/deleteAutoTagging'; +export const SET_AUTO_TAGGING_VALUE = 'settings/autoTaggings/setAutoTaggingValue'; +export const CLONE_AUTO_TAGGING = 'settings/autoTaggings/cloneAutoTagging'; + +// +// Action Creators + +export const fetchAutoTaggings = createThunk(FETCH_AUTO_TAGGINGS); +export const saveAutoTagging = createThunk(SAVE_AUTO_TAGGING); +export const deleteAutoTagging = createThunk(DELETE_AUTO_TAGGING); + +export const setAutoTaggingValue = createAction(SET_AUTO_TAGGING_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const cloneAutoTagging = createAction(CLONE_AUTO_TAGGING); + +// +// Details + +export default { + + // + // State + + defaultState: { + isSchemaFetching: false, + isSchemaPopulated: false, + isFetching: false, + isPopulated: false, + schema: { + removeTagsAutomatically: false, + tags: [] + }, + error: null, + isDeleting: false, + deleteError: null, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + + // + // Action Handlers + + actionHandlers: { + [FETCH_AUTO_TAGGINGS]: createFetchHandler(section, '/autoTagging'), + + [DELETE_AUTO_TAGGING]: createRemoveItemHandler(section, '/autoTagging'), + + [SAVE_AUTO_TAGGING]: (getState, payload, dispatch) => { + // move the format tags in as a pending change + const state = getState(); + const pendingChanges = state.settings.autoTaggings.pendingChanges; + pendingChanges.specifications = state.settings.autoTaggingSpecifications.items; + dispatch(set({ + section, + pendingChanges + })); + + createSaveProviderHandler(section, '/autoTagging')(getState, payload, dispatch); + } + }, + + // + // Reducers + + reducers: { + [SET_AUTO_TAGGING_VALUE]: createSetSettingValueReducer(section), + + [CLONE_AUTO_TAGGING]: function(state, { payload }) { + const id = payload.id; + const newState = getSectionState(state, section); + const item = newState.items.find((i) => i.id === id); + const pendingChanges = { ...item, id: 0 }; + delete pendingChanges.id; + + pendingChanges.name = `${pendingChanges.name} - Copy`; + newState.pendingChanges = pendingChanges; + + return updateSectionState(state, section, newState); + } + } + +}; diff --git a/frontend/src/Store/Actions/Settings/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 12651039d..1113e7daf 100644 --- a/frontend/src/Store/Actions/Settings/downloadClients.js +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -1,4 +1,5 @@ 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'; @@ -7,6 +8,7 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import { createThunk } from 'Store/thunks'; @@ -33,6 +35,7 @@ export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestD export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients'; export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients'; export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients'; +export const SET_MANAGE_DOWNLOAD_CLIENTS_SORT = 'settings/downloadClients/setManageDownloadClientsSort'; // // Action Creators @@ -49,6 +52,7 @@ export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT) export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS); export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS); export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS); +export const setManageDownloadClientsSort = createAction(SET_MANAGE_DOWNLOAD_CLIENTS_SORT); export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => { return { @@ -88,7 +92,14 @@ export default { isTesting: false, isTestingAll: false, items: [], - pendingChanges: {} + pendingChanges: {}, + sortKey: 'name', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + name: ({ name }) => { + return name.toLocaleLowerCase(); + } + } }, // @@ -122,7 +133,10 @@ export default { return selectedSchema; }); - } + }, + + [SET_MANAGE_DOWNLOAD_CLIENTS_SORT]: createSetClientSideCollectionSortReducer(section) + } }; 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 c76750cf2..511a2e475 100644 --- a/frontend/src/Store/Actions/Settings/indexers.js +++ b/frontend/src/Store/Actions/Settings/indexers.js @@ -1,4 +1,5 @@ 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'; @@ -7,6 +8,7 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import { createThunk } from 'Store/thunks'; @@ -36,6 +38,7 @@ export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers'; export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers'; +export const SET_MANAGE_INDEXERS_SORT = 'settings/indexers/setManageIndexersSort'; // // Action Creators @@ -53,6 +56,7 @@ export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS); export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS); +export const setManageIndexersSort = createAction(SET_MANAGE_INDEXERS_SORT); export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { return { @@ -92,7 +96,14 @@ export default { isTesting: false, isTestingAll: false, items: [], - pendingChanges: {} + pendingChanges: {}, + sortKey: 'name', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + name: ({ name }) => { + return name.toLocaleLowerCase(); + } + } }, // @@ -142,7 +153,13 @@ export default { delete selectedSchema.name; selectedSchema.fields = selectedSchema.fields.map((field) => { - return { ...field }; + const newField = { ...field }; + + if (newField.privacy === 'apiKey' || newField.privacy === 'password') { + newField.value = ''; + } + + return newField; }); newState.selectedSchema = selectedSchema; @@ -153,7 +170,10 @@ export default { }; return updateSectionState(state, section, newState); - } + }, + + [SET_MANAGE_INDEXERS_SORT]: createSetClientSideCollectionSortReducer(section) + } }; 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/albumStudioActions.js b/frontend/src/Store/Actions/albumStudioActions.js deleted file mode 100644 index 0f543a7d6..000000000 --- a/frontend/src/Store/Actions/albumStudioActions.js +++ /dev/null @@ -1,167 +0,0 @@ -import { createAction } from 'redux-actions'; -import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; -import { createThunk, handleThunks } from 'Store/thunks'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; -import translate from 'Utilities/String/translate'; -import { fetchAlbums } from './albumActions'; -import { filterPredicates, filters } from './artistActions'; -import { set } from './baseActions'; -import createHandleActions from './Creators/createHandleActions'; -import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; -import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; - -// -// Variables - -export const section = 'albumStudio'; - -// -// State - -export const defaultState = { - isSaving: false, - saveError: null, - sortKey: 'sortName', - sortDirection: sortDirections.ASCENDING, - secondarySortKey: 'sortName', - secondarySortDirection: sortDirections.ASCENDING, - selectedFilterKey: 'all', - filters, - filterPredicates, - - filterBuilderProps: [ - { - name: 'monitored', - label: () => translate('Monitored'), - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.BOOL - }, - { - name: 'status', - label: () => translate('Status'), - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.ARTIST_STATUS - }, - { - name: 'artistType', - label: () => translate('ArtistType'), - type: filterBuilderTypes.EXACT - }, - { - name: 'qualityProfileId', - label: () => translate('QualityProfile'), - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.QUALITY_PROFILE - }, - { - name: 'metadataProfileId', - label: () => translate('MetadataProfile'), - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.METADATA_PROFILE - }, - { - name: 'rootFolderPath', - label: () => translate('RootFolderPath'), - type: filterBuilderTypes.EXACT - }, - { - name: 'tags', - label: () => translate('Tags'), - type: filterBuilderTypes.ARRAY, - valueType: filterBuilderValueTypes.TAG - } - ] -}; - -export const persistState = [ - 'albumStudio.sortKey', - 'albumStudio.sortDirection', - 'albumStudio.selectedFilterKey', - 'albumStudio.customFilters' -]; - -// -// Actions Types - -export const SET_ALBUM_STUDIO_SORT = 'albumStudio/setAlbumStudioSort'; -export const SET_ALBUM_STUDIO_FILTER = 'albumStudio/setAlbumStudioFilter'; -export const SAVE_ALBUM_STUDIO = 'albumStudio/saveAlbumStudio'; - -// -// Action Creators - -export const setAlbumStudioSort = createAction(SET_ALBUM_STUDIO_SORT); -export const setAlbumStudioFilter = createAction(SET_ALBUM_STUDIO_FILTER); -export const saveAlbumStudio = createThunk(SAVE_ALBUM_STUDIO); - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - - [SAVE_ALBUM_STUDIO]: function(getState, payload, dispatch) { - const { - artistIds, - monitored, - monitor, - monitorNewItems - } = payload; - - const artist = []; - - artistIds.forEach((id) => { - const artistToUpdate = { id }; - - if (payload.hasOwnProperty('monitored')) { - artistToUpdate.monitored = monitored; - } - - artist.push(artistToUpdate); - }); - - dispatch(set({ - section, - isSaving: true - })); - - const promise = createAjaxRequest({ - url: '/albumStudio', - method: 'POST', - data: JSON.stringify({ - artist, - monitoringOptions: { monitor }, - monitorNewItems - }), - dataType: 'json' - }).request; - - promise.done((data) => { - dispatch(fetchAlbums()); - - dispatch(set({ - section, - isSaving: false, - saveError: null - })); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isSaving: false, - saveError: xhr - })); - }); - } -}); - -// -// Reducers - -export const reducers = createHandleActions({ - - [SET_ALBUM_STUDIO_SORT]: createSetClientSideCollectionSortReducer(section), - [SET_ALBUM_STUDIO_FILTER]: createSetClientSideCollectionFilterReducer(section) - -}, defaultState, section); - diff --git a/frontend/src/Store/Actions/artistActions.js b/frontend/src/Store/Actions/artistActions.js index ee81624e0..89384bcc4 100644 --- a/frontend/src/Store/Actions/artistActions.js +++ b/frontend/src/Store/Actions/artistActions.js @@ -2,11 +2,12 @@ import _ from 'lodash'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props'; +import { fetchAlbums } from 'Store/Actions/albumActions'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate'; import translate from 'Utilities/String/translate'; -import { updateItem } from './baseActions'; +import { set, updateItem } from './baseActions'; import createFetchHandler from './Creators/createFetchHandler'; import createHandleActions from './Creators/createHandleActions'; import createRemoveItemHandler from './Creators/createRemoveItemHandler'; @@ -154,12 +155,21 @@ export const defaultState = { error: null, isSaving: false, saveError: null, + isDeleting: false, + deleteError: null, items: [], sortKey: 'sortName', sortDirection: sortDirections.ASCENDING, - pendingChanges: {} + pendingChanges: {}, + deleteOptions: { + addImportListExclusion: false + } }; +export const persistState = [ + 'artist.deleteOptions' +]; + // // Actions Types @@ -170,6 +180,11 @@ export const DELETE_ARTIST = 'artist/deleteArtist'; export const TOGGLE_ARTIST_MONITORED = 'artist/toggleArtistMonitored'; export const TOGGLE_ALBUM_MONITORED = 'artist/toggleAlbumMonitored'; +export const UPDATE_ARTISTS_MONITOR = 'artist/updateArtistsMonitor'; +export const SAVE_ARTIST_EDITOR = 'artist/saveArtistEditor'; +export const BULK_DELETE_ARTIST = 'artist/bulkDeleteArtist'; + +export const SET_DELETE_OPTION = 'artist/setDeleteOption'; // // Action Creators @@ -203,6 +218,9 @@ export const deleteArtist = createThunk(DELETE_ARTIST, (payload) => { export const toggleArtistMonitored = createThunk(TOGGLE_ARTIST_MONITORED); export const toggleAlbumMonitored = createThunk(TOGGLE_ALBUM_MONITORED); +export const updateArtistsMonitor = createThunk(UPDATE_ARTISTS_MONITOR); +export const saveArtistEditor = createThunk(SAVE_ARTIST_EDITOR); +export const bulkDeleteArtist = createThunk(BULK_DELETE_ARTIST); export const setArtistValue = createAction(SET_ARTIST_VALUE, (payload) => { return { @@ -211,6 +229,8 @@ export const setArtistValue = createAction(SET_ARTIST_VALUE, (payload) => { }; }); +export const setDeleteOption = createAction(SET_DELETE_OPTION); + // // Helpers @@ -331,8 +351,145 @@ export const actionHandlers = handleThunks({ seasons: artist.seasons })); }); - } + }, + [UPDATE_ARTISTS_MONITOR]: function(getState, payload, dispatch) { + const { + artistIds, + monitor, + monitored, + monitorNewItems, + shouldFetchAlbumsAfterUpdate = false + } = payload; + + const artists = []; + + artistIds.forEach((id) => { + const artistsToUpdate = { id }; + + if (monitored != null) { + artistsToUpdate.monitored = monitored; + } + + artists.push(artistsToUpdate); + }); + + dispatch(set({ + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: '/albumStudio', + method: 'POST', + data: JSON.stringify({ + artist: artists, + monitoringOptions: { monitor }, + monitorNewItems + }), + dataType: 'json' + }).request; + + promise.done((data) => { + if (shouldFetchAlbumsAfterUpdate) { + dispatch(fetchAlbums({ artistId: artistIds[0] })); + } + + dispatch(set({ + section, + isSaving: false, + saveError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }, + + [SAVE_ARTIST_EDITOR]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: '/artist/editor', + method: 'PUT', + data: JSON.stringify(payload), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(batchActions([ + ...data.map((artist) => { + + const { + images, + rootFolderPath, + statistics, + ...propsToUpdate + } = artist; + + return updateItem({ + id: artist.id, + section: 'artist', + ...propsToUpdate + }); + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }, + + [BULK_DELETE_ARTIST]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isDeleting: true + })); + + const promise = createAjaxRequest({ + url: '/artist/editor', + method: 'DELETE', + data: JSON.stringify(payload), + dataType: 'json' + }).request; + + promise.done(() => { + // SignaR will take care of removing the artist from the collection + + dispatch(set({ + section, + isDeleting: false, + deleteError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + } }); // @@ -340,6 +497,15 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ - [SET_ARTIST_VALUE]: createSetSettingValueReducer(section) + [SET_ARTIST_VALUE]: createSetSettingValueReducer(section), + + [SET_DELETE_OPTION]: (state, { payload }) => { + return { + ...state, + deleteOptions: { + ...payload + } + }; + } }, defaultState, section); diff --git a/frontend/src/Store/Actions/artistEditorActions.js b/frontend/src/Store/Actions/artistEditorActions.js deleted file mode 100644 index 2e3d5d1f7..000000000 --- a/frontend/src/Store/Actions/artistEditorActions.js +++ /dev/null @@ -1,251 +0,0 @@ -import { createAction } from 'redux-actions'; -import { batchActions } from 'redux-batched-actions'; -import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; -import { createThunk, handleThunks } from 'Store/thunks'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; -import translate from 'Utilities/String/translate'; -import { filterPredicates, filters, sortPredicates } from './artistActions'; -import { set, updateItem } from './baseActions'; -import createHandleActions from './Creators/createHandleActions'; -import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; -import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; -import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; - -// -// Variables - -export const section = 'artistEditor'; - -// -// State - -export const defaultState = { - isSaving: false, - saveError: null, - isDeleting: false, - deleteError: null, - sortKey: 'sortName', - sortDirection: sortDirections.ASCENDING, - secondarySortKey: 'sortName', - secondarySortDirection: sortDirections.ASCENDING, - selectedFilterKey: 'all', - filters, - filterPredicates, - - columns: [ - { - name: 'status', - columnLabel: () => translate('Status'), - isSortable: true, - isVisible: true, - isModifiable: false - }, - { - name: 'sortName', - label: () => translate('Name'), - isSortable: true, - isVisible: true - }, - { - name: 'monitorNewItems', - label: () => translate('MonitorNewItems'), - isSortable: true, - isVisible: true - }, - { - name: 'qualityProfileId', - label: () => translate('QualityProfile'), - isSortable: true, - isVisible: true - }, - { - name: 'metadataProfileId', - label: () => translate('MetadataProfile'), - isSortable: true, - isVisible: true - }, - { - name: 'path', - label: () => translate('Path'), - isSortable: true, - isVisible: true - }, - { - name: 'sizeOnDisk', - label: () => translate('SizeOnDisk'), - isSortable: true, - isVisible: false - }, - { - name: 'tags', - label: () => translate('Tags'), - isSortable: true, - isVisible: true - } - ], - - filterBuilderProps: [ - { - name: 'monitored', - label: () => translate('Monitored'), - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.BOOL - }, - { - name: 'status', - label: () => translate('Status'), - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.ARTIST_STATUS - }, - { - name: 'qualityProfileId', - label: () => translate('QualityProfile'), - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.QUALITY_PROFILE - }, - { - name: 'metadataProfileId', - label: () => translate('MetadataProfile'), - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.METADATA_PROFILE - }, - { - name: 'path', - label: () => translate('Path'), - type: filterBuilderTypes.STRING - }, - { - name: 'rootFolderPath', - label: () => translate('RootFolderPath'), - type: filterBuilderTypes.EXACT - }, - { - name: 'sizeOnDisk', - label: () => translate('SizeOnDisk'), - type: filterBuilderTypes.NUMBER, - valueType: filterBuilderValueTypes.BYTES - }, - { - name: 'tags', - label: () => translate('Tags'), - type: filterBuilderTypes.ARRAY, - valueType: filterBuilderValueTypes.TAG - } - ], - - sortPredicates -}; - -export const persistState = [ - 'artistEditor.sortKey', - 'artistEditor.sortDirection', - 'artistEditor.selectedFilterKey', - 'artistEditor.customFilters', - 'artistEditor.columns' -]; - -// -// Actions Types - -export const SET_ARTIST_EDITOR_SORT = 'artistEditor/setArtistEditorSort'; -export const SET_ARTIST_EDITOR_FILTER = 'artistEditor/setArtistEditorFilter'; -export const SAVE_ARTIST_EDITOR = 'artistEditor/saveArtistEditor'; -export const BULK_DELETE_ARTIST = 'artistEditor/bulkDeleteArtist'; -export const SET_ARTIST_EDITOR_TABLE_OPTION = 'artistEditor/setArtistEditorTableOption'; - -// -// Action Creators - -export const setArtistEditorSort = createAction(SET_ARTIST_EDITOR_SORT); -export const setArtistEditorFilter = createAction(SET_ARTIST_EDITOR_FILTER); -export const saveArtistEditor = createThunk(SAVE_ARTIST_EDITOR); -export const bulkDeleteArtist = createThunk(BULK_DELETE_ARTIST); -export const setArtistEditorTableOption = createAction(SET_ARTIST_EDITOR_TABLE_OPTION); - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - [SAVE_ARTIST_EDITOR]: function(getState, payload, dispatch) { - dispatch(set({ - section, - isSaving: true - })); - - const promise = createAjaxRequest({ - url: '/artist/editor', - method: 'PUT', - data: JSON.stringify(payload), - dataType: 'json' - }).request; - - promise.done((data) => { - dispatch(batchActions([ - ...data.map((artist) => { - return updateItem({ - id: artist.id, - section: 'artist', - ...artist - }); - }), - - set({ - section, - isSaving: false, - saveError: null - }) - ])); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isSaving: false, - saveError: xhr - })); - }); - }, - - [BULK_DELETE_ARTIST]: function(getState, payload, dispatch) { - dispatch(set({ - section, - isDeleting: true - })); - - const promise = createAjaxRequest({ - url: '/artist/editor', - method: 'DELETE', - data: JSON.stringify(payload), - dataType: 'json' - }).request; - - promise.done(() => { - // SignalR will take care of removing the artist from the collection - - dispatch(set({ - section, - isDeleting: false, - deleteError: null - })); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isDeleting: false, - deleteError: xhr - })); - }); - } -}); - -// -// Reducers - -export const reducers = createHandleActions({ - - [SET_ARTIST_EDITOR_TABLE_OPTION]: createSetTableOptionReducer(section), - [SET_ARTIST_EDITOR_SORT]: createSetClientSideCollectionSortReducer(section), - [SET_ARTIST_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(section) - -}, defaultState, section); diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js index e54b38df6..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'; @@ -39,6 +39,7 @@ export const defaultState = { showTitle: false, showMonitored: true, showQualityProfile: true, + showNextAlbum: true, showSearchAction: false }, @@ -93,6 +94,12 @@ export const defaultState = { isSortable: true, isVisible: false }, + { + name: 'monitorNewItems', + label: () => translate('MonitorNewItems'), + isSortable: true, + isVisible: false + }, { name: 'nextAlbum', label: () => translate('NextAlbum'), @@ -144,7 +151,7 @@ export const defaultState = { { name: 'genres', label: () => translate('Genres'), - isSortable: false, + isSortable: true, isVisible: false }, { @@ -175,7 +182,7 @@ export const defaultState = { const { trackCount = 0, - trackFileCount + trackFileCount = 0 } = statistics; const progress = trackCount ? trackFileCount / trackCount * 100 : 100; @@ -200,7 +207,7 @@ export const defaultState = { albumCount: function(item) { const { statistics = {} } = item; - return statistics.albumCount; + return statistics.albumCount || 0; }, trackCount: function(item) { @@ -228,7 +235,7 @@ export const defaultState = { const { trackCount = 0, - trackFileCount + trackFileCount = 0 } = statistics; const progress = trackCount ? @@ -266,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'), @@ -321,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 9dc212645..9d16d29c4 100644 --- a/frontend/src/Store/Actions/historyActions.js +++ b/frontend/src/Store/Actions/historyActions.js @@ -1,7 +1,7 @@ import React from 'react'; import { createAction } from 'redux-actions'; import Icon from 'Components/Icon'; -import { filterTypes, icons, sortDirections } from 'Helpers/Props'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, icons, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; @@ -60,7 +60,7 @@ export const defaultState = { }, { name: 'customFormats', - label: 'Formats', + label: () => translate('Formats'), isSortable: false, isVisible: true }, @@ -90,17 +90,12 @@ export const defaultState = { label: () => translate('SourceTitle'), isVisible: false }, - { - name: 'sourceTitle', - label: 'Source Title', - isVisible: false - }, { name: 'customFormatScore', - columnLabel: 'Custom Format Score', + columnLabel: () => translate('CustomFormatScore'), label: React.createElement(Icon, { name: icons.SCORE, - title: 'Custom format score' + title: () => translate('CustomFormatScore') }), isVisible: false }, @@ -155,7 +150,7 @@ export const defaultState = { }, { key: 'importFailed', - label: () => translate('ImportFailed'), + label: () => translate('ImportCompleteFailed'), filters: [ { key: 'eventType', @@ -219,6 +214,27 @@ export const defaultState = { } ] } + ], + + filterBuilderProps: [ + { + name: 'eventType', + label: () => translate('EventType'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE + }, + { + name: 'artistIds', + label: () => translate('Artist'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.ARTIST + }, + { + name: 'quality', + label: () => translate('Quality'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.QUALITY + } ] }; diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 577c19c6b..85fda482b 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -1,9 +1,8 @@ import * as albums from './albumActions'; import * as albumHistory from './albumHistoryActions'; -import * as albumStudio from './albumStudioActions'; +import * as albumSelection from './albumSelectionActions'; import * as app from './appActions'; import * as artist from './artistActions'; -import * as artistEditor from './artistEditorActions'; import * as artistHistory from './artistHistoryActions'; import * as artistIndex from './artistIndexActions'; import * as blocklist from './blocklistActions'; @@ -15,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'; @@ -30,28 +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, - albumStudio, - artist, - artistEditor, - artistHistory, - artistIndex, search, settings, system, diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js index e9008bb85..a250292c5 100644 --- a/frontend/src/Store/Actions/interactiveImportActions.js +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -16,8 +16,8 @@ import createSetClientSideCollectionSortReducer from './Creators/Reducers/create export const section = 'interactiveImport'; -const albumsSection = `${section}.albums`; const trackFilesSection = `${section}.trackFiles`; +let abortCurrentFetchRequest = null; let abortCurrentRequest = null; let currentIds = []; @@ -35,6 +35,8 @@ export const defaultState = { pendingChanges: {}, sortKey: 'path', sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'path', + secondarySortDirection: sortDirections.ASCENDING, recentFolders: [], importMode: 'chooseImportMode', sortPredicates: { @@ -55,15 +57,6 @@ export const defaultState = { } }, - albums: { - isFetching: false, - isPopulated: false, - error: null, - sortKey: 'albumTitle', - sortDirection: sortDirections.ASCENDING, - items: [] - }, - trackFiles: { isFetching: false, isPopulated: false, @@ -75,6 +68,8 @@ export const defaultState = { }; export const persistState = [ + 'interactiveImport.sortKey', + 'interactiveImport.sortDirection', 'interactiveImport.recentFolders', 'interactiveImport.importMode' ]; @@ -92,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'; @@ -112,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); @@ -123,6 +110,11 @@ export const clearInteractiveImportTrackFiles = createAction(CLEAR_INTERACTIVE_I // Action Handlers export const actionHandlers = handleThunks({ [FETCH_INTERACTIVE_IMPORT_ITEMS]: function(getState, payload, dispatch) { + if (abortCurrentFetchRequest) { + abortCurrentFetchRequest(); + abortCurrentFetchRequest = null; + } + if (!payload.downloadId && !payload.folder) { dispatch(set({ section, error: { message: '`downloadId` or `folder` is required.' } })); return; @@ -130,12 +122,14 @@ export const actionHandlers = handleThunks({ dispatch(set({ section, isFetching: true })); - const promise = createAjaxRequest({ + const { request, abortRequest } = createAjaxRequest({ url: '/manualimport', data: payload - }).request; + }); - promise.done((data) => { + abortCurrentFetchRequest = abortRequest; + + request.done((data) => { dispatch(batchActions([ update({ section, data }), @@ -148,7 +142,11 @@ export const actionHandlers = handleThunks({ ])); }); - promise.fail((xhr) => { + request.fail((xhr) => { + if (xhr.aborted) { + return; + } + dispatch(set({ section, isFetching: false, @@ -192,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, @@ -236,8 +235,6 @@ export const actionHandlers = handleThunks({ }); }, - [FETCH_INTERACTIVE_IMPORT_ALBUMS]: createFetchHandler(albumsSection, '/album'), - [FETCH_INTERACTIVE_IMPORT_TRACKFILES]: createFetchHandler(trackFilesSection, '/trackFile') }); @@ -319,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 dc20da73d..4bf200a5c 100644 --- a/frontend/src/Store/Actions/queueActions.js +++ b/frontend/src/Store/Actions/queueActions.js @@ -3,7 +3,7 @@ import React from 'react'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; import Icon from 'Components/Icon'; -import { icons, sortDirections } from 'Helpers/Props'; +import { filterBuilderTypes, filterBuilderValueTypes, icons, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; @@ -132,7 +132,7 @@ export const defaultState = { name: 'size', label: () => translate('Size'), isSortable: true, - isVisibile: false + isVisible: false }, { name: 'outputPath', @@ -146,6 +146,12 @@ export const defaultState = { isSortable: true, isVisible: true }, + { + name: 'added', + label: () => translate('Added'), + isSortable: true, + isVisible: false + }, { name: 'progress', label: () => translate('Progress'), @@ -158,6 +164,37 @@ export const defaultState = { isVisible: true, isModifiable: false } + ], + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + } + ], + + filterBuilderProps: [ + { + name: 'artistIds', + label: () => translate('Artist'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.ARTIST + }, + { + name: 'quality', + label: () => translate('Quality'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.QUALITY + }, + { + name: 'protocol', + label: () => translate('Protocol'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.PROTOCOL + } ] } }; @@ -167,7 +204,8 @@ export const persistState = [ 'queue.paged.pageSize', 'queue.paged.sortKey', 'queue.paged.sortDirection', - 'queue.paged.columns' + 'queue.paged.columns', + 'queue.paged.selectedFilterKey' ]; // @@ -192,6 +230,7 @@ export const GOTO_NEXT_QUEUE_PAGE = 'queue/gotoQueueNextPage'; export const GOTO_LAST_QUEUE_PAGE = 'queue/gotoQueueLastPage'; export const GOTO_QUEUE_PAGE = 'queue/gotoQueuePage'; export const SET_QUEUE_SORT = 'queue/setQueueSort'; +export const SET_QUEUE_FILTER = 'queue/setQueueFilter'; export const SET_QUEUE_TABLE_OPTION = 'queue/setQueueTableOption'; export const SET_QUEUE_OPTION = 'queue/setQueueOption'; export const CLEAR_QUEUE = 'queue/clearQueue'; @@ -216,6 +255,7 @@ export const gotoQueueNextPage = createThunk(GOTO_NEXT_QUEUE_PAGE); export const gotoQueueLastPage = createThunk(GOTO_LAST_QUEUE_PAGE); export const gotoQueuePage = createThunk(GOTO_QUEUE_PAGE); export const setQueueSort = createThunk(SET_QUEUE_SORT); +export const setQueueFilter = createThunk(SET_QUEUE_FILTER); export const setQueueTableOption = createAction(SET_QUEUE_TABLE_OPTION); export const setQueueOption = createAction(SET_QUEUE_OPTION); export const clearQueue = createAction(CLEAR_QUEUE); @@ -267,7 +307,8 @@ export const actionHandlers = handleThunks({ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_QUEUE_PAGE, [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_QUEUE_PAGE, [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_QUEUE_PAGE, - [serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT + [serverSideCollectionHandlers.SORT]: SET_QUEUE_SORT, + [serverSideCollectionHandlers.FILTER]: SET_QUEUE_FILTER }, fetchDataAugmenter ), @@ -371,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; @@ -395,7 +437,8 @@ export const actionHandlers = handleThunks({ ids, removeFromClient, blocklist, - skipRedownload + skipRedownload, + changeCategory } = payload; dispatch(batchActions([ @@ -411,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/searchActions.js b/frontend/src/Store/Actions/searchActions.js index c72cdc901..c2af6f47b 100644 --- a/frontend/src/Store/Actions/searchActions.js +++ b/frontend/src/Store/Actions/searchActions.js @@ -36,6 +36,8 @@ export const defaultState = { monitorNewItems: monitorNewItemsOptions[0].key, qualityProfileId: 0, metadataProfileId: 0, + searchForMissingAlbums: false, + searchForNewAlbum: false, tags: [] } }; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index a8af15174..54b059083 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -1,6 +1,8 @@ import { createAction } from 'redux-actions'; import { handleThunks } from 'Store/thunks'; import createHandleActions from './Creators/createHandleActions'; +import autoTaggings from './Settings/autoTaggings'; +import autoTaggingSpecifications from './Settings/autoTaggingSpecifications'; import customFormats from './Settings/customFormats'; import customFormatSpecifications from './Settings/customFormatSpecifications'; import delayProfiles from './Settings/delayProfiles'; @@ -9,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'; @@ -26,6 +29,8 @@ import remotePathMappings from './Settings/remotePathMappings'; import rootFolders from './Settings/rootFolders'; import ui from './Settings/ui'; +export * from './Settings/autoTaggingSpecifications'; +export * from './Settings/autoTaggings'; export * from './Settings/customFormatSpecifications.js'; export * from './Settings/customFormats'; export * from './Settings/delayProfiles'; @@ -34,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'; @@ -61,13 +67,15 @@ export const section = 'settings'; export const defaultState = { advancedSettings: false, - + autoTaggingSpecifications: autoTaggingSpecifications.defaultState, + autoTaggings: autoTaggings.defaultState, customFormatSpecifications: customFormatSpecifications.defaultState, customFormats: customFormats.defaultState, delayProfiles: delayProfiles.defaultState, downloadClients: downloadClients.defaultState, downloadClientOptions: downloadClientOptions.defaultState, general: general.defaultState, + indexerFlags: indexerFlags.defaultState, indexerOptions: indexerOptions.defaultState, indexers: indexers.defaultState, importLists: importLists.defaultState, @@ -106,12 +114,15 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS); // Action Handlers export const actionHandlers = handleThunks({ + ...autoTaggingSpecifications.actionHandlers, + ...autoTaggings.actionHandlers, ...customFormatSpecifications.actionHandlers, ...customFormats.actionHandlers, ...delayProfiles.actionHandlers, ...downloadClients.actionHandlers, ...downloadClientOptions.actionHandlers, ...general.actionHandlers, + ...indexerFlags.actionHandlers, ...indexerOptions.actionHandlers, ...indexers.actionHandlers, ...importLists.actionHandlers, @@ -141,12 +152,15 @@ export const reducers = createHandleActions({ return Object.assign({}, state, { advancedSettings: !state.advancedSettings }); }, + ...autoTaggingSpecifications.reducers, + ...autoTaggings.reducers, ...customFormatSpecifications.reducers, ...customFormats.reducers, ...delayProfiles.reducers, ...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 3522939eb..a71388c88 100644 --- a/frontend/src/Store/Actions/trackActions.js +++ b/frontend/src/Store/Actions/trackActions.js @@ -58,6 +58,11 @@ export const defaultState = { label: () => translate('AudioInfo'), isVisible: true }, + { + name: 'size', + label: () => translate('Size'), + isVisible: false + }, { name: 'customFormats', label: 'Formats', @@ -72,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 new file mode 100644 index 000000000..414a451f5 --- /dev/null +++ b/frontend/src/Store/Selectors/createArtistAlbumsSelector.ts @@ -0,0 +1,28 @@ +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'; + +function createArtistAlbumsSelector(artistId: number) { + return createSelector( + (state: AppState) => state.albums, + createArtistSelectorForHook(artistId), + (albums: AlbumAppState, artist = {} as Artist) => { + const { isFetching, isPopulated, error, items } = albums; + + const filteredAlbums = items.filter( + (album) => album.artistId === artist.id + ); + + return { + isFetching, + isPopulated, + error, + items: filteredAlbums, + }; + } + ); +} + +export default createArtistAlbumsSelector; 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.js b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js deleted file mode 100644 index de5205948..000000000 --- a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createSelector } from 'reselect'; -import createArtistSelector from './createArtistSelector'; - -function createArtistMetadataProfileSelector() { - return createSelector( - (state) => state.settings.metadataProfiles.items, - createArtistSelector(), - (metadataProfiles, artist = {}) => { - return metadataProfiles.find((profile) => { - return profile.id === artist.metadataProfileId; - }); - } - ); -} - -export default createArtistMetadataProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts new file mode 100644 index 000000000..fa60d936d --- /dev/null +++ b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts @@ -0,0 +1,19 @@ +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: MetadataProfile[], artist = {} as Artist) => { + return metadataProfiles.find((profile) => { + return profile.id === artist.metadataProfileId; + }); + } + ); +} + +export default createArtistMetadataProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js deleted file mode 100644 index 5819eb080..000000000 --- a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createSelector } from 'reselect'; -import createArtistSelector from './createArtistSelector'; - -function createArtistQualityProfileSelector() { - return createSelector( - (state) => state.settings.qualityProfiles.items, - createArtistSelector(), - (qualityProfiles, artist = {}) => { - return qualityProfiles.find((profile) => { - return profile.id === artist.qualityProfileId; - }); - } - ); -} - -export default createArtistQualityProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts new file mode 100644 index 000000000..67639919b --- /dev/null +++ b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts @@ -0,0 +1,19 @@ +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: QualityProfile[], artist = {} as Artist) => { + return qualityProfiles.find( + (profile) => profile.id === artist.qualityProfileId + ); + } + ); +} + +export default createArtistQualityProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistSelector.js b/frontend/src/Store/Selectors/createArtistSelector.js index 104ef83e3..c335f37f5 100644 --- a/frontend/src/Store/Selectors/createArtistSelector.js +++ b/frontend/src/Store/Selectors/createArtistSelector.js @@ -1,5 +1,15 @@ import { createSelector } from 'reselect'; +export function createArtistSelectorForHook(artistId) { + return createSelector( + (state) => state.artist.itemMap, + (state) => state.artist.items, + (itemMap, allArtists) => { + return artistId ? allArtists[itemMap[artistId]]: undefined; + } + ); +} + function createArtistSelector() { return createSelector( (state, { artistId }) => artistId, diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js index ae1031dca..1bac14f08 100644 --- a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js +++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js @@ -108,7 +108,7 @@ function sort(items, state) { return _.orderBy(items, clauses, orders); } -function createCustomFiltersSelector(type, alternateType) { +export function createCustomFiltersSelector(type, alternateType) { return createSelector( (state) => state.customFilters.items, (customFilters) => { 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/createDimensionsSelector.js b/frontend/src/Store/Selectors/createDimensionsSelector.ts similarity index 69% rename from frontend/src/Store/Selectors/createDimensionsSelector.js rename to frontend/src/Store/Selectors/createDimensionsSelector.ts index ce26b2e2c..b9602cb02 100644 --- a/frontend/src/Store/Selectors/createDimensionsSelector.js +++ b/frontend/src/Store/Selectors/createDimensionsSelector.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createDimensionsSelector() { return createSelector( - (state) => state.app.dimensions, + (state: AppState) => state.app.dimensions, (dimensions) => { return dimensions; } 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/createProviderSettingsSelector.js b/frontend/src/Store/Selectors/createProviderSettingsSelector.js index 46659609f..f5ac9bad5 100644 --- a/frontend/src/Store/Selectors/createProviderSettingsSelector.js +++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.js @@ -2,62 +2,70 @@ import _ from 'lodash'; import { createSelector } from 'reselect'; import selectSettings from 'Store/Selectors/selectSettings'; -function createProviderSettingsSelector(sectionName) { +function selector(id, section) { + if (!id) { + const item = _.isArray(section.schema) ? section.selectedSchema : section.schema; + const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError); + + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges, + ...settings, + item: settings.settings + }; + } + + const { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError); + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + ...settings, + item: settings.settings + }; +} + +export default function createProviderSettingsSelector(sectionName) { return createSelector( (state, { id }) => id, (state) => state.settings[sectionName], - (id, section) => { - if (!id) { - const item = _.isArray(section.schema) ? section.selectedSchema : section.schema; - const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError); - - const { - isSchemaFetching: isFetching, - isSchemaPopulated: isPopulated, - schemaError: error, - isSaving, - saveError, - isTesting, - pendingChanges - } = section; - - return { - isFetching, - isPopulated, - error, - isSaving, - saveError, - isTesting, - pendingChanges, - ...settings, - item: settings.settings - }; - } - - const { - isFetching, - isPopulated, - error, - isSaving, - saveError, - isTesting, - pendingChanges - } = section; - - const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError); - - return { - isFetching, - isPopulated, - error, - isSaving, - saveError, - isTesting, - ...settings, - item: settings.settings - }; - } + (id, section) => selector(id, section) + ); +} + +export function createProviderSettingsSelectorHook(sectionName, id) { + return createSelector( + (state) => state.settings[sectionName], + (section) => selector(id, section) ); } -export default createProviderSettingsSelector; 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/dark.js b/frontend/src/Styles/Themes/dark.js index 5e16185ca..7513139a4 100644 --- a/frontend/src/Styles/Themes/dark.js +++ b/frontend/src/Styles/Themes/dark.js @@ -39,7 +39,7 @@ module.exports = { themeDarkColor: '#494949', themeLightColor: '#595959', pageBackground: '#202020', - pageFooterBackgroud: 'rgba(0, 0, 0, .25)', + pageFooterBackground: 'rgba(0, 0, 0, .25)', torrentColor: '#00853d', usenetColor: '#17b1d9', 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/Themes/light.js b/frontend/src/Styles/Themes/light.js index b7d24f92a..ccf5dcea6 100644 --- a/frontend/src/Styles/Themes/light.js +++ b/frontend/src/Styles/Themes/light.js @@ -39,7 +39,7 @@ module.exports = { themeDarkColor: '#353535', themeLightColor: '#1d563d', pageBackground: '#f5f7fa', - pageFooterBackgroud: '#f1f1f1', + pageFooterBackground: '#f1f1f1', torrentColor: '#00853d', usenetColor: '#17b1d9', 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/Track/Track.ts b/frontend/src/Track/Track.ts new file mode 100644 index 000000000..7c080290a --- /dev/null +++ b/frontend/src/Track/Track.ts @@ -0,0 +1,19 @@ +import ModelBase from 'App/ModelBase'; + +interface Track extends ModelBase { + artistId: number; + foreignTrackId: string; + foreignRecordingId: string; + trackFileId: number; + albumId: number; + explicit: boolean; + absoluteTrackNumber: number; + trackNumber: string; + title: string; + duration: number; + trackFile?: object; + mediumNumber: number; + hasFile: boolean; +} + +export default Track; 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 new file mode 100644 index 000000000..ef4dc65f3 --- /dev/null +++ b/frontend/src/TrackFile/TrackFile.ts @@ -0,0 +1,19 @@ +import ModelBase from 'App/ModelBase'; +import { QualityModel } from 'Quality/Quality'; +import CustomFormat from 'typings/CustomFormat'; +import MediaInfo from 'typings/MediaInfo'; + +export interface TrackFile extends ModelBase { + artistId: number; + albumId: number; + path: string; + size: number; + dateAdded: string; + sceneName: string; + releaseGroup: string; + quality: QualityModel; + customFormats: CustomFormat[]; + indexerFlags: number; + mediaInfo: MediaInfo; + qualityCutoffNotMet: boolean; +} diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTable.js b/frontend/src/UnmappedFiles/UnmappedFilesTable.js index 817826cc1..a326f91e0 100644 --- a/frontend/src/UnmappedFiles/UnmappedFilesTable.js +++ b/frontend/src/UnmappedFiles/UnmappedFilesTable.js @@ -27,8 +27,9 @@ class UnmappedFilesTable extends Component { constructor(props, context) { super(props, context); + this.scrollerRef = React.createRef(); + this.state = { - scroller: null, allSelected: false, allUnselected: false, lastToggled: null, @@ -65,13 +66,6 @@ class UnmappedFilesTable extends Component { } } - // - // Control - - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - }; - getSelectedIds = () => { if (this.state.allUnselected) { return []; @@ -184,7 +178,6 @@ class UnmappedFilesTable extends Component { } = this.props; const { - scroller, allSelected, allUnselected, selectedState @@ -227,9 +220,7 @@ class UnmappedFilesTable extends Component { - + { isFetching && !isPopulated && @@ -243,11 +234,14 @@ class UnmappedFilesTable extends Component { } { - isPopulated && !error && !!items.length && scroller && + isPopulated && + !error && + !!items.length && + this.scrollerRef.current ? + /> : + null } diff --git a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js index d27cfd604..cec7fb09a 100644 --- a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js +++ b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js @@ -1,7 +1,5 @@ -import _ from 'lodash'; - export default function getIndexOfFirstCharacter(items, character) { - return _.findIndex(items, (item) => { + return items.findIndex((item) => { const firstCharacter = item.sortName.charAt(0); if (character === '#') { 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 0ccfa839b..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); } }); @@ -25,20 +25,24 @@ export async function fetchTranslations(): Promise { export default function translate( key: string, - tokens?: Record + tokens: Record = {} ) { - const translation = translations[key] || key; + const { isProduction = true } = window.Lidarr; - if (tokens) { - // Fallback to the old behaviour for translations not yet updated to use named tokens - Object.values(tokens).forEach((value, index) => { - tokens[index] = value; - }); - - return translation.replace(/\{([a-z0-9]+?)\}/gi, (match, tokenMatch) => - String(tokens[tokenMatch] ?? match) - ); + if (!isProduction && !(key in translations)) { + console.warn(`Missing translation for key: ${key}`); } - return translation; + const translation = translations[key] || key; + + tokens.appName = 'Lidarr'; + + // Fallback to the old behaviour for translations not yet updated to use named tokens + Object.values(tokens).forEach((value, index) => { + tokens[index] = value; + }); + + return translation.replace(/\{([a-z0-9]+?)\}/gi, (match, tokenMatch) => + String(tokens[tokenMatch] ?? match) + ); } 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/Utilities/Table/toggleSelected.js b/frontend/src/Utilities/Table/toggleSelected.js index dbc0d6223..ec8870b0b 100644 --- a/frontend/src/Utilities/Table/toggleSelected.js +++ b/frontend/src/Utilities/Table/toggleSelected.js @@ -1,29 +1,29 @@ import areAllSelected from './areAllSelected'; import getToggledRange from './getToggledRange'; -function toggleSelected(state, items, id, selected, shiftKey) { - const lastToggled = state.lastToggled; - const selectedState = { - ...state.selectedState, +function toggleSelected(selectedState, items, id, selected, shiftKey) { + const lastToggled = selectedState.lastToggled; + const nextSelectedState = { + ...selectedState.selectedState, [id]: selected }; if (selected == null) { - delete selectedState[id]; + delete nextSelectedState[id]; } if (shiftKey && lastToggled) { const { lower, upper } = getToggledRange(items, id, lastToggled); for (let i = lower; i < upper; i++) { - selectedState[items[i].id] = selected; + nextSelectedState[items[i].id] = selected; } } return { - ...areAllSelected(selectedState), + ...areAllSelected(nextSelectedState), lastToggled: id, - selectedState + selectedState: nextSelectedState }; } 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 9ee605335..1dd9870d1 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js @@ -53,7 +53,7 @@ class CutoffUnmetConnector extends Component { gotoCutoffUnmetFirstPage } = this.props; - registerPagePopulator(this.repopulate, ['trackFileUpdated']); + registerPagePopulator(this.repopulate, ['trackFileUpdated', 'trackFileDeleted']); if (useCurrentPage) { fetchCutoffUnmet(); @@ -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 ed4972022..008f1a149 100644 --- a/frontend/src/Wanted/Missing/MissingConnector.js +++ b/frontend/src/Wanted/Missing/MissingConnector.js @@ -50,7 +50,7 @@ class MissingConnector extends Component { gotoMissingFirstPage } = this.props; - registerPagePopulator(this.repopulate, ['trackFileUpdated']); + registerPagePopulator(this.repopulate, ['trackFileUpdated', 'trackFileDeleted']); if (useCurrentPage) { fetchMissing(); @@ -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 @@