diff --git a/.devcontainer/Lidarr.code-workspace b/.devcontainer/Lidarr.code-workspace new file mode 100644 index 000000000..a46158e44 --- /dev/null +++ b/.devcontainer/Lidarr.code-workspace @@ -0,0 +1,13 @@ +// This file is used to open the backend and frontend in the same workspace, which is necessary as +// the frontend has vscode settings that are distinct from the backend +{ + "folders": [ + { + "path": ".." + }, + { + "path": "../frontend" + } + ], + "settings": {} +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..d0fa03d5f --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet +{ + "name": "Lidarr", + "image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "nodeGypDependencies": true, + "version": "20", + "nvmVersion": "latest" + } + }, + "forwardPorts": [8686], + "customizations": { + "vscode": { + "extensions": ["esbenp.prettier-vscode"] + } + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 31f001e52..491815370 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -60,6 +60,7 @@ body: - Master - Develop - Nightly + - Plugins (experimental) - Other (This issue will be closed) validations: required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..f33a02cd1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/label-actions.yml b/.github/workflows/label-actions.yml index a7fc89446..a6246a6b3 100644 --- a/.github/workflows/label-actions.yml +++ b/.github/workflows/label-actions.yml @@ -12,6 +12,6 @@ jobs: action: runs-on: ubuntu-latest steps: - - uses: dessant/label-actions@v3 + - uses: dessant/label-actions@v4 with: process-only: 'issues' diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index cf38066c5..1d50cb1f1 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -9,7 +9,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v4 + - uses: dessant/lock-threads@v5 with: github-token: ${{ github.token }} issue-inactive-days: '90' diff --git a/.gitignore b/.gitignore index d2dc01467..a5d6bb7c8 100644 --- a/.gitignore +++ b/.gitignore @@ -121,11 +121,13 @@ _artifacts _rawPackage/ _dotTrace* _tests/ +_temp* *.Result.xml coverage*.xml coverage*.json setup/Output/ *.~is +.mono # VS outout folders bin @@ -138,12 +140,6 @@ project.fragment.lock.json artifacts/ **/Properties/launchSettings.json -#VS outout folders -bin -obj -output/* - - # macOS metadata files ._* .DS_Store @@ -162,34 +158,12 @@ Thumbs.db /tools/Addins/* packages.config.md5sum - -# Common IntelliJ Platform excludes - -# User specific -**/.idea/**/workspace.xml -**/.idea/**/tasks.xml -**/.idea/shelf/* -**/.idea/dictionaries -**/.idea/.idea.Radarr.Posix -**/.idea/.idea.Radarr.Windows - -# Sensitive or high-churn files -**/.idea/**/dataSources/ -**/.idea/**/dataSources.ids -**/.idea/**/dataSources.xml -**/.idea/**/dataSources.local.xml -**/.idea/**/sqlDataSources.xml -**/.idea/**/dynamic.xml - -# Rider -# Rider auto-generates .iml files, and contentModel.xml -**/.idea/**/*.iml -**/.idea/**/contentModel.xml -**/.idea/**/modules.xml - # ignore node_modules symlink node_modules node_modules.nosync # API doc generation .config/ + +# Ignore Jetbrains IntelliJ Workspace Directories +.idea/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..7a36fefe1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "ms-dotnettools.csdevkit", + "ms-vscode-remote.remote-containers" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..74b8d418b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md + "name": "Run Lidarr", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build dotnet", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/_output/net6.0/Lidarr", + "args": [], + "cwd": "${workspaceFolder}", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "integratedTerminal", + "stopAtEntry": false + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..4b3b00b89 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,44 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build dotnet", + "command": "dotnet", + "type": "process", + "args": [ + "msbuild", + "-restore", + "${workspaceFolder}/src/Lidarr.sln", + "-p:GenerateFullPaths=true", + "-p:Configuration=Debug", + "-p:Platform=Posix", + "-consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/src/Lidarr.sln", + "-property:GenerateFullPaths=true", + "-consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/Lidarr.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/README.md b/README.md index f5c8cdf84..6e643760f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Lidarr [![Build Status](https://dev.azure.com/Lidarr/Lidarr/_apis/build/status/lidarr.Lidarr?branchName=develop)](https://dev.azure.com/Lidarr/Lidarr/_build/latest?definitionId=1&branchName=develop) +[![Translation status](https://translate.servarr.com/widget/servarr/lidarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/?utm_source=widget) [![Docker Pulls](https://img.shields.io/docker/pulls/linuxserver/lidarr.svg)](https://wiki.servarr.com/lidarr/installation#docker) ![Github Downloads](https://img.shields.io/github/downloads/lidarr/lidarr/total.svg) [![Backers on Open Collective](https://opencollective.com/lidarr/backers/badge.svg)](#backers) @@ -8,6 +9,9 @@ Lidarr is a music collection manager for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new tracks from your favorite artists and will grab, sort and rename them. It can also be configured to automatically upgrade the quality of files already downloaded when a better quality format becomes available. +> [!WARNING] +> NOTICE - The Lidarr Metadata Server is currently down impacting adding artists, etc. Please follow [GHI 5498](https://github.com/Lidarr/Lidarr/issues/5498) or see Discord for detaila. + ## Major Features Include: * Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc. diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 643fb6b54..85d13499a 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,18 +9,18 @@ variables: testsFolder: './_tests' yarnCacheFolder: $(Pipeline.Workspace)/.yarn nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages - majorVersion: '2.2.0' + majorVersion: '2.13.3' minorVersion: $[counter('minorVersion', 1076)] lidarrVersion: '$(majorVersion).$(minorVersion)' buildName: '$(Build.SourceBranchName).$(lidarrVersion)' sentryOrg: 'servarr' sentryUrl: 'https://sentry.servarr.com' - dotnetVersion: '6.0.417' - nodeVersion: '16.X' + dotnetVersion: '6.0.427' + nodeVersion: '20.X' innoVersion: '6.2.0' windowsImage: 'windows-2022' - linuxImage: 'ubuntu-20.04' - macImage: 'macOS-11' + linuxImage: 'ubuntu-22.04' + macImage: 'macOS-13' trigger: branches: @@ -166,10 +166,10 @@ stages: pool: vmImage: $(imageName) steps: - - task: NodeTool@0 + - task: UseNode@1 displayName: Set Node.js version inputs: - versionSpec: $(nodeVersion) + version: $(nodeVersion) - checkout: self submodules: true fetchDepth: 1 @@ -1093,10 +1093,10 @@ stages: pool: vmImage: $(imageName) steps: - - task: NodeTool@0 + - task: UseNode@1 displayName: Set Node.js version inputs: - versionSpec: $(nodeVersion) + version: $(nodeVersion) - checkout: self submodules: true fetchDepth: 1 @@ -1120,19 +1120,19 @@ stages: vmImage: ${{ variables.windowsImage }} steps: - checkout: self # Need history for Sonar analysis - - task: SonarCloudPrepare@1 + - task: SonarCloudPrepare@3 env: SONAR_SCANNER_OPTS: '' inputs: SonarCloud: 'SonarCloud' organization: 'lidarr' - scannerMode: 'CLI' + scannerMode: 'cli' configMode: 'manual' cliProjectKey: 'lidarr_Lidarr.UI' cliProjectName: 'LidarrUI' cliProjectVersion: '$(lidarrVersion)' cliSources: './frontend' - - task: SonarCloudAnalyze@1 + - task: SonarCloudAnalyze@3 - job: Api_Docs displayName: API Docs @@ -1208,12 +1208,12 @@ stages: submodules: true - powershell: Set-Service SCardSvr -StartupType Manual displayName: Enable Windows Test Service - - task: SonarCloudPrepare@1 + - task: SonarCloudPrepare@3 condition: eq(variables['System.PullRequest.IsFork'], 'False') inputs: SonarCloud: 'SonarCloud' organization: 'lidarr' - scannerMode: 'MSBuild' + scannerMode: 'dotnet' projectKey: 'lidarr_Lidarr' projectName: 'Lidarr' projectVersion: '$(lidarrVersion)' @@ -1226,21 +1226,16 @@ stages: ./build.sh --backend -f net6.0 -r win-x64 TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage displayName: Coverage Unit Tests - - task: SonarCloudAnalyze@1 + - task: SonarCloudAnalyze@3 condition: eq(variables['System.PullRequest.IsFork'], 'False') displayName: Publish SonarCloud Results - - task: reportgenerator@4 + - task: reportgenerator@5.3.11 displayName: Generate Coverage Report inputs: reports: '$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml' targetdir: '$(Build.SourcesDirectory)/CoverageResults/combined' reporttypes: 'HtmlInline_AzurePipelines;Cobertura;Badges' - - task: PublishCodeCoverageResults@1 - displayName: Publish Coverage Report - inputs: - codeCoverageTool: 'cobertura' - summaryFileLocation: './CoverageResults/combined/Cobertura.xml' - reportDirectory: './CoverageResults/combined/' + publishCodeCoverageResults: true - stage: Report_Out dependsOn: diff --git a/distribution/debian/install.sh b/distribution/debian/install.sh index 6eff79eaa..b71eb20c9 100644 --- a/distribution/debian/install.sh +++ b/distribution/debian/install.sh @@ -59,7 +59,7 @@ 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 that user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories" +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 @@ -114,7 +114,7 @@ case "$ARCH" in esac echo "" echo "Removing previous tarballs" -# -f to Force so we fail if it doesnt exist +# -f to Force so we fail if it doesn't exist rm -f "${app^}".*.tar.gz echo "" echo "Downloading..." 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/build/webpack.config.js b/frontend/build/webpack.config.js index e0ec27c27..d1873380e 100644 --- a/frontend/build/webpack.config.js +++ b/frontend/build/webpack.config.js @@ -26,6 +26,7 @@ module.exports = (env) => { const config = { mode: isProduction ? 'production' : 'development', devtool: isProduction ? 'source-map' : 'eval-source-map', + target: 'web', stats: { children: false @@ -67,7 +68,7 @@ module.exports = (env) => { output: { path: distFolder, publicPath: '/', - filename: '[name]-[contenthash].js', + filename: isProduction ? '[name]-[contenthash].js' : '[name].js', sourceMapFilename: '[file].map' }, @@ -92,7 +93,7 @@ module.exports = (env) => { new MiniCssExtractPlugin({ filename: 'Content/styles.css', - chunkFilename: 'Content/[id]-[chunkhash].css' + chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css' }), new HtmlWebpackPlugin({ @@ -134,6 +135,12 @@ module.exports = (env) => { { source: 'frontend/src/Content/robots.txt', destination: path.join(distFolder, 'Content/robots.txt') + }, + + // manifest.json and browserconfig.xml + { + source: 'frontend/src/Content/*.(json|xml)', + destination: path.join(distFolder, 'Content') } ] } @@ -181,7 +188,7 @@ module.exports = (env) => { loose: true, debug: false, useBuiltIns: 'entry', - corejs: 3 + corejs: '3.41' } ] ] @@ -202,7 +209,7 @@ module.exports = (env) => { options: { importLoaders: 1, modules: { - localIdentName: '[name]/[local]/[hash:base64:5]' + localIdentName: isProduction ? '[name]/[local]/[hash:base64:5]' : '[name]/[local]' } } }, diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index f657adf28..89db00f8c 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -16,6 +16,7 @@ const mixinsFiles = [ module.exports = { plugins: [ + 'autoprefixer', ['postcss-mixins', { mixinsFiles }], diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js index b90a64f47..84aa3e0f2 100644 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -172,7 +172,8 @@ function HistoryDetails(props) { if (eventType === 'downloadFailed') { const { - message + message, + indexer } = data; return ( @@ -192,6 +193,14 @@ function HistoryDetails(props) { null } + { + indexer ? ( + + ) : null} + { message ? { diff --git a/frontend/src/AddArtist/ArtistMonitorNewItemsOptionsPopoverContent.js b/frontend/src/AddArtist/ArtistMonitorNewItemsOptionsPopoverContent.js index 5a837b1eb..cda224e2f 100644 --- a/frontend/src/AddArtist/ArtistMonitorNewItemsOptionsPopoverContent.js +++ b/frontend/src/AddArtist/ArtistMonitorNewItemsOptionsPopoverContent.js @@ -8,17 +8,17 @@ function ArtistMonitorNewItemsOptionsPopoverContent() { ); diff --git a/frontend/src/Album/Album.ts b/frontend/src/Album/Album.ts index c9f10a87c..86f1ed5fe 100644 --- a/frontend/src/Album/Album.ts +++ b/frontend/src/Album/Album.ts @@ -10,6 +10,7 @@ export interface Statistics { } interface Album extends ModelBase { + artistId: number; artist: Artist; foreignAlbumId: string; title: string; @@ -19,6 +20,7 @@ interface Album extends ModelBase { monitored: boolean; releaseDate: string; statistics: Statistics; + lastSearchTime?: string; isSaving?: boolean; } diff --git a/frontend/src/Album/AlbumTitleLink.js b/frontend/src/Album/AlbumTitleLink.js index 8b4dfe212..e55fadfc0 100644 --- a/frontend/src/Album/AlbumTitleLink.js +++ b/frontend/src/Album/AlbumTitleLink.js @@ -4,10 +4,11 @@ import Link from 'Components/Link/Link'; function AlbumTitleLink({ foreignAlbumId, title, disambiguation }) { const link = `/album/${foreignAlbumId}`; + const albumTitle = `${title}${disambiguation ? ` (${disambiguation})` : ''}`; return ( - - {title}{disambiguation ? ` (${disambiguation})` : ''} + + {albumTitle} ); } diff --git a/frontend/src/Album/Delete/DeleteAlbumModalContent.js b/frontend/src/Album/Delete/DeleteAlbumModalContent.js index e45985c97..28505ea75 100644 --- a/frontend/src/Album/Delete/DeleteAlbumModalContent.js +++ b/frontend/src/Album/Delete/DeleteAlbumModalContent.js @@ -53,7 +53,7 @@ class DeleteAlbumModalContent extends Component { render() { const { title, - statistics, + statistics = {}, onModalClose } = this.props; diff --git a/frontend/src/Album/Details/AlbumDetails.css b/frontend/src/Album/Details/AlbumDetails.css index d87920074..a676ae574 100644 --- a/frontend/src/Album/Details/AlbumDetails.css +++ b/frontend/src/Album/Details/AlbumDetails.css @@ -121,6 +121,8 @@ .releaseDate, .sizeOnDisk, +.albumType, +.secondaryTypes, .qualityProfileName, .links, .tags { diff --git a/frontend/src/Album/Details/AlbumDetails.css.d.ts b/frontend/src/Album/Details/AlbumDetails.css.d.ts index 4c126c8b5..1d14a0ccf 100644 --- a/frontend/src/Album/Details/AlbumDetails.css.d.ts +++ b/frontend/src/Album/Details/AlbumDetails.css.d.ts @@ -3,6 +3,7 @@ interface CssExports { 'albumNavigationButton': string; 'albumNavigationButtons': string; + 'albumType': string; 'alternateTitlesIconContainer': string; 'backdrop': string; 'backdropOverlay': string; @@ -20,6 +21,7 @@ interface CssExports { 'overview': string; 'qualityProfileName': string; 'releaseDate': string; + 'secondaryTypes': string; 'sizeOnDisk': string; 'tags': string; 'title': string; diff --git a/frontend/src/Album/Details/AlbumDetails.js b/frontend/src/Album/Details/AlbumDetails.js index 783216a61..fe007e168 100644 --- a/frontend/src/Album/Details/AlbumDetails.js +++ b/frontend/src/Album/Details/AlbumDetails.js @@ -192,6 +192,7 @@ class AlbumDetails extends Component { duration, overview, albumType, + secondaryTypes, statistics = {}, monitored, releaseDate, @@ -204,6 +205,7 @@ class AlbumDetails extends Component { isFetching, isPopulated, albumsError, + tracksError, trackFilesError, hasTrackFiles, shortDateFormat, @@ -396,10 +398,11 @@ class AlbumDetails extends Component {
{ - !!duration && + duration ? {formatDuration(duration)} - + : + null } - - - - {moment(releaseDate).format(shortDateFormat)} - +
+ + + {moment(releaseDate).format(shortDateFormat)} + +
- - - - { - formatBytes(sizeOnDisk || 0) - } - +
+ + + {formatBytes(sizeOnDisk)} + +
} tooltip={ @@ -459,32 +462,55 @@ class AlbumDetails extends Component { className={styles.detailsLabel} size={sizes.LARGE} > - - - - {monitored ? translate('Monitored') : translate('Unmonitored')} - +
+ + + {monitored ? translate('Monitored') : translate('Unmonitored')} + +
{ - !!albumType && + albumType ? : + null + } - - {albumType} - - + { + secondaryTypes.length ? + : + null } - - - - {translate('Links')} - +
+ + + {translate('Links')} + +
} tooltip={ @@ -526,8 +553,9 @@ class AlbumDetails extends Component {
{ - !isPopulated && !albumsError && !trackFilesError && - + !isPopulated && !albumsError && !tracksError && !trackFilesError ? + : + null } { @@ -538,6 +566,14 @@ class AlbumDetails extends Component { null } + { + !isFetching && tracksError ? + + {translate('TracksLoadError')} + : + null + } + { !isFetching && trackFilesError ? @@ -566,6 +602,14 @@ class AlbumDetails extends Component {
} + { + isPopulated && !media.length ? + + {translate('NoMediumInformation')} + : + null + } +
{ if (track.trackFileId) { - trackCount++; trackFileCount++; - } else { - trackCount++; } totalTrackCount++; diff --git a/frontend/src/Album/Details/TrackRow.css b/frontend/src/Album/Details/TrackRow.css index 11ebb64fa..912c00101 100644 --- a/frontend/src/Album/Details/TrackRow.css +++ b/frontend/src/Album/Details/TrackRow.css @@ -35,3 +35,9 @@ width: 55px; } + +.indexerFlags { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} diff --git a/frontend/src/Album/Details/TrackRow.css.d.ts b/frontend/src/Album/Details/TrackRow.css.d.ts index c5644a2d4..79bbdaf43 100644 --- a/frontend/src/Album/Details/TrackRow.css.d.ts +++ b/frontend/src/Album/Details/TrackRow.css.d.ts @@ -4,6 +4,7 @@ interface CssExports { 'audio': string; 'customFormatScore': string; 'duration': string; + 'indexerFlags': string; 'monitored': string; 'size': string; 'status': string; diff --git a/frontend/src/Album/Details/TrackRow.js b/frontend/src/Album/Details/TrackRow.js index 5f60df882..db128d493 100644 --- a/frontend/src/Album/Details/TrackRow.js +++ b/frontend/src/Album/Details/TrackRow.js @@ -2,15 +2,19 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import AlbumFormats from 'Album/AlbumFormats'; import EpisodeStatusConnector from 'Album/EpisodeStatusConnector'; +import IndexerFlags from 'Album/IndexerFlags'; +import Icon from 'Components/Icon'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; +import Popover from 'Components/Tooltip/Popover'; import Tooltip from 'Components/Tooltip/Tooltip'; -import { tooltipPositions } from 'Helpers/Props'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; import MediaInfoConnector from 'TrackFile/MediaInfoConnector'; import * as mediaInfoTypes from 'TrackFile/mediaInfoTypes'; import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; import formatBytes from 'Utilities/Number/formatBytes'; import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import translate from 'Utilities/String/translate'; import TrackActionsCell from './TrackActionsCell'; import styles from './TrackRow.css'; @@ -32,6 +36,7 @@ class TrackRow extends Component { trackFileSize, customFormats, customFormatScore, + indexerFlags, columns, deleteTrackFile } = this.props; @@ -141,12 +146,30 @@ class TrackRow extends Component { customFormats.length )} tooltip={} - position={tooltipPositions.BOTTOM} + position={tooltipPositions.LEFT} /> ); } + if (name === 'indexerFlags') { + return ( + + {indexerFlags ? ( + } + title={translate('IndexerFlags')} + body={} + position={tooltipPositions.LEFT} + /> + ) : null} + + ); + } + if (name === 'size') { return ( (indexerFlags & item.id) === item.id + ); + + return flags.length ? ( +
    + {flags.map((flag, index) => { + return
  • {flag.name}
  • ; + })} +
+ ) : null; +} + +export default IndexerFlags; diff --git a/frontend/src/Album/Search/AlbumInteractiveSearchModal.js b/frontend/src/Album/Search/AlbumInteractiveSearchModal.js index 5049658a0..6ce488615 100644 --- a/frontend/src/Album/Search/AlbumInteractiveSearchModal.js +++ b/frontend/src/Album/Search/AlbumInteractiveSearchModal.js @@ -15,7 +15,7 @@ function AlbumInteractiveSearchModal(props) { return ( diff --git a/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js b/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js index 97261ee35..370f67ab1 100644 --- a/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js +++ b/frontend/src/Album/Search/AlbumInteractiveSearchModalContent.js @@ -7,6 +7,7 @@ import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { scrollDirections } from 'Helpers/Props'; import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector'; +import translate from 'Utilities/String/translate'; function AlbumInteractiveSearchModalContent(props) { const { @@ -18,7 +19,10 @@ function AlbumInteractiveSearchModalContent(props) { return ( - Interactive Search {albumId != null && `- ${albumTitle}`} + {albumTitle === undefined ? + translate('InteractiveSearchModalHeader') : + translate('InteractiveSearchModalHeaderTitle', { title: albumTitle }) + } @@ -32,7 +36,7 @@ function AlbumInteractiveSearchModalContent(props) { diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js index 3871b14e9..9e8d508ac 100644 --- a/frontend/src/App/App.js +++ b/frontend/src/App/App.js @@ -12,11 +12,10 @@ function App({ store, history }) { - - - - - + + + + diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 0af990f43..c1004d36d 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -11,7 +11,7 @@ import CalendarPageConnector from 'Calendar/CalendarPageConnector'; import NotFound from 'Components/NotFound'; import Switch from 'Components/Router/Switch'; import AddNewItemConnector from 'Search/AddNewItemConnector'; -import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector'; +import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage'; import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector'; @@ -29,7 +29,7 @@ import LogsTableConnector from 'System/Events/LogsTableConnector'; import Logs from 'System/Logs/Logs'; import Status from 'System/Status/Status'; import Tasks from 'System/Tasks/Tasks'; -import UpdatesConnector from 'System/Updates/UpdatesConnector'; +import Updates from 'System/Updates/Updates'; import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector'; import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector'; @@ -184,7 +184,7 @@ function AppRoutes(props) { state.settings.ui.item.theme || window.Lidarr.theme, - ( - theme - ) => { - return { - theme - }; - } - ); -} - -function ApplyTheme({ theme, children }) { - // Update the CSS Variables - - const updateCSSVariables = useCallback(() => { - const arrayOfVariableKeys = Object.keys(themes[theme]); - const arrayOfVariableValues = Object.values(themes[theme]); - - // Loop through each array key and set the CSS Variables - arrayOfVariableKeys.forEach((cssVariableKey, index) => { - // Based on our snippet from MDN - document.documentElement.style.setProperty( - `--${cssVariableKey}`, - arrayOfVariableValues[index] - ); - }); - }, [theme]); - - // On Component Mount and Component Update - useEffect(() => { - updateCSSVariables(theme); - }, [updateCSSVariables, theme]); - - return {children}; -} - -ApplyTheme.propTypes = { - theme: PropTypes.string.isRequired, - children: PropTypes.object.isRequired -}; - -export default connect(createMapStateToProps)(ApplyTheme); diff --git a/frontend/src/App/ApplyTheme.tsx b/frontend/src/App/ApplyTheme.tsx new file mode 100644 index 000000000..e04dda8c4 --- /dev/null +++ b/frontend/src/App/ApplyTheme.tsx @@ -0,0 +1,37 @@ +import React, { Fragment, ReactNode, useCallback, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import themes from 'Styles/Themes'; +import AppState from './State/AppState'; + +interface ApplyThemeProps { + children: ReactNode; +} + +function createThemeSelector() { + return createSelector( + (state: AppState) => state.settings.ui.item.theme || window.Lidarr.theme, + (theme) => { + return theme; + } + ); +} + +function ApplyTheme({ children }: ApplyThemeProps) { + const theme = useSelector(createThemeSelector()); + + const updateCSSVariables = useCallback(() => { + Object.entries(themes[theme]).forEach(([key, value]) => { + document.documentElement.style.setProperty(`--${key}`, value); + }); + }, [theme]); + + // On Component Mount and Component Update + useEffect(() => { + updateCSSVariables(); + }, [updateCSSVariables, theme]); + + return {children}; +} + +export default ApplyTheme; diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index a8850b16f..cb8da5987 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,9 +1,12 @@ +import ParseAppState from 'App/State/ParseAppState'; import AlbumAppState from './AlbumAppState'; import ArtistAppState, { ArtistIndexAppState } from './ArtistAppState'; import CalendarAppState from './CalendarAppState'; +import CommandAppState from './CommandAppState'; import HistoryAppState from './HistoryAppState'; import QueueAppState from './QueueAppState'; import SettingsAppState from './SettingsAppState'; +import SystemAppState from './SystemAppState'; import TagsAppState from './TagsAppState'; import TrackFilesAppState from './TrackFilesAppState'; import TracksAppState from './TracksAppState'; @@ -41,6 +44,7 @@ export interface CustomFilter { } export interface AppSectionState { + version: string; dimensions: { isSmallScreen: boolean; width: number; @@ -54,12 +58,15 @@ interface AppState { 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/CommandAppState.ts b/frontend/src/App/State/CommandAppState.ts new file mode 100644 index 000000000..1bde37371 --- /dev/null +++ b/frontend/src/App/State/CommandAppState.ts @@ -0,0 +1,6 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Command from 'Commands/Command'; + +export type CommandAppState = AppSectionState; + +export default CommandAppState; diff --git a/frontend/src/App/State/ParseAppState.ts b/frontend/src/App/State/ParseAppState.ts new file mode 100644 index 000000000..827d5b1a7 --- /dev/null +++ b/frontend/src/App/State/ParseAppState.ts @@ -0,0 +1,35 @@ +import Album from 'Album/Album'; +import ModelBase from 'App/ModelBase'; +import { AppSectionItemState } from 'App/State/AppSectionState'; +import Artist from 'Artist/Artist'; +import { QualityModel } from 'Quality/Quality'; +import CustomFormat from 'typings/CustomFormat'; + +export interface ArtistTitleInfo { + title: string; +} + +export interface ParsedAlbumInfo { + albumTitle: string; + artistName: string; + artistTitleInfo: ArtistTitleInfo; + discography: boolean; + quality: QualityModel; + releaseGroup?: string; + releaseHash: string; + releaseTitle: string; + releaseTokens: string; +} + +export interface ParseModel extends ModelBase { + title: string; + parsedAlbumInfo: ParsedAlbumInfo; + artist?: Artist; + albums: Album[]; + customFormats?: CustomFormat[]; + customFormatScore?: number; +} + +type ParseAppState = AppSectionItemState; + +export default ParseAppState; diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 8511b5e2b..b387e13fd 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -1,22 +1,28 @@ import AppSectionState, { AppSectionDeleteState, + AppSectionItemState, AppSectionSaveState, AppSectionSchemaState, } from 'App/State/AppSectionState'; +import CustomFormat from 'typings/CustomFormat'; import DownloadClient from 'typings/DownloadClient'; import ImportList from 'typings/ImportList'; import Indexer from 'typings/Indexer'; +import IndexerFlag from 'typings/IndexerFlag'; import MetadataProfile from 'typings/MetadataProfile'; import Notification from 'typings/Notification'; import QualityProfile from 'typings/QualityProfile'; import RootFolder from 'typings/RootFolder'; -import { UiSettings } from 'typings/UiSettings'; +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, @@ -39,22 +45,32 @@ export interface MetadataProfilesAppState extends AppSectionState, AppSectionSchemaState {} +export interface CustomFormatAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + export interface RootFolderAppState extends AppSectionState, AppSectionDeleteState, AppSectionSaveState {} -export type UiSettingsAppState = AppSectionState; +export type IndexerFlagSettingsAppState = AppSectionState; +export type UiSettingsAppState = AppSectionItemState; interface SettingsAppState { + advancedSettings: boolean; + customFormats: CustomFormatAppState; downloadClients: DownloadClientAppState; + general: GeneralAppState; importLists: ImportListAppState; + indexerFlags: IndexerFlagSettingsAppState; indexers: IndexerAppState; metadataProfiles: MetadataProfilesAppState; notifications: NotificationAppState; qualityProfiles: QualityProfilesAppState; rootFolders: RootFolderAppState; - uiSettings: UiSettingsAppState; + ui: UiSettingsAppState; } export default SettingsAppState; diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts new file mode 100644 index 000000000..3c150fcfb --- /dev/null +++ b/frontend/src/App/State/SystemAppState.ts @@ -0,0 +1,13 @@ +import SystemStatus from 'typings/SystemStatus'; +import Update from 'typings/Update'; +import AppSectionState, { AppSectionItemState } from './AppSectionState'; + +export type SystemStatusAppState = AppSectionItemState; +export type UpdateAppState = AppSectionState; + +interface SystemAppState { + updates: UpdateAppState; + status: SystemStatusAppState; +} + +export default SystemAppState; diff --git a/frontend/src/App/State/TagsAppState.ts b/frontend/src/App/State/TagsAppState.ts index d1f1d5a2f..edaf3a158 100644 --- a/frontend/src/App/State/TagsAppState.ts +++ b/frontend/src/App/State/TagsAppState.ts @@ -1,12 +1,32 @@ import ModelBase from 'App/ModelBase'; import AppSectionState, { AppSectionDeleteState, + AppSectionSaveState, } from 'App/State/AppSectionState'; export interface Tag extends ModelBase { label: string; } -interface TagsAppState extends AppSectionState, AppSectionDeleteState {} +export interface TagDetail extends ModelBase { + label: string; + autoTagIds: number[]; + delayProfileIds: number[]; + downloadClientIds: []; + importListIds: number[]; + indexerIds: number[]; + notificationIds: number[]; + restrictionIds: number[]; + artistIds: number[]; +} + +export interface TagDetailAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +interface TagsAppState extends AppSectionState, AppSectionDeleteState { + details: TagDetailAppState; +} export default TagsAppState; diff --git a/frontend/src/Artist/Artist.ts b/frontend/src/Artist/Artist.ts index d89e32f34..813dbea08 100644 --- a/frontend/src/Artist/Artist.ts +++ b/frontend/src/Artist/Artist.ts @@ -23,7 +23,6 @@ export interface Ratings { interface Artist extends ModelBase { added: string; - artistMetadataId: string; foreignArtistId: string; cleanName: string; ended: boolean; diff --git a/frontend/src/Artist/Delete/DeleteArtistModalContent.js b/frontend/src/Artist/Delete/DeleteArtistModalContent.js index 0542f718b..ac1e2b041 100644 --- a/frontend/src/Artist/Delete/DeleteArtistModalContent.js +++ b/frontend/src/Artist/Delete/DeleteArtistModalContent.js @@ -135,14 +135,14 @@ class DeleteArtistModalContent extends Component { @@ -161,9 +161,7 @@ DeleteArtistModalContent.propTypes = { }; DeleteArtistModalContent.defaultProps = { - statistics: { - trackFileCount: 0 - } + statistics: {} }; export default DeleteArtistModalContent; diff --git a/frontend/src/Artist/Details/AlbumGroupInfo.js b/frontend/src/Artist/Details/AlbumGroupInfo.js index 0fb62d4a3..139cd7765 100644 --- a/frontend/src/Artist/Details/AlbumGroupInfo.js +++ b/frontend/src/Artist/Details/AlbumGroupInfo.js @@ -10,6 +10,7 @@ function AlbumGroupInfo(props) { const { totalAlbumCount, monitoredAlbumCount, + albumFileCount, trackFileCount, sizeOnDisk } = props; @@ -30,6 +31,13 @@ function AlbumGroupInfo(props) { data={monitoredAlbumCount} /> + + - { - secondaryTypes - } + {secondaryTypes.join(', ')}
); } @@ -160,7 +158,7 @@ class AlbumRow extends Component { return ( { - statistics.totalTrackCount + totalTrackCount } ); diff --git a/frontend/src/Artist/Details/ArtistDetails.js b/frontend/src/Artist/Details/ArtistDetails.js index c6fb445b7..1bfa767c3 100644 --- a/frontend/src/Artist/Details/ArtistDetails.js +++ b/frontend/src/Artist/Details/ArtistDetails.js @@ -192,7 +192,7 @@ class ArtistDetails extends Component { artistName, ratings, path, - statistics, + statistics = {}, qualityProfileId, monitored, genres, diff --git a/frontend/src/Artist/Details/ArtistDetailsSeason.js b/frontend/src/Artist/Details/ArtistDetailsSeason.js index 37c85aa66..004613e30 100644 --- a/frontend/src/Artist/Details/ArtistDetailsSeason.js +++ b/frontend/src/Artist/Details/ArtistDetailsSeason.js @@ -22,32 +22,43 @@ import styles from './ArtistDetailsSeason.css'; 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 getAlbumStatistics(albums) { }; } -function getAlbumCountKind(monitored, albumCount, monitoredAlbumCount) { - if (albumCount === monitoredAlbumCount && monitoredAlbumCount > 0) { +function getAlbumCountKind(monitored, albumCount, albumFileCount) { + if (albumCount === albumFileCount && albumFileCount > 0) { return kinds.SUCCESS; } @@ -192,6 +203,7 @@ class ArtistDetailsSeason extends Component { const { albumCount, + albumFileCount, totalAlbumCount, trackFileCount, monitoredAlbumCount, @@ -226,9 +238,9 @@ class ArtistDetailsSeason extends Component { anchor={ } title={translate('GroupInformation')} @@ -237,6 +249,7 @@ class ArtistDetailsSeason extends Component { diff --git a/frontend/src/Artist/Details/ArtistTagsConnector.js b/frontend/src/Artist/Details/ArtistTagsConnector.js index 33ced5f0d..1d24a5755 100644 --- a/frontend/src/Artist/Details/ArtistTagsConnector.js +++ b/frontend/src/Artist/Details/ArtistTagsConnector.js @@ -2,6 +2,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import createArtistSelector from 'Store/Selectors/createArtistSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import sortByProp from 'Utilities/Array/sortByProp'; import ArtistTags from './ArtistTags'; function createMapStateToProps() { @@ -12,8 +13,8 @@ function createMapStateToProps() { const tags = artist.tags .map((tagId) => tagList.find((tag) => tag.id === tagId)) .filter((tag) => !!tag) - .map((tag) => tag.label) - .sort((a, b) => a.localeCompare(b)); + .sort(sortByProp('label')) + .map((tag) => tag.label); return { tags diff --git a/frontend/src/Artist/Edit/EditArtistModalContent.js b/frontend/src/Artist/Edit/EditArtistModalContent.js index 82a390d84..bca6e3ea6 100644 --- a/frontend/src/Artist/Edit/EditArtistModalContent.js +++ b/frontend/src/Artist/Edit/EditArtistModalContent.js @@ -15,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import Popover from 'Components/Tooltip/Popover'; -import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; +import { icons, inputTypes, kinds, sizes, tooltipPositions } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './EditArtistModalContent.css'; @@ -93,7 +93,7 @@ class EditArtistModalContent extends Component {
- + {translate('Monitored')} @@ -107,9 +107,10 @@ class EditArtistModalContent extends Component { /> - + {translate('MonitorNewItems')} + - + {translate('QualityProfile')} @@ -146,10 +147,10 @@ class EditArtistModalContent extends Component { { - showMetadataProfile && - + showMetadataProfile ? + - Metadata Profile + {translate('MetadataProfile')} - + : + null } - + {translate('Path')} @@ -189,7 +191,7 @@ class EditArtistModalContent extends Component { /> - + {translate('Tags')} @@ -209,7 +211,7 @@ class EditArtistModalContent extends Component { kind={kinds.DANGER} onPress={onDeleteArtistPress} > - Delete + {translate('Delete')}
-
{translate('Filters')}
+
+ {translate('Filters')} +
{ diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js index 8be71aaff..77dad7173 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import SelectInput from 'Components/Form/SelectInput'; import IconButton from 'Components/Link/IconButton'; import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props'; +import sortByProp from 'Utilities/Array/sortByProp'; import ArtistFilterBuilderRowValue from './ArtistFilterBuilderRowValue'; import ArtistStatusFilterBuilderRowValue from './ArtistStatusFilterBuilderRowValue'; import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; @@ -10,11 +11,11 @@ import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue'; import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; -import MetadataProfileFilterBuilderRowValueConnector from './MetadataProfileFilterBuilderRowValueConnector'; +import MetadataProfileFilterBuilderRowValue from './MetadataProfileFilterBuilderRowValue'; import MonitorNewItemsFilterBuilderRowValue from './MonitorNewItemsFilterBuilderRowValue'; import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; -import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector'; +import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue'; import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector'; import styles from './FilterBuilderRow.css'; @@ -67,7 +68,7 @@ function getRowValueConnector(selectedFilterBuilderProp) { return IndexerFilterBuilderRowValueConnector; case filterBuilderValueTypes.METADATA_PROFILE: - return MetadataProfileFilterBuilderRowValueConnector; + return MetadataProfileFilterBuilderRowValue; case filterBuilderValueTypes.MONITOR_NEW_ITEMS: return MonitorNewItemsFilterBuilderRowValue; @@ -79,7 +80,7 @@ function getRowValueConnector(selectedFilterBuilderProp) { return QualityFilterBuilderRowValueConnector; case filterBuilderValueTypes.QUALITY_PROFILE: - return QualityProfileFilterBuilderRowValueConnector; + return QualityProfileFilterBuilderRowValue; case filterBuilderValueTypes.ARTIST: return ArtistFilterBuilderRowValue; @@ -224,7 +225,7 @@ class FilterBuilderRow extends Component { key: name, value: typeof label === 'function' ? label() : label }; - }).sort((a, b) => a.value.localeCompare(b.value)); + }).sort(sortByProp('value')); const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js index a7aed80b6..d1419327a 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { filterBuilderTypes } from 'Helpers/Props'; import * as filterTypes from 'Helpers/Props/filterTypes'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import FilterBuilderRowValue from './FilterBuilderRowValue'; function createTagListSelector() { @@ -38,7 +38,7 @@ function createTagListSelector() { } return acc; - }, []).sort(sortByName); + }, []).sort(sortByProp('name')); } return _.uniqBy(items, 'id'); diff --git a/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx index 45e6fc756..1b3b369be 100644 --- a/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx +++ b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx @@ -25,7 +25,7 @@ const EVENT_TYPE_OPTIONS = [ { id: 7, get name() { - return translate('ImportFailed'); + return translate('ImportCompleteFailed'); }, }, { diff --git a/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValue.tsx new file mode 100644 index 000000000..bbd9a8274 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValue.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps'; +import sortByProp from 'Utilities/Array/sortByProp'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createMetadataProfilesSelector() { + return createSelector( + (state: AppState) => state.settings.metadataProfiles.items, + (metadataProfiles) => { + return metadataProfiles; + } + ); +} + +function MetadataProfileFilterBuilderRowValue( + props: FilterBuilderRowValueProps +) { + const metadataProfiles = useSelector(createMetadataProfilesSelector()); + + const tagList = metadataProfiles + .map(({ id, name }) => ({ id, name })) + .sort(sortByProp('name')); + + return ; +} + +export default MetadataProfileFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValueConnector.js deleted file mode 100644 index 89d6c06b3..000000000 --- a/frontend/src/Components/Filter/Builder/MetadataProfileFilterBuilderRowValueConnector.js +++ /dev/null @@ -1,28 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import FilterBuilderRowValue from './FilterBuilderRowValue'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.metadataProfiles, - (metadataProfiles) => { - const tagList = metadataProfiles.items.map((metadataProfile) => { - const { - id, - name - } = metadataProfile; - - return { - id, - name - }; - }); - - return { - tagList - }; - } - ); -} - -export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx new file mode 100644 index 000000000..50036cb90 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps'; +import sortByProp from 'Utilities/Array/sortByProp'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createQualityProfilesSelector() { + return createSelector( + (state: AppState) => state.settings.qualityProfiles.items, + (qualityProfiles) => { + return qualityProfiles; + } + ); +} + +function QualityProfileFilterBuilderRowValue( + props: FilterBuilderRowValueProps +) { + const qualityProfiles = useSelector(createQualityProfilesSelector()); + + const tagList = qualityProfiles + .map(({ id, name }) => ({ id, name })) + .sort(sortByProp('name')); + + return ; +} + +export default QualityProfileFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js deleted file mode 100644 index 4a8b82283..000000000 --- a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js +++ /dev/null @@ -1,28 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import FilterBuilderRowValue from './FilterBuilderRowValue'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.qualityProfiles, - (qualityProfiles) => { - const tagList = qualityProfiles.items.map((qualityProfile) => { - const { - id, - name - } = qualityProfile; - - return { - id, - name - }; - }); - - return { - tagList - }; - } - ); -} - -export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js index 7407f729a..9f378d5a2 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js @@ -37,8 +37,8 @@ class CustomFilter extends Component { dispatchSetFilter } = this.props; - // Assume that delete and then unmounting means the delete was successful. - // Moving this check to a ancestor would be more accurate, but would have + // Assume that delete and then unmounting means the deletion was successful. + // Moving this check to an ancestor would be more accurate, but would have // more boilerplate. if (this.state.isDeleting && id === selectedFilterKey) { dispatchSetFilter({ selectedFilterKey: 'all' }); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js index cd9c07053..d70b97e44 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js @@ -5,6 +5,7 @@ import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import CustomFilter from './CustomFilter'; import styles from './CustomFiltersModalContent.css'; @@ -31,7 +32,7 @@ function CustomFiltersModalContent(props) { { customFilters - .sort((a, b) => a.label.localeCompare(b.label)) + .sort((a, b) => sortByProp(a, b, 'label')) .map((customFilter) => { return ( void; +} + +export default function ArtistTagInput(props: ArtistTagInputProps) { + const { value, onChange, ...otherProps } = props; + const isArray = Array.isArray(value); + + const handleChange = useCallback( + ({ name, value: newValue }: { name: string; value: number[] }) => { + if (isArray) { + onChange({ name, value: newValue }); + } else { + onChange({ + name, + value: newValue.length ? newValue[newValue.length - 1] : 0, + }); + } + }, + [isArray, onChange] + ); + + let finalValue: number[] = []; + + if (isArray) { + finalValue = value; + } else if (value === 0) { + finalValue = []; + } else { + finalValue = [value]; + } + + return ( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore 2786 'TagInputConnector' isn't typed yet + + ); +} diff --git a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js index 172b86752..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,7 +23,7 @@ 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, @@ -33,7 +34,7 @@ function createMapStateToProps() { 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 c0f0bf5dd..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; @@ -292,6 +300,7 @@ FormInputGroup.propTypes = { includeNoChangeDisabled: PropTypes.bool, includeNone: PropTypes.bool, selectedValueOptions: PropTypes.object, + indexerFlags: PropTypes.number, pending: PropTypes.bool, errors: PropTypes.arrayOf(PropTypes.object), warnings: PropTypes.arrayOf(PropTypes.object), diff --git a/frontend/src/Components/Form/IndexerFlagsSelectInput.tsx b/frontend/src/Components/Form/IndexerFlagsSelectInput.tsx new file mode 100644 index 000000000..8dbd27a70 --- /dev/null +++ b/frontend/src/Components/Form/IndexerFlagsSelectInput.tsx @@ -0,0 +1,62 @@ +import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import EnhancedSelectInput from './EnhancedSelectInput'; + +const selectIndexerFlagsValues = (selectedFlags: number) => + createSelector( + (state: AppState) => state.settings.indexerFlags, + (indexerFlags) => { + const value = indexerFlags.items.reduce((acc: number[], { id }) => { + // eslint-disable-next-line no-bitwise + if ((selectedFlags & id) === id) { + acc.push(id); + } + + return acc; + }, []); + + const values = indexerFlags.items.map(({ id, name }) => ({ + key: id, + value: name, + })); + + return { + value, + values, + }; + } + ); + +interface IndexerFlagsSelectInputProps { + name: string; + indexerFlags: number; + onChange(payload: object): void; +} + +function IndexerFlagsSelectInput(props: IndexerFlagsSelectInputProps) { + const { indexerFlags, onChange } = props; + + const { value, values } = useSelector(selectIndexerFlagsValues(indexerFlags)); + + const onChangeWrapper = useCallback( + ({ name, value }: { name: string; value: number[] }) => { + const indexerFlags = value.reduce((acc, flagId) => acc + flagId, 0); + + onChange({ name, value: indexerFlags }); + }, + [onChange] + ); + + return ( + + ); +} + +export default IndexerFlagsSelectInput; diff --git a/frontend/src/Components/Form/IndexerSelectInputConnector.js b/frontend/src/Components/Form/IndexerSelectInputConnector.js index cd58270eb..5f62becbb 100644 --- a/frontend/src/Components/Form/IndexerSelectInputConnector.js +++ b/frontend/src/Components/Form/IndexerSelectInputConnector.js @@ -4,7 +4,8 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { fetchIndexers } from 'Store/Actions/settingsActions'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; +import translate from 'Utilities/String/translate'; import EnhancedSelectInput from './EnhancedSelectInput'; function createMapStateToProps() { @@ -19,7 +20,7 @@ function createMapStateToProps() { items } = indexers; - const values = _.map(items.sort(sortByName), (indexer) => { + const values = _.map(items.sort(sortByProp('name')), (indexer) => { return { key: indexer.id, value: indexer.name @@ -29,7 +30,7 @@ function createMapStateToProps() { if (includeAny) { values.unshift({ key: 0, - value: '(Any)' + value: `(${translate('Any')})` }); } diff --git a/frontend/src/Components/Form/KeyValueListInput.js b/frontend/src/Components/Form/KeyValueListInput.js deleted file mode 100644 index 3e73d74f3..000000000 --- a/frontend/src/Components/Form/KeyValueListInput.js +++ /dev/null @@ -1,156 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import KeyValueListInputItem from './KeyValueListInputItem'; -import styles from './KeyValueListInput.css'; - -class KeyValueListInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isFocused: false - }; - } - - // - // Listeners - - onItemChange = (index, itemValue) => { - const { - name, - value, - onChange - } = this.props; - - const newValue = [...value]; - - if (index == null) { - newValue.push(itemValue); - } else { - newValue.splice(index, 1, itemValue); - } - - onChange({ - name, - value: newValue - }); - }; - - onRemoveItem = (index) => { - const { - name, - value, - onChange - } = this.props; - - const newValue = [...value]; - newValue.splice(index, 1); - - onChange({ - name, - value: newValue - }); - }; - - onFocus = () => { - this.setState({ - isFocused: true - }); - }; - - onBlur = () => { - this.setState({ - isFocused: false - }); - - const { - name, - value, - onChange - } = this.props; - - const newValue = value.reduce((acc, v) => { - if (v.key || v.value) { - acc.push(v); - } - - return acc; - }, []); - - if (newValue.length !== value.length) { - onChange({ - name, - value: newValue - }); - } - }; - - // - // Render - - render() { - const { - className, - value, - keyPlaceholder, - valuePlaceholder, - hasError, - hasWarning - } = this.props; - - const { isFocused } = this.state; - - return ( -
- { - [...value, { key: '', value: '' }].map((v, index) => { - return ( - - ); - }) - } -
- ); - } -} - -KeyValueListInput.propTypes = { - className: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.object).isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - keyPlaceholder: PropTypes.string, - valuePlaceholder: PropTypes.string, - onChange: PropTypes.func.isRequired -}; - -KeyValueListInput.defaultProps = { - className: styles.inputContainer, - value: [] -}; - -export default KeyValueListInput; diff --git a/frontend/src/Components/Form/KeyValueListInput.tsx b/frontend/src/Components/Form/KeyValueListInput.tsx new file mode 100644 index 000000000..f5c6ac19b --- /dev/null +++ b/frontend/src/Components/Form/KeyValueListInput.tsx @@ -0,0 +1,104 @@ +import classNames from 'classnames'; +import React, { useCallback, useState } from 'react'; +import { InputOnChange } from 'typings/inputs'; +import KeyValueListInputItem from './KeyValueListInputItem'; +import styles from './KeyValueListInput.css'; + +interface KeyValue { + key: string; + value: string; +} + +export interface KeyValueListInputProps { + className?: string; + name: string; + value: KeyValue[]; + hasError?: boolean; + hasWarning?: boolean; + keyPlaceholder?: string; + valuePlaceholder?: string; + onChange: InputOnChange; +} + +function KeyValueListInput({ + className = styles.inputContainer, + name, + value = [], + hasError = false, + hasWarning = false, + keyPlaceholder, + valuePlaceholder, + onChange, +}: KeyValueListInputProps): JSX.Element { + const [isFocused, setIsFocused] = useState(false); + + const handleItemChange = useCallback( + (index: number | null, itemValue: KeyValue) => { + const newValue = [...value]; + + if (index === null) { + newValue.push(itemValue); + } else { + newValue.splice(index, 1, itemValue); + } + + onChange({ name, value: newValue }); + }, + [value, name, onChange] + ); + + const handleRemoveItem = useCallback( + (index: number) => { + const newValue = [...value]; + newValue.splice(index, 1); + onChange({ name, value: newValue }); + }, + [value, name, onChange] + ); + + const onFocus = useCallback(() => setIsFocused(true), []); + + const onBlur = useCallback(() => { + setIsFocused(false); + + const newValue = value.reduce((acc: KeyValue[], v) => { + if (v.key || v.value) { + acc.push(v); + } + return acc; + }, []); + + if (newValue.length !== value.length) { + onChange({ name, value: newValue }); + } + }, [value, name, onChange]); + + return ( +
+ {[...value, { key: '', value: '' }].map((v, index) => ( + + ))} +
+ ); +} + +export default KeyValueListInput; diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css b/frontend/src/Components/Form/KeyValueListInputItem.css index dca2882a8..ed82db459 100644 --- a/frontend/src/Components/Form/KeyValueListInputItem.css +++ b/frontend/src/Components/Form/KeyValueListInputItem.css @@ -5,13 +5,19 @@ &:last-child { margin-bottom: 0; + border-bottom: 0; } } -.inputWrapper { +.keyInputWrapper { flex: 1 0 0; } +.valueInputWrapper { + flex: 1 0 0; + min-width: 40px; +} + .buttonWrapper { flex: 0 0 22px; } @@ -20,6 +26,10 @@ .valueInput { width: 100%; border: none; - background-color: var(--inputBackgroundColor); + background-color: transparent; color: var(--textColor); + + &::placeholder { + color: var(--helpTextColor); + } } diff --git a/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts b/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts index 35baf55cd..aa0c1be13 100644 --- a/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts +++ b/frontend/src/Components/Form/KeyValueListInputItem.css.d.ts @@ -2,10 +2,11 @@ // Please do not change this file! interface CssExports { 'buttonWrapper': string; - 'inputWrapper': string; 'itemContainer': string; 'keyInput': string; + 'keyInputWrapper': string; 'valueInput': string; + 'valueInputWrapper': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Components/Form/KeyValueListInputItem.js b/frontend/src/Components/Form/KeyValueListInputItem.js deleted file mode 100644 index 5379c2129..000000000 --- a/frontend/src/Components/Form/KeyValueListInputItem.js +++ /dev/null @@ -1,124 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import IconButton from 'Components/Link/IconButton'; -import { icons } from 'Helpers/Props'; -import TextInput from './TextInput'; -import styles from './KeyValueListInputItem.css'; - -class KeyValueListInputItem extends Component { - - // - // Listeners - - onKeyChange = ({ value: keyValue }) => { - const { - index, - value, - onChange - } = this.props; - - onChange(index, { key: keyValue, value }); - }; - - onValueChange = ({ value }) => { - // TODO: Validate here or validate at a lower level component - - const { - index, - keyValue, - onChange - } = this.props; - - onChange(index, { key: keyValue, value }); - }; - - onRemovePress = () => { - const { - index, - onRemove - } = this.props; - - onRemove(index); - }; - - onFocus = () => { - this.props.onFocus(); - }; - - onBlur = () => { - this.props.onBlur(); - }; - - // - // Render - - render() { - const { - keyValue, - value, - keyPlaceholder, - valuePlaceholder, - isNew - } = this.props; - - return ( -
-
- -
- -
- -
- -
- { - isNew ? - null : - - } -
-
- ); - } -} - -KeyValueListInputItem.propTypes = { - index: PropTypes.number, - keyValue: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - keyPlaceholder: PropTypes.string.isRequired, - valuePlaceholder: PropTypes.string.isRequired, - isNew: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - onRemove: PropTypes.func.isRequired, - onFocus: PropTypes.func.isRequired, - onBlur: PropTypes.func.isRequired -}; - -KeyValueListInputItem.defaultProps = { - keyPlaceholder: 'Key', - valuePlaceholder: 'Value' -}; - -export default KeyValueListInputItem; diff --git a/frontend/src/Components/Form/KeyValueListInputItem.tsx b/frontend/src/Components/Form/KeyValueListInputItem.tsx new file mode 100644 index 000000000..c63ad50a9 --- /dev/null +++ b/frontend/src/Components/Form/KeyValueListInputItem.tsx @@ -0,0 +1,89 @@ +import React, { useCallback } from 'react'; +import IconButton from 'Components/Link/IconButton'; +import { icons } from 'Helpers/Props'; +import TextInput from './TextInput'; +import styles from './KeyValueListInputItem.css'; + +interface KeyValueListInputItemProps { + index: number; + keyValue: string; + value: string; + keyPlaceholder?: string; + valuePlaceholder?: string; + isNew: boolean; + onChange: (index: number, itemValue: { key: string; value: string }) => void; + onRemove: (index: number) => void; + onFocus: () => void; + onBlur: () => void; +} + +function KeyValueListInputItem({ + index, + keyValue, + value, + keyPlaceholder = 'Key', + valuePlaceholder = 'Value', + isNew, + onChange, + onRemove, + onFocus, + onBlur, +}: KeyValueListInputItemProps): JSX.Element { + const handleKeyChange = useCallback( + ({ value: keyValue }: { value: string }) => { + onChange(index, { key: keyValue, value }); + }, + [index, value, onChange] + ); + + const handleValueChange = useCallback( + ({ value }: { value: string }) => { + onChange(index, { key: keyValue, value }); + }, + [index, keyValue, onChange] + ); + + const handleRemovePress = useCallback(() => { + onRemove(index); + }, [index, onRemove]); + + return ( +
+
+ +
+ +
+ +
+ +
+ {isNew ? null : ( + + )} +
+
+ ); +} + +export default KeyValueListInputItem; diff --git a/frontend/src/Components/Form/MetadataProfileSelectInputConnector.js b/frontend/src/Components/Form/MetadataProfileSelectInputConnector.js index 3d763e713..6e6aad5f9 100644 --- a/frontend/src/Components/Form/MetadataProfileSelectInputConnector.js +++ b/frontend/src/Components/Form/MetadataProfileSelectInputConnector.js @@ -5,13 +5,13 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { metadataProfileNames } from 'Helpers/Props'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import EnhancedSelectInput from './EnhancedSelectInput'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.metadataProfiles', sortByName), + createSortedSectionSelector('settings.metadataProfiles', sortByProp('name')), (state, { includeNoChange }) => includeNoChange, (state, { includeNoChangeDisabled }) => includeNoChangeDisabled, (state, { includeMixed }) => includeMixed, @@ -38,7 +38,7 @@ function createMapStateToProps() { values.unshift({ key: 'noChange', value: translate('NoChange'), - disabled: includeNoChangeDisabled + isDisabled: includeNoChangeDisabled }); } @@ -46,7 +46,7 @@ function createMapStateToProps() { values.unshift({ key: 'mixed', value: '(Mixed)', - disabled: true + isDisabled: true }); } diff --git a/frontend/src/Components/Form/MonitorAlbumsSelectInput.js b/frontend/src/Components/Form/MonitorAlbumsSelectInput.js index a10bbb776..d48284c38 100644 --- a/frontend/src/Components/Form/MonitorAlbumsSelectInput.js +++ b/frontend/src/Components/Form/MonitorAlbumsSelectInput.js @@ -18,15 +18,15 @@ function MonitorAlbumsSelectInput(props) { values.unshift({ key: 'noChange', value: translate('NoChange'), - disabled: includeNoChangeDisabled + isDisabled: includeNoChangeDisabled }); } if (includeMixed) { values.unshift({ key: 'mixed', - value: '(Mixed)', - disabled: true + value: `(${translate('Mixed')})`, + isDisabled: true }); } diff --git a/frontend/src/Components/Form/MonitorNewItemsSelectInput.js b/frontend/src/Components/Form/MonitorNewItemsSelectInput.js index f9cc07d7d..0dccc44a4 100644 --- a/frontend/src/Components/Form/MonitorNewItemsSelectInput.js +++ b/frontend/src/Components/Form/MonitorNewItemsSelectInput.js @@ -18,7 +18,7 @@ function MonitorNewItemsSelectInput(props) { values.unshift({ key: 'noChange', value: translate('NoChange'), - disabled: includeNoChangeDisabled + isDisabled: includeNoChangeDisabled }); } @@ -26,7 +26,7 @@ function MonitorNewItemsSelectInput(props) { values.unshift({ key: 'mixed', value: '(Mixed)', - disabled: true + isDisabled: true }); } diff --git a/frontend/src/Components/Form/PasswordInput.css b/frontend/src/Components/Form/PasswordInput.css deleted file mode 100644 index 6cb162784..000000000 --- a/frontend/src/Components/Form/PasswordInput.css +++ /dev/null @@ -1,5 +0,0 @@ -.input { - composes: input from '~Components/Form/TextInput.css'; - - font-family: $passwordFamily; -} diff --git a/frontend/src/Components/Form/PasswordInput.js b/frontend/src/Components/Form/PasswordInput.js index fef54fd5a..dbc4cfdb4 100644 --- a/frontend/src/Components/Form/PasswordInput.js +++ b/frontend/src/Components/Form/PasswordInput.js @@ -1,7 +1,5 @@ -import PropTypes from 'prop-types'; import React from 'react'; import TextInput from './TextInput'; -import styles from './PasswordInput.css'; // Prevent a user from copying (or cutting) the password from the input function onCopy(e) { @@ -13,17 +11,14 @@ function PasswordInput(props) { return ( ); } PasswordInput.propTypes = { - className: PropTypes.string.isRequired -}; - -PasswordInput.defaultProps = { - className: styles.input + ...TextInput.props }; export default PasswordInput; diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js index 9c2d124d7..311f1bbbd 100644 --- a/frontend/src/Components/Form/ProviderFieldFormGroup.js +++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js @@ -14,6 +14,8 @@ function getType({ type, selectOptionsProviderAction }) { return inputTypes.CHECK; case 'device': return inputTypes.DEVICE; + case 'keyValueList': + return inputTypes.KEY_VALUE_LIST; case 'playlist': return inputTypes.PLAYLIST; case 'password': @@ -29,6 +31,8 @@ function getType({ type, selectOptionsProviderAction }) { return inputTypes.DYNAMIC_SELECT; } return inputTypes.SELECT; + case 'artistTag': + return inputTypes.ARTIST_TAG; case 'tag': return inputTypes.TEXT_TAG; case 'tagSelect': diff --git a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js index a898de4a2..d7719969a 100644 --- a/frontend/src/Components/Form/QualityProfileSelectInputConnector.js +++ b/frontend/src/Components/Form/QualityProfileSelectInputConnector.js @@ -4,13 +4,13 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import EnhancedSelectInput from './EnhancedSelectInput'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.qualityProfiles', sortByName), + createSortedSectionSelector('settings.qualityProfiles', sortByProp('name')), (state, { includeNoChange }) => includeNoChange, (state, { includeNoChangeDisabled }) => includeNoChangeDisabled, (state, { includeMixed }) => includeMixed, @@ -26,7 +26,7 @@ function createMapStateToProps() { values.unshift({ key: 'noChange', value: translate('NoChange'), - disabled: includeNoChangeDisabled + isDisabled: includeNoChangeDisabled }); } @@ -34,7 +34,7 @@ function createMapStateToProps() { values.unshift({ key: 'mixed', value: '(Mixed)', - disabled: true + isDisabled: true }); } diff --git a/frontend/src/Components/Form/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 c14617b29..000000000 --- a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.js +++ /dev/null @@ -1,90 +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 translate from 'Utilities/String/translate'; -import styles from './PageHeaderActionsMenu.css'; - -function PageHeaderActionsMenu(props) { - const { - formsAuth, - onKeyboardShortcutsPress, - onRestartPress, - onShutdownPress - } = props; - - return ( -
- - - - - - - - - {translate('KeyboardShortcuts')} - - - - - - - {translate('Restart')} - - - - - Shutdown - - - { - formsAuth && -
- } - - { - formsAuth && - - - {translate('Logout')} - - } - -
-
- ); -} - -PageHeaderActionsMenu.propTypes = { - formsAuth: PropTypes.bool.isRequired, - onKeyboardShortcutsPress: PropTypes.func.isRequired, - onRestartPress: PropTypes.func.isRequired, - onShutdownPress: PropTypes.func.isRequired -}; - -export default PageHeaderActionsMenu; diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx new file mode 100644 index 000000000..7a0c35c1c --- /dev/null +++ b/frontend/src/Components/Page/Header/PageHeaderActionsMenu.tsx @@ -0,0 +1,87 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Icon from 'Components/Icon'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import MenuItem from 'Components/Menu/MenuItem'; +import MenuItemSeparator from 'Components/Menu/MenuItemSeparator'; +import { align, icons, kinds } from 'Helpers/Props'; +import { restart, shutdown } from 'Store/Actions/systemActions'; +import translate from 'Utilities/String/translate'; +import styles from './PageHeaderActionsMenu.css'; + +interface PageHeaderActionsMenuProps { + onKeyboardShortcutsPress(): void; +} + +function PageHeaderActionsMenu(props: PageHeaderActionsMenuProps) { + const { onKeyboardShortcutsPress } = props; + + const dispatch = useDispatch(); + + const { authentication, isDocker } = useSelector( + (state: AppState) => state.system.status.item + ); + + const formsAuth = authentication === 'forms'; + + const handleRestartPress = useCallback(() => { + dispatch(restart()); + }, [dispatch]); + + const handleShutdownPress = useCallback(() => { + dispatch(shutdown()); + }, [dispatch]); + + return ( +
+ + + + + + + + + {translate('KeyboardShortcuts')} + + + {isDocker ? null : ( + <> + + + + + {translate('Restart')} + + + + + {translate('Shutdown')} + + + )} + + {formsAuth ? ( + <> + + + + + {translate('Logout')} + + + ) : null} + + +
+ ); +} + +export default PageHeaderActionsMenu; diff --git a/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js b/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js deleted file mode 100644 index 3aba95065..000000000 --- a/frontend/src/Components/Page/Header/PageHeaderActionsMenuConnector.js +++ /dev/null @@ -1,56 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { restart, shutdown } from 'Store/Actions/systemActions'; -import PageHeaderActionsMenu from './PageHeaderActionsMenu'; - -function createMapStateToProps() { - return createSelector( - (state) => state.system.status, - (status) => { - return { - formsAuth: status.item.authentication === 'forms' - }; - } - ); -} - -const mapDispatchToProps = { - restart, - shutdown -}; - -class PageHeaderActionsMenuConnector extends Component { - - // - // Listeners - - onRestartPress = () => { - this.props.restart(); - }; - - onShutdownPress = () => { - this.props.shutdown(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -PageHeaderActionsMenuConnector.propTypes = { - restart: PropTypes.func.isRequired, - shutdown: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector); diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index 070be9b42..c84099a5e 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -6,7 +6,14 @@ import { createSelector } from 'reselect'; import { fetchTranslations, saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; import { fetchArtist } from 'Store/Actions/artistActions'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; -import { fetchImportLists, fetchLanguages, fetchMetadataProfiles, fetchQualityProfiles, fetchUISettings } from 'Store/Actions/settingsActions'; +import { + fetchImportLists, + fetchIndexerFlags, + fetchLanguages, + fetchMetadataProfiles, + fetchQualityProfiles, + fetchUISettings +} from 'Store/Actions/settingsActions'; import { fetchStatus } from 'Store/Actions/systemActions'; import { fetchTags } from 'Store/Actions/tagActions'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; @@ -44,6 +51,7 @@ const selectAppProps = createSelector( ); const selectIsPopulated = createSelector( + (state) => state.artist.isPopulated, (state) => state.customFilters.isPopulated, (state) => state.tags.isPopulated, (state) => state.settings.ui.isPopulated, @@ -51,9 +59,11 @@ const selectIsPopulated = createSelector( (state) => state.settings.qualityProfiles.isPopulated, (state) => state.settings.metadataProfiles.isPopulated, (state) => state.settings.importLists.isPopulated, + (state) => state.settings.indexerFlags.isPopulated, (state) => state.system.status.isPopulated, (state) => state.app.translations.isPopulated, ( + artistsIsPopulated, customFiltersIsPopulated, tagsIsPopulated, uiSettingsIsPopulated, @@ -61,10 +71,12 @@ const selectIsPopulated = createSelector( qualityProfilesIsPopulated, metadataProfilesIsPopulated, importListsIsPopulated, + indexerFlagsIsPopulated, systemStatusIsPopulated, translationsIsPopulated ) => { return ( + artistsIsPopulated && customFiltersIsPopulated && tagsIsPopulated && uiSettingsIsPopulated && @@ -72,6 +84,7 @@ const selectIsPopulated = createSelector( qualityProfilesIsPopulated && metadataProfilesIsPopulated && importListsIsPopulated && + indexerFlagsIsPopulated && systemStatusIsPopulated && translationsIsPopulated ); @@ -79,6 +92,7 @@ const selectIsPopulated = createSelector( ); const selectErrors = createSelector( + (state) => state.artist.error, (state) => state.customFilters.error, (state) => state.tags.error, (state) => state.settings.ui.error, @@ -86,9 +100,11 @@ const selectErrors = createSelector( (state) => state.settings.qualityProfiles.error, (state) => state.settings.metadataProfiles.error, (state) => state.settings.importLists.error, + (state) => state.settings.indexerFlags.error, (state) => state.system.status.error, (state) => state.app.translations.error, ( + artistsError, customFiltersError, tagsError, uiSettingsError, @@ -96,10 +112,12 @@ const selectErrors = createSelector( qualityProfilesError, metadataProfilesError, importListsError, + indexerFlagsError, systemStatusError, translationsError ) => { const hasError = !!( + artistsError || customFiltersError || tagsError || uiSettingsError || @@ -107,6 +125,7 @@ const selectErrors = createSelector( qualityProfilesError || metadataProfilesError || importListsError || + indexerFlagsError || systemStatusError || translationsError ); @@ -120,6 +139,7 @@ const selectErrors = createSelector( qualityProfilesError, metadataProfilesError, importListsError, + indexerFlagsError, systemStatusError, translationsError }; @@ -177,6 +197,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchImportLists() { dispatch(fetchImportLists()); }, + dispatchFetchIndexerFlags() { + dispatch(fetchIndexerFlags()); + }, dispatchFetchUISettings() { dispatch(fetchUISettings()); }, @@ -217,6 +240,7 @@ class PageConnector extends Component { this.props.dispatchFetchQualityProfiles(); this.props.dispatchFetchMetadataProfiles(); this.props.dispatchFetchImportLists(); + this.props.dispatchFetchIndexerFlags(); this.props.dispatchFetchUISettings(); this.props.dispatchFetchStatus(); this.props.dispatchFetchTranslations(); @@ -243,6 +267,7 @@ class PageConnector extends Component { dispatchFetchQualityProfiles, dispatchFetchMetadataProfiles, dispatchFetchImportLists, + dispatchFetchIndexerFlags, dispatchFetchUISettings, dispatchFetchStatus, dispatchFetchTranslations, @@ -284,6 +309,7 @@ PageConnector.propTypes = { dispatchFetchQualityProfiles: PropTypes.func.isRequired, dispatchFetchMetadataProfiles: PropTypes.func.isRequired, dispatchFetchImportLists: PropTypes.func.isRequired, + dispatchFetchIndexerFlags: PropTypes.func.isRequired, dispatchFetchUISettings: PropTypes.func.isRequired, dispatchFetchStatus: PropTypes.func.isRequired, dispatchFetchTranslations: PropTypes.func.isRequired, diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index c970589c7..365827a2b 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -172,7 +172,7 @@ class SignalRConnector extends Component { const status = resource.status; // Both successful and failed commands need to be - // completed, otherwise they spin until they timeout. + // completed, otherwise they spin until they time out. if (status === 'completed' || status === 'failed') { this.props.dispatchFinishCommand(resource); @@ -224,10 +224,58 @@ class SignalRConnector extends Component { repopulatePage('trackFileUpdated'); }; + handleDownloadclient = ({ action, resource }) => { + const section = 'settings.downloadClients'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + handleHealth = () => { this.props.dispatchFetchHealth(); }; + handleImportlist = ({ action, resource }) => { + const section = 'settings.importLists'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + + handleIndexer = ({ action, resource }) => { + const section = 'settings.indexers'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + + handleMetadata = ({ action, resource }) => { + const section = 'settings.metadata'; + + if (action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } + }; + + handleNotification = ({ action, resource }) => { + const section = 'settings.notifications'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + handleArtist = (body) => { const action = body.action; const section = 'artist'; @@ -266,7 +314,7 @@ class SignalRConnector extends Component { handleWantedCutoff = (body) => { if (body.action === 'updated') { this.props.dispatchUpdateItem({ - section: 'cutoffUnmet', + section: 'wanted.cutoffUnmet', updateOnly: true, ...body.resource }); @@ -276,7 +324,7 @@ class SignalRConnector extends Component { handleWantedMissing = (body) => { if (body.action === 'updated') { this.props.dispatchUpdateItem({ - section: 'missing', + section: 'wanted.missing', updateOnly: true, ...body.resource }); diff --git a/frontend/src/Components/Table/Cells/TableRowCell.css b/frontend/src/Components/Table/Cells/TableRowCell.css index 47ce0d22e..7e3353c25 100644 --- a/frontend/src/Components/Table/Cells/TableRowCell.css +++ b/frontend/src/Components/Table/Cells/TableRowCell.css @@ -4,7 +4,7 @@ line-height: 1.52857143; } -@media only screen and (max-width: $breakpointSmall) { +@media only screen and (max-width: $breakpointMedium) { .cell { white-space: nowrap; } diff --git a/frontend/src/Components/Table/Cells/VirtualTableRowCell.css b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css index 2501b7c84..f7f3b9306 100644 --- a/frontend/src/Components/Table/Cells/VirtualTableRowCell.css +++ b/frontend/src/Components/Table/Cells/VirtualTableRowCell.css @@ -7,7 +7,7 @@ white-space: nowrap; } -@media only screen and (max-width: $breakpointSmall) { +@media only screen and (max-width: $breakpointMedium) { .cell { white-space: nowrap; } diff --git a/frontend/src/Components/Table/Table.css b/frontend/src/Components/Table/Table.css index bdfdec641..d0507be6b 100644 --- a/frontend/src/Components/Table/Table.css +++ b/frontend/src/Components/Table/Table.css @@ -10,7 +10,7 @@ border-collapse: collapse; } -@media only screen and (max-width: $breakpointSmall) { +@media only screen and (max-width: $breakpointMedium) { .tableContainer { min-width: 100%; width: fit-content; diff --git a/frontend/src/Components/Table/TableHeaderCell.css b/frontend/src/Components/Table/TableHeaderCell.css index c2c4f58c8..eded9c95b 100644 --- a/frontend/src/Components/Table/TableHeaderCell.css +++ b/frontend/src/Components/Table/TableHeaderCell.css @@ -9,7 +9,7 @@ margin-left: 10px; } -@media only screen and (max-width: $breakpointSmall) { +@media only screen and (max-width: $breakpointMedium) { .headerCell { white-space: nowrap; } diff --git a/frontend/src/Components/Table/TablePager.css b/frontend/src/Components/Table/TablePager.css index d73a0d0c0..6d184196e 100644 --- a/frontend/src/Components/Table/TablePager.css +++ b/frontend/src/Components/Table/TablePager.css @@ -60,7 +60,7 @@ height: 25px; } -@media only screen and (max-width: $breakpointSmall) { +@media only screen and (max-width: $breakpointMedium) { .pager { flex-wrap: wrap; } diff --git a/frontend/src/Components/Table/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/Content/Fonts/fonts.css b/frontend/src/Content/Fonts/fonts.css index bf31501dd..e0f1bf5dc 100644 --- a/frontend/src/Content/Fonts/fonts.css +++ b/frontend/src/Content/Fonts/fonts.css @@ -25,14 +25,3 @@ font-family: 'Ubuntu Mono'; src: url('UbuntuMono-Regular.eot?#iefix&v=1.3.0') format('embedded-opentype'), url('UbuntuMono-Regular.woff?v=1.3.0') format('woff'), url('UbuntuMono-Regular.ttf?v=1.3.0') format('truetype'); } - -/* - * text-security-disc - */ - -@font-face { - font-weight: normal; - font-style: normal; - font-family: 'text-security-disc'; - src: url('text-security-disc.woff?v=1.3.0') format('woff'), url('text-security-disc.ttf?v=1.3.0') format('truetype'); -} diff --git a/frontend/src/Content/Fonts/text-security-disc.ttf b/frontend/src/Content/Fonts/text-security-disc.ttf deleted file mode 100644 index 86038dba8..000000000 Binary files a/frontend/src/Content/Fonts/text-security-disc.ttf and /dev/null differ diff --git a/frontend/src/Content/Fonts/text-security-disc.woff b/frontend/src/Content/Fonts/text-security-disc.woff deleted file mode 100644 index bc4cc324b..000000000 Binary files a/frontend/src/Content/Fonts/text-security-disc.woff and /dev/null differ diff --git a/frontend/src/Content/Images/Icons/browserconfig.xml b/frontend/src/Content/Images/Icons/browserconfig.xml deleted file mode 100644 index 993924968..000000000 --- a/frontend/src/Content/Images/Icons/browserconfig.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - #00ccff - - - diff --git a/frontend/src/Content/Images/Icons/manifest.json b/frontend/src/Content/Images/Icons/manifest.json deleted file mode 100644 index a7ffa6777..000000000 --- a/frontend/src/Content/Images/Icons/manifest.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "Lidarr", - "icons": [ - { - "src": "android-chrome-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "android-chrome-512x512.png", - "sizes": "512x512", - "type": "image/png" - } - ], - "start_url": "../../../../", - "theme_color": "#3a3f51", - "background_color": "#3a3f51", - "display": "minimal-ui" -} diff --git a/frontend/src/Content/browserconfig.xml b/frontend/src/Content/browserconfig.xml new file mode 100644 index 000000000..646112d06 --- /dev/null +++ b/frontend/src/Content/browserconfig.xml @@ -0,0 +1,11 @@ + + + + + + + #00ccff + + + + diff --git a/frontend/src/Content/manifest.json b/frontend/src/Content/manifest.json new file mode 100644 index 000000000..5c2b3d59d --- /dev/null +++ b/frontend/src/Content/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "__INSTANCE_NAME__", + "icons": [ + { + "src": "__URL_BASE__/Content/Images/Icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "__URL_BASE__/Content/Images/Icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "start_url": "__URL_BASE__/", + "theme_color": "#3a3f51", + "background_color": "#3a3f51", + "display": "standalone" +} diff --git a/frontend/src/Helpers/Hooks/useModalOpenState.ts b/frontend/src/Helpers/Hooks/useModalOpenState.ts new file mode 100644 index 000000000..f5b5a96f0 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useModalOpenState.ts @@ -0,0 +1,17 @@ +import { useCallback, useState } from 'react'; + +export default function useModalOpenState( + initialState: boolean +): [boolean, () => void, () => void] { + const [isOpen, setOpen] = useState(initialState); + + const setModalOpen = useCallback(() => { + setOpen(true); + }, [setOpen]); + + const setModalClosed = useCallback(() => { + setOpen(false); + }, [setOpen]); + + return [isOpen, setModalOpen, setModalClosed]; +} diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 77803e56e..aa9c23145 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -32,6 +32,7 @@ import { faBookReader as fasBookReader, faBroadcastTower as fasBroadcastTower, faBug as fasBug, + faCalculator as fasCalculator, faCalendarAlt as fasCalendarAlt, faCaretDown as fasCaretDown, faCheck as fasCheck, @@ -60,6 +61,7 @@ import { faFileImport as fasFileImport, faFileInvoice as farFileInvoice, faFilter as fasFilter, + faFlag as fasFlag, faFolderOpen as fasFolderOpen, faForward as fasForward, faHeart as fasHeart, @@ -158,6 +160,7 @@ export const FILE = farFile; export const FILE_IMPORT = fasFileImport; export const FILE_MISSING = fasFileCircleQuestion; export const FILTER = fasFilter; +export const FLAG = fasFlag; export const FOLDER = farFolder; export const FOLDER_OPEN = fasFolderOpen; export const GROUP = farObjectGroup; @@ -185,6 +188,7 @@ export const PAGE_PREVIOUS = fasBackward; export const PAGE_NEXT = fasForward; export const PAGE_LAST = fasFastForward; export const PARENT = fasLevelUpAlt; +export const PARSE = fasCalculator; export const PAUSED = fasPause; export const PENDING = farClock; export const PROFILE = fasUser; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index 8ebbd540b..44115c787 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -2,8 +2,8 @@ export const AUTO_COMPLETE = 'autoComplete'; export const CAPTCHA = 'captcha'; export const CHECK = 'check'; export const DEVICE = 'device'; -export const PLAYLIST = 'playlist'; export const KEY_VALUE_LIST = 'keyValueList'; +export const PLAYLIST = 'playlist'; export const MONITOR_ALBUMS_SELECT = 'monitorAlbumsSelect'; export const MONITOR_NEW_ITEMS_SELECT = 'monitorNewItemsSelect'; export const FLOAT = 'float'; @@ -15,10 +15,12 @@ export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; export const METADATA_PROFILE_SELECT = 'metadataProfileSelect'; export const ALBUM_RELEASE_SELECT = 'albumReleaseSelect'; export const INDEXER_SELECT = 'indexerSelect'; +export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect'; export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const SELECT = 'select'; export const SERIES_TYPE_SELECT = 'artistTypeSelect'; +export const ARTIST_TAG = 'artistTag'; export const DYNAMIC_SELECT = 'dynamicSelect'; export const TAG = 'tag'; export const TAG_SELECT = 'tagSelect'; @@ -32,8 +34,8 @@ export const all = [ CAPTCHA, CHECK, DEVICE, - PLAYLIST, KEY_VALUE_LIST, + PLAYLIST, MONITOR_ALBUMS_SELECT, MONITOR_NEW_ITEMS_SELECT, FLOAT, @@ -48,6 +50,7 @@ export const all = [ DOWNLOAD_CLIENT_SELECT, ROOT_FOLDER_SELECT, SELECT, + ARTIST_TAG, DYNAMIC_SELECT, SERIES_TYPE_SELECT, TAG, diff --git a/frontend/src/Helpers/Props/sizes.js b/frontend/src/Helpers/Props/sizes.js index d7f85df5e..6ac15f3bd 100644 --- a/frontend/src/Helpers/Props/sizes.js +++ b/frontend/src/Helpers/Props/sizes.js @@ -3,5 +3,5 @@ export const SMALL = 'small'; export const MEDIUM = 'medium'; export const LARGE = 'large'; export const EXTRA_LARGE = 'extraLarge'; - -export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE]; +export const EXTRA_EXTRA_LARGE = 'extraExtraLarge'; +export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE, EXTRA_EXTRA_LARGE]; diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js b/frontend/src/InteractiveImport/Album/SelectAlbumModalContent.js index 5f2cc9696..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 }, { @@ -63,16 +66,22 @@ 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 ( @@ -83,27 +92,29 @@ class SelectAlbumModalContent extends Component { className={styles.modalBody} scrollDirection={scrollDirections.NONE} > - { - isFetching && - - } - - - { + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + + + {isPopulated && !!items.length ? ( { @@ -122,7 +133,7 @@ class SelectAlbumModalContent extends Component { }
- } + ) : null}
@@ -137,8 +148,13 @@ class SelectAlbumModalContent extends Component { } SelectAlbumModalContent.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.string, + onSortPress: PropTypes.func.isRequired, onAlbumSelect: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js index 12cd88e53..d09da0fca 100644 --- a/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js +++ b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js @@ -3,18 +3,14 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { - clearInteractiveImportAlbums, - fetchInteractiveImportAlbums, - saveInteractiveImportItem, - setInteractiveImportAlbumsSort, - updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; +import { clearAlbums, fetchAlbums, setAlbumsSort } from 'Store/Actions/albumSelectionActions'; +import { saveInteractiveImportItem, updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import SelectAlbumModalContent from './SelectAlbumModalContent'; function createMapStateToProps() { return createSelector( - createClientSideCollectionSelector('interactiveImport.albums'), + createClientSideCollectionSelector('albumSelection'), (albums) => { return albums; } @@ -22,9 +18,9 @@ function createMapStateToProps() { } const mapDispatchToProps = { - fetchInteractiveImportAlbums, - setInteractiveImportAlbumsSort, - clearInteractiveImportAlbums, + fetchAlbums, + setAlbumsSort, + clearAlbums, updateInteractiveImportItem, saveInteractiveImportItem }; @@ -39,20 +35,20 @@ class SelectAlbumModalContentConnector extends Component { artistId } = this.props; - this.props.fetchInteractiveImportAlbums({ artistId }); + this.props.fetchAlbums({ artistId }); } componentWillUnmount() { // This clears the albums for the queue and hides the queue // We'll need another place to store albums for manual import - this.props.clearInteractiveImportAlbums(); + this.props.clearAlbums(); } // // Listeners onSortPress = (sortKey, sortDirection) => { - this.props.setInteractiveImportAlbumsSort({ sortKey, sortDirection }); + this.props.setAlbumsSort({ sortKey, sortDirection }); }; onAlbumSelect = (albumId) => { @@ -82,6 +78,7 @@ class SelectAlbumModalContentConnector extends Component { return ( ); @@ -92,9 +89,9 @@ SelectAlbumModalContentConnector.propTypes = { ids: PropTypes.arrayOf(PropTypes.number).isRequired, artistId: PropTypes.number.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchInteractiveImportAlbums: PropTypes.func.isRequired, - setInteractiveImportAlbumsSort: PropTypes.func.isRequired, - clearInteractiveImportAlbums: PropTypes.func.isRequired, + fetchAlbums: PropTypes.func.isRequired, + setAlbumsSort: PropTypes.func.isRequired, + clearAlbums: PropTypes.func.isRequired, saveInteractiveImportItem: PropTypes.func.isRequired, updateInteractiveImportItem: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired diff --git a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js index d9301cdfa..26fa7282a 100644 --- a/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js +++ b/frontend/src/InteractiveImport/AlbumRelease/SelectAlbumReleaseRow.js @@ -1,10 +1,9 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import FormInputGroup from 'Components/Form/FormInputGroup'; +import SelectInput from 'Components/Form/SelectInput'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; -import { inputTypes } from 'Helpers/Props'; import shortenList from 'Utilities/String/shortenList'; import titleCase from 'Utilities/String/titleCase'; @@ -56,8 +55,7 @@ class SelectAlbumReleaseRow extends Component { if (name === 'release') { return ( - ({ key: r.id, diff --git a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModal.js b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModal.js new file mode 100644 index 000000000..04359b96a --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModal.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import SelectIndexerFlagsModalContentConnector from './SelectIndexerFlagsModalContentConnector'; + +class SelectIndexerFlagsModal extends Component { + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +SelectIndexerFlagsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectIndexerFlagsModal; diff --git a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css new file mode 100644 index 000000000..72dfb1cb6 --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css @@ -0,0 +1,7 @@ +.modalBody { + composes: modalBody from '~Components/Modal/ModalBody.css'; + + display: flex; + flex: 1 1 auto; + flex-direction: column; +} diff --git a/frontend/src/Components/Form/PasswordInput.css.d.ts b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts similarity index 87% rename from frontend/src/Components/Form/PasswordInput.css.d.ts rename to frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts index 774807ef4..3fc49a060 100644 --- a/frontend/src/Components/Form/PasswordInput.css.d.ts +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.css.d.ts @@ -1,7 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'input': string; + 'modalBody': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.js b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.js new file mode 100644 index 000000000..b30f76775 --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContent.js @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds, scrollDirections } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './SelectIndexerFlagsModalContent.css'; + +class SelectIndexerFlagsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + indexerFlags + } = props; + + this.state = { + indexerFlags + }; + } + + // + // Listeners + + onIndexerFlagsChange = ({ value }) => { + this.setState({ indexerFlags: value }); + }; + + onIndexerFlagsSelect = () => { + this.props.onIndexerFlagsSelect(this.state); + }; + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + indexerFlags + } = this.state; + + return ( + + + Manual Import - Set indexer Flags + + + + + + + {translate('IndexerFlags')} + + + + + + + + + + + + + + ); + } +} + +SelectIndexerFlagsModalContent.propTypes = { + indexerFlags: PropTypes.number.isRequired, + onIndexerFlagsSelect: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default SelectIndexerFlagsModalContent; diff --git a/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContentConnector.js b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContentConnector.js new file mode 100644 index 000000000..7a9af7353 --- /dev/null +++ b/frontend/src/InteractiveImport/IndexerFlags/SelectIndexerFlagsModalContentConnector.js @@ -0,0 +1,54 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { saveInteractiveImportItem, updateInteractiveImportItems } from 'Store/Actions/interactiveImportActions'; +import SelectIndexerFlagsModalContent from './SelectIndexerFlagsModalContent'; + +const mapDispatchToProps = { + dispatchUpdateInteractiveImportItems: updateInteractiveImportItems, + dispatchSaveInteractiveImportItems: saveInteractiveImportItem +}; + +class SelectIndexerFlagsModalContentConnector extends Component { + + // + // Listeners + + onIndexerFlagsSelect = ({ indexerFlags }) => { + const { + ids, + dispatchUpdateInteractiveImportItems, + dispatchSaveInteractiveImportItems + } = this.props; + + dispatchUpdateInteractiveImportItems({ + ids, + indexerFlags + }); + + dispatchSaveInteractiveImportItems({ ids }); + + this.props.onModalClose(true); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +SelectIndexerFlagsModalContentConnector.propTypes = { + ids: PropTypes.arrayOf(PropTypes.number).isRequired, + dispatchUpdateInteractiveImportItems: PropTypes.func.isRequired, + dispatchSaveInteractiveImportItems: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(null, mapDispatchToProps)(SelectIndexerFlagsModalContentConnector); diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css index 573b16667..93d815c9c 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css @@ -18,12 +18,17 @@ .leftButtons, .rightButtons { display: flex; - flex: 1 0 50%; flex-wrap: wrap; + min-width: 0; +} + +.leftButtons { + flex: 0 1 auto; } .rightButtons { justify-content: flex-end; + flex: 1 1 50%; } .importMode, @@ -31,6 +36,7 @@ composes: select from '~Components/Form/SelectInput.css'; margin-right: 10px; + max-width: 100%; width: auto; } @@ -43,10 +49,12 @@ .leftButtons, .rightButtons { flex-direction: column; + gap: 3px; } .leftButtons { align-items: flex-start; + max-width: fit-content; } .rightButtons { diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js index d1361a785..d980e77ce 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -20,6 +20,7 @@ import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal'; import SelectAlbumReleaseModal from 'InteractiveImport/AlbumRelease/SelectAlbumReleaseModal'; import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal'; import ConfirmImportModal from 'InteractiveImport/Confirmation/ConfirmImportModal'; +import SelectIndexerFlagsModal from 'InteractiveImport/IndexerFlags/SelectIndexerFlagsModal'; import SelectQualityModal from 'InteractiveImport/Quality/SelectQualityModal'; import SelectReleaseGroupModal from 'InteractiveImport/ReleaseGroup/SelectReleaseGroupModal'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; @@ -30,7 +31,7 @@ import toggleSelected from 'Utilities/Table/toggleSelected'; import InteractiveImportRow from './InteractiveImportRow'; import styles from './InteractiveImportModalContent.css'; -const columns = [ +const COLUMNS = [ { name: 'path', label: () => translate('Path'), @@ -79,11 +80,21 @@ const columns = [ isSortable: true, isVisible: true }, + { + name: 'indexerFlags', + label: React.createElement(Icon, { + name: icons.FLAG, + title: () => translate('IndexerFlags') + }), + isSortable: true, + isVisible: true + }, { name: 'rejections', label: React.createElement(Icon, { name: icons.DANGER, - kind: kinds.DANGER + kind: kinds.DANGER, + title: () => translate('Rejections') }), isSortable: true, isVisible: true @@ -107,6 +118,7 @@ const ALBUM = 'album'; const ALBUM_RELEASE = 'albumRelease'; const RELEASE_GROUP = 'releaseGroup'; const QUALITY = 'quality'; +const INDEXER_FLAGS = 'indexerFlags'; const replaceExistingFilesOptions = { COMBINE: 'combine', @@ -301,6 +313,21 @@ class InteractiveImportModalContent extends Component { inconsistentAlbumReleases } = this.state; + const allColumns = _.cloneDeep(COLUMNS); + const columns = allColumns.map((column) => { + const showIndexerFlags = items.some((item) => item.indexerFlags); + + if (!showIndexerFlags) { + const indexerFlagsColumn = allColumns.find((c) => c.name === 'indexerFlags'); + + if (indexerFlagsColumn) { + indexerFlagsColumn.isVisible = false; + } + } + + return column; + }); + const selectedIds = this.getSelectedIds(); const selectedItem = selectedIds.length ? _.find(items, { id: selectedIds[0] }) : null; const errorMessage = getErrorMessage(error, 'Unable to load manual import items'); @@ -310,7 +337,8 @@ class InteractiveImportModalContent extends Component { { key: ALBUM, value: translate('SelectAlbum') }, { key: ALBUM_RELEASE, value: translate('SelectAlbumRelease') }, { key: QUALITY, value: translate('SelectQuality') }, - { key: RELEASE_GROUP, value: translate('SelectReleaseGroup') } + { key: RELEASE_GROUP, value: translate('SelectReleaseGroup') }, + { key: INDEXER_FLAGS, value: translate('SelectIndexerFlags') } ]; if (allowArtistChange) { @@ -433,6 +461,7 @@ class InteractiveImportModalContent extends Component { isSaving={isSaving} {...item} allowArtistChange={allowArtistChange} + columns={columns} onSelectedChange={this.onSelectedChange} onValidRowChange={this.onValidRowChange} /> @@ -547,6 +576,13 @@ class InteractiveImportModalContent extends Component { onModalClose={this.onSelectModalClose} /> + + 0 ) { this.props.onSelectedChange({ id, value: true }); } @@ -130,6 +135,10 @@ class InteractiveImportRow extends Component { this.setState({ isSelectQualityModalOpen: true }); }; + onSelectIndexerFlagsPress = () => { + this.setState({ isSelectIndexerFlagsModalOpen: true }); + }; + onSelectArtistModalClose = (changed) => { this.setState({ isSelectArtistModalOpen: false }); this.selectRowAfterChange(changed); @@ -155,6 +164,11 @@ class InteractiveImportRow extends Component { this.selectRowAfterChange(changed); }; + onSelectIndexerFlagsModalClose = (changed) => { + this.setState({ isSelectIndexerFlagsModalOpen: false }); + this.selectRowAfterChange(changed); + }; + // // Render @@ -171,7 +185,9 @@ class InteractiveImportRow extends Component { releaseGroup, size, customFormats, + indexerFlags, rejections, + columns, isReprocessing, audioTags, additionalFile, @@ -184,7 +200,8 @@ class InteractiveImportRow extends Component { isSelectAlbumModalOpen, isSelectTrackModalOpen, isSelectReleaseGroupModalOpen, - isSelectQualityModalOpen + isSelectQualityModalOpen, + isSelectIndexerFlagsModalOpen } = this.state; const artistName = artist ? artist.artistName : ''; @@ -204,6 +221,7 @@ class InteractiveImportRow extends Component { const showTrackNumbersLoading = isReprocessing && !tracks.length; const showReleaseGroupPlaceholder = isSelected && !releaseGroup; const showQualityPlaceholder = isSelected && !quality; + const showIndexerFlagsPlaceholder = isSelected && !indexerFlags; const pathCellContents = (
@@ -219,6 +237,8 @@ class InteractiveImportRow extends Component { /> ) : pathCellContents; + const isIndexerFlagsColumnVisible = columns.find((c) => c.name === 'indexerFlags')?.isVisible ?? false; + return ( + {isIndexerFlagsColumnVisible ? ( + + {showIndexerFlagsPlaceholder ? ( + + ) : ( + <> + {indexerFlags ? ( + } + title={translate('IndexerFlags')} + body={} + position={tooltipPositions.LEFT} + /> + ) : null} + + )} + + ) : null} + { rejections.length ? @@ -395,6 +437,13 @@ class InteractiveImportRow extends Component { real={quality ? quality.revision.real > 0 : false} onModalClose={this.onSelectQualityModalClose} /> + + ); } @@ -413,7 +462,9 @@ InteractiveImportRow.propTypes = { quality: PropTypes.object, size: PropTypes.number.isRequired, customFormats: PropTypes.arrayOf(PropTypes.object), + indexerFlags: PropTypes.number.isRequired, rejections: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, audioTags: PropTypes.object.isRequired, additionalFile: PropTypes.bool.isRequired, isReprocessing: PropTypes.bool, diff --git a/frontend/src/InteractiveImport/InteractiveImportModal.js b/frontend/src/InteractiveImport/InteractiveImportModal.js index dda75152f..4d0e9f2f1 100644 --- a/frontend/src/InteractiveImport/InteractiveImportModal.js +++ b/frontend/src/InteractiveImport/InteractiveImportModal.js @@ -48,7 +48,7 @@ class InteractiveImportModal extends Component { return ( diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.js b/frontend/src/InteractiveSearch/InteractiveSearch.js index 6e74695b0..64d1ce730 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearch.js +++ b/frontend/src/InteractiveSearch/InteractiveSearch.js @@ -65,6 +65,15 @@ const columns = [ isSortable: true, isVisible: true }, + { + name: 'indexerFlags', + label: React.createElement(Icon, { + name: icons.FLAG, + title: () => translate('IndexerFlags') + }), + isSortable: true, + isVisible: true + }, { name: 'rejections', label: React.createElement(Icon, { diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css index ffea82600..dad7242c8 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css @@ -35,6 +35,7 @@ } .rejected, +.indexerFlags, .download { composes: cell from '~Components/Table/Cells/TableRowCell.css'; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts index ca01c5ee6..bec6dcf78 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.css.d.ts @@ -5,6 +5,7 @@ interface CssExports { 'customFormatScore': string; 'download': string; 'indexer': string; + 'indexerFlags': string; 'peers': string; 'protocol': string; 'quality': string; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js index 3029d2d2f..a139f8085 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.js +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import AlbumFormats from 'Album/AlbumFormats'; +import IndexerFlags from 'Album/IndexerFlags'; import TrackQuality from 'Album/TrackQuality'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; @@ -48,12 +49,12 @@ function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { if (isGrabbing) { return ''; } else if (isGrabbed) { - return 'Added to downloaded queue'; + return translate('AddedToDownloadQueue'); } else if (grabError) { return grabError; } - return 'Add to downloaded queue'; + return translate('AddToDownloadQueue'); } class InteractiveSearchRow extends Component { @@ -129,6 +130,7 @@ class InteractiveSearchRow extends Component { quality, customFormatScore, customFormats, + indexerFlags = 0, rejections, downloadAllowed, isGrabbing, @@ -187,10 +189,21 @@ class InteractiveSearchRow extends Component { formatCustomFormatScore(customFormatScore, customFormats.length) } tooltip={} - position={tooltipPositions.BOTTOM} + position={tooltipPositions.LEFT} /> + + {indexerFlags ? ( + } + title={translate('IndexerFlags')} + body={} + position={tooltipPositions.LEFT} + /> + ) : null} + + { !!rejections.length && @@ -236,7 +249,9 @@ class InteractiveSearchRow extends Component { isOpen={this.state.isConfirmGrabModalOpen} kind={kinds.WARNING} title={translate('GrabRelease')} - message={translate('GrabReleaseMessageText', [title])} + message={translate('GrabReleaseUnknownArtistOrAlbumMessageText', { + title + })} confirmLabel={translate('Grab')} onConfirm={this.onGrabConfirm} onCancel={this.onGrabCancel} @@ -263,6 +278,7 @@ InteractiveSearchRow.propTypes = { quality: PropTypes.object.isRequired, customFormats: PropTypes.arrayOf(PropTypes.object), customFormatScore: PropTypes.number.isRequired, + indexerFlags: PropTypes.number.isRequired, rejections: PropTypes.arrayOf(PropTypes.string).isRequired, downloadAllowed: PropTypes.bool.isRequired, isGrabbing: PropTypes.bool.isRequired, @@ -275,6 +291,7 @@ InteractiveSearchRow.propTypes = { }; InteractiveSearchRow.defaultProps = { + indexerFlags: 0, rejections: [], isGrabbing: false, isGrabbed: false diff --git a/frontend/src/Parse/Parse.css b/frontend/src/Parse/Parse.css new file mode 100644 index 000000000..43536452c --- /dev/null +++ b/frontend/src/Parse/Parse.css @@ -0,0 +1,45 @@ +.inputContainer { + display: flex; + margin-bottom: 10px; +} + +.inputIconContainer { + width: 58px; + height: 46px; + border: 1px solid var(--inputBorderColor); + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: var(--inputIconContainerBackgroundColor); + text-align: center; + line-height: 46px; +} + +.input { + composes: input from '~Components/Form/TextInput.css'; + + height: 46px; + border-radius: 0; + font-size: 18px; +} + +.clearButton { + border: 1px solid var(--inputBorderColor); + border-left: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} + +.message { + margin-top: 30px; + text-align: center; + font-weight: 300; + font-size: $largeFontSize; +} + +.helpText { + margin-bottom: 10px; + font-size: 24px; +} diff --git a/frontend/src/Parse/Parse.css.d.ts b/frontend/src/Parse/Parse.css.d.ts new file mode 100644 index 000000000..4a4def577 --- /dev/null +++ b/frontend/src/Parse/Parse.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'clearButton': string; + 'helpText': string; + 'input': string; + 'inputContainer': string; + 'inputIconContainer': string; + 'message': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Parse/Parse.tsx b/frontend/src/Parse/Parse.tsx new file mode 100644 index 000000000..15a0deb47 --- /dev/null +++ b/frontend/src/Parse/Parse.tsx @@ -0,0 +1,109 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import TextInput from 'Components/Form/TextInput'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import { icons } from 'Helpers/Props'; +import { clear, fetch } from 'Store/Actions/parseActions'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import ParseResult from './ParseResult'; +import parseStateSelector from './parseStateSelector'; +import styles from './Parse.css'; + +function Parse() { + const { isFetching, error, item } = useSelector(parseStateSelector()); + + const [title, setTitle] = useState(''); + const dispatch = useDispatch(); + + const onInputChange = useCallback( + ({ value }: { value: string }) => { + const trimmedValue = value.trim(); + + setTitle(value); + + if (trimmedValue === '') { + dispatch(clear()); + } else { + dispatch(fetch({ title: trimmedValue })); + } + }, + [setTitle, dispatch] + ); + + const onClearPress = useCallback(() => { + setTitle(''); + dispatch(clear()); + }, [setTitle, dispatch]); + + useEffect( + () => { + return () => { + dispatch(clear()); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( + + +
+
+ +
+ + + + +
+ + {isFetching ? : null} + + {!isFetching && !!error ? ( +
+
+ {translate('ParseModalErrorParsing')} +
+
{getErrorMessage(error)}
+
+ ) : null} + + {!isFetching && title && !error && !item.parsedAlbumInfo ? ( +
+ {translate('ParseModalUnableToParse')} +
+ ) : null} + + {!isFetching && !error && item.parsedAlbumInfo ? ( + + ) : null} + + {title ? null : ( +
+
+ {translate('ParseModalHelpText')} +
+
{translate('ParseModalHelpTextDetails')}
+
+ )} +
+
+ ); +} + +export default Parse; diff --git a/frontend/src/Parse/ParseModal.tsx b/frontend/src/Parse/ParseModal.tsx new file mode 100644 index 000000000..0ee455bf0 --- /dev/null +++ b/frontend/src/Parse/ParseModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ParseModalContent from './ParseModalContent'; + +interface ParseModalProps { + isOpen: boolean; + onModalClose: () => void; +} + +function ParseModal(props: ParseModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ParseModal; diff --git a/frontend/src/Parse/ParseModalContent.css b/frontend/src/Parse/ParseModalContent.css new file mode 100644 index 000000000..43536452c --- /dev/null +++ b/frontend/src/Parse/ParseModalContent.css @@ -0,0 +1,45 @@ +.inputContainer { + display: flex; + margin-bottom: 10px; +} + +.inputIconContainer { + width: 58px; + height: 46px; + border: 1px solid var(--inputBorderColor); + border-right: none; + border-radius: 4px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background-color: var(--inputIconContainerBackgroundColor); + text-align: center; + line-height: 46px; +} + +.input { + composes: input from '~Components/Form/TextInput.css'; + + height: 46px; + border-radius: 0; + font-size: 18px; +} + +.clearButton { + border: 1px solid var(--inputBorderColor); + border-left: none; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); +} + +.message { + margin-top: 30px; + text-align: center; + font-weight: 300; + font-size: $largeFontSize; +} + +.helpText { + margin-bottom: 10px; + font-size: 24px; +} diff --git a/frontend/src/Parse/ParseModalContent.css.d.ts b/frontend/src/Parse/ParseModalContent.css.d.ts new file mode 100644 index 000000000..4a4def577 --- /dev/null +++ b/frontend/src/Parse/ParseModalContent.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'clearButton': string; + 'helpText': string; + 'input': string; + 'inputContainer': string; + 'inputIconContainer': string; + 'message': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Parse/ParseModalContent.tsx b/frontend/src/Parse/ParseModalContent.tsx new file mode 100644 index 000000000..d5ae93759 --- /dev/null +++ b/frontend/src/Parse/ParseModalContent.tsx @@ -0,0 +1,122 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import TextInput from 'Components/Form/TextInput'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { icons } from 'Helpers/Props'; +import { clear, fetch } from 'Store/Actions/parseActions'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import ParseResult from './ParseResult'; +import parseStateSelector from './parseStateSelector'; +import styles from './ParseModalContent.css'; + +interface ParseModalContentProps { + onModalClose: () => void; +} + +function ParseModalContent(props: ParseModalContentProps) { + const { onModalClose } = props; + const { isFetching, error, item } = useSelector(parseStateSelector()); + + const [title, setTitle] = useState(''); + const dispatch = useDispatch(); + + const onInputChange = useCallback( + ({ value }: { value: string }) => { + const trimmedValue = value.trim(); + + setTitle(value); + + if (trimmedValue === '') { + dispatch(clear()); + } else { + dispatch(fetch({ title: trimmedValue })); + } + }, + [setTitle, dispatch] + ); + + const onClearPress = useCallback(() => { + setTitle(''); + dispatch(clear()); + }, [setTitle, dispatch]); + + useEffect( + () => { + return () => { + dispatch(clear()); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( + + {translate('TestParsing')} + + +
+
+ +
+ + + + +
+ + {isFetching ? : null} + + {!isFetching && !!error ? ( +
+
+ {translate('ParseModalErrorParsing')} +
+
{getErrorMessage(error)}
+
+ ) : null} + + {!isFetching && title && !error && !item.parsedAlbumInfo ? ( +
+ {translate('ParseModalUnableToParse')} +
+ ) : null} + + {!isFetching && !error && item.parsedAlbumInfo ? ( + + ) : null} + + {title ? null : ( +
+
+ {translate('ParseModalHelpText')} +
+
{translate('ParseModalHelpTextDetails')}
+
+ )} +
+ + + + +
+ ); +} + +export default ParseModalContent; diff --git a/frontend/src/Parse/ParseResult.css b/frontend/src/Parse/ParseResult.css new file mode 100644 index 000000000..c49c4e3fa --- /dev/null +++ b/frontend/src/Parse/ParseResult.css @@ -0,0 +1,8 @@ +.container { + display: flex; + flex-wrap: wrap; +} + +.column { + flex: 0 0 50%; +} diff --git a/frontend/src/Parse/ParseResult.css.d.ts b/frontend/src/Parse/ParseResult.css.d.ts new file mode 100644 index 000000000..653368e06 --- /dev/null +++ b/frontend/src/Parse/ParseResult.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'column': string; + 'container': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Parse/ParseResult.tsx b/frontend/src/Parse/ParseResult.tsx new file mode 100644 index 000000000..7e6c40d92 --- /dev/null +++ b/frontend/src/Parse/ParseResult.tsx @@ -0,0 +1,160 @@ +import _ from 'lodash'; +import moment from 'moment'; +import React from 'react'; +import AlbumFormats from 'Album/AlbumFormats'; +import AlbumTitleLink from 'Album/AlbumTitleLink'; +import { ParseModel } from 'App/State/ParseAppState'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import FieldSet from 'Components/FieldSet'; +import translate from 'Utilities/String/translate'; +import ParseResultItem from './ParseResultItem'; +import styles from './ParseResult.css'; + +interface ParseResultProps { + item: ParseModel; +} + +function ParseResult(props: ParseResultProps) { + const { item } = props; + const { customFormats, customFormatScore, albums, parsedAlbumInfo, artist } = + item; + + const { + releaseTitle, + artistName, + albumTitle, + releaseGroup, + discography, + quality, + } = parsedAlbumInfo; + + const sortedAlbums = _.sortBy(albums, (item) => + moment(item.releaseDate).unix() + ); + + return ( +
+
+ + + + + + + +
+ +
+
+
+ +
+
+
+ +
+
+
+ + 1 && !quality.revision.isRepack + ? translate('True') + : '-' + } + /> + + +
+ +
+ 1 ? quality.revision.version : '-' + } + /> + + +
+
+
+ +
+ + ) : ( + '-' + ) + } + /> + + + {sortedAlbums.map((album) => { + return ( +
+ +
+ ); + })} +
+ ) : ( + '-' + ) + } + /> + + + ) : ( + '-' + ) + } + /> + + + +
+ ); +} + +export default ParseResult; diff --git a/frontend/src/Parse/ParseResultItem.css b/frontend/src/Parse/ParseResultItem.css new file mode 100644 index 000000000..275fe7e1f --- /dev/null +++ b/frontend/src/Parse/ParseResultItem.css @@ -0,0 +1,21 @@ +.item { + display: flex; +} + +.title { + margin-right: 20px; + width: 250px; + text-align: right; + font-weight: bold; +} + +@media (max-width: $breakpointSmall) { + .item { + display: block; + margin-bottom: 10px; + } + + .title { + text-align: left; + } +} diff --git a/frontend/src/Parse/ParseResultItem.css.d.ts b/frontend/src/Parse/ParseResultItem.css.d.ts new file mode 100644 index 000000000..bcf268e50 --- /dev/null +++ b/frontend/src/Parse/ParseResultItem.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'item': string; + 'title': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Parse/ParseResultItem.tsx b/frontend/src/Parse/ParseResultItem.tsx new file mode 100644 index 000000000..661af448d --- /dev/null +++ b/frontend/src/Parse/ParseResultItem.tsx @@ -0,0 +1,20 @@ +import React, { ReactNode } from 'react'; +import styles from './ParseResultItem.css'; + +interface ParseResultItemProps { + title: string; + data: string | number | ReactNode; +} + +function ParseResultItem(props: ParseResultItemProps) { + const { title, data } = props; + + return ( +
+
{title}
+
{data}
+
+ ); +} + +export default ParseResultItem; diff --git a/frontend/src/Parse/ParseToolbarButton.tsx b/frontend/src/Parse/ParseToolbarButton.tsx new file mode 100644 index 000000000..43b8b959f --- /dev/null +++ b/frontend/src/Parse/ParseToolbarButton.tsx @@ -0,0 +1,31 @@ +import React, { Fragment, useCallback, useState } from 'react'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import { icons } from 'Helpers/Props'; +import ParseModal from 'Parse/ParseModal'; +import translate from 'Utilities/String/translate'; + +function ParseToolbarButton() { + const [isParseModalOpen, setIsParseModalOpen] = useState(false); + + const onOpenParseModalPress = useCallback(() => { + setIsParseModalOpen(true); + }, [setIsParseModalOpen]); + + const onParseModalClose = useCallback(() => { + setIsParseModalOpen(false); + }, [setIsParseModalOpen]); + + return ( + + + + + + ); +} + +export default ParseToolbarButton; diff --git a/frontend/src/Parse/parseStateSelector.ts b/frontend/src/Parse/parseStateSelector.ts new file mode 100644 index 000000000..7abcfeca1 --- /dev/null +++ b/frontend/src/Parse/parseStateSelector.ts @@ -0,0 +1,12 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import ParseAppState from 'App/State/ParseAppState'; + +export default function parseStateSelector() { + return createSelector( + (state: AppState) => state.parse, + (parse: ParseAppState) => { + return parse; + } + ); +} diff --git a/frontend/src/Search/AddNewItem.js b/frontend/src/Search/AddNewItem.js index 5ec065149..e335ef4c2 100644 --- a/frontend/src/Search/AddNewItem.js +++ b/frontend/src/Search/AddNewItem.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import TextInput from 'Components/Form/TextInput'; import Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; @@ -130,7 +131,8 @@ class AddNewItem extends Component {
{translate('FailedLoadingSearchResults')}
-
{getErrorMessage(error)}
+ + {getErrorMessage(error)}
: null } diff --git a/frontend/src/Settings/CustomFormats/CustomFormatSettingsConnector.js b/frontend/src/Settings/CustomFormats/CustomFormatSettingsConnector.js deleted file mode 100644 index 342df29d2..000000000 --- a/frontend/src/Settings/CustomFormats/CustomFormatSettingsConnector.js +++ /dev/null @@ -1,33 +0,0 @@ -import React, { Component } from 'react'; -import { DndProvider } from 'react-dnd'; -import { HTML5Backend } from 'react-dnd-html5-backend'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; -import translate from 'Utilities/String/translate'; -import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector'; - -class CustomFormatSettingsConnector extends Component { - - // - // Render - - render() { - return ( - - - - - - - - - - ); - } -} - -export default CustomFormatSettingsConnector; - diff --git a/frontend/src/Settings/CustomFormats/CustomFormatSettingsPage.tsx b/frontend/src/Settings/CustomFormats/CustomFormatSettingsPage.tsx new file mode 100644 index 000000000..66c208f9a --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormatSettingsPage.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import ParseToolbarButton from 'Parse/ParseToolbarButton'; +import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; +import translate from 'Utilities/String/translate'; +import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector'; +import ManageCustomFormatsToolbarButton from './CustomFormats/Manage/ManageCustomFormatsToolbarButton'; + +function CustomFormatSettingsPage() { + return ( + + + + + + + + + } + /> + + + {/* TODO: Upgrade react-dnd to get typings, we're 2 major versions behind */} + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-ignore */} + + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-ignore */} + + + + + ); +} + +export default CustomFormatSettingsPage; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormatsConnector.js b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormatsConnector.js index 8e828620b..0417d9b21 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormatsConnector.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormatsConnector.js @@ -4,12 +4,12 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { cloneCustomFormat, deleteCustomFormat, fetchCustomFormats } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import CustomFormats from './CustomFormats'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.customFormats', sortByName), + createSortedSectionSelector('settings.customFormats', sortByProp('name')), (customFormats) => customFormats ); } diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalConnector.js b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalConnector.js index 52b2f09f6..3e79425cd 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalConnector.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalConnector.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { clearPendingChanges } from 'Store/Actions/baseActions'; import EditCustomFormatModal from './EditCustomFormatModal'; +import EditCustomFormatModalContentConnector from './EditCustomFormatModalContentConnector'; function mapStateToProps() { return {}; @@ -36,6 +37,7 @@ class EditCustomFormatModalConnector extends Component { } EditCustomFormatModalConnector.propTypes = { + ...EditCustomFormatModalContentConnector.propTypes, onModalClose: PropTypes.func.isRequired, clearPendingChanges: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css index b7d3da255..24830ef42 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css @@ -25,3 +25,8 @@ border-radius: 4px; background-color: var(--cardCenterBackgroundColor); } + +.customFormats { + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css.d.ts b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css.d.ts index 1339caf02..1aab6062e 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css.d.ts +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.css.d.ts @@ -3,6 +3,7 @@ interface CssExports { 'addSpecification': string; 'center': string; + 'customFormats': string; 'deleteButton': string; 'rightButtons': string; } diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js index 4d8f0fd4b..8b06deb4b 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/EditCustomFormatModalContent.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import Card from 'Components/Card'; import FieldSet from 'Components/FieldSet'; import Form from 'Components/Form/Form'; @@ -150,6 +151,11 @@ class EditCustomFormatModalContent extends Component {
+ +
+ {translate('CustomFormatsSettingsTriggerInfo')} +
+
{ specifications.map((tag) => { diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModal.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModal.tsx new file mode 100644 index 000000000..3ff5cfa37 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModal.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageCustomFormatsEditModalContent from './ManageCustomFormatsEditModalContent'; + +interface ManageCustomFormatsEditModalProps { + isOpen: boolean; + customFormatIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function ManageCustomFormatsEditModal( + props: ManageCustomFormatsEditModalProps +) { + const { isOpen, customFormatIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageCustomFormatsEditModal; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css @@ -0,0 +1,16 @@ +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + +.selected { + font-weight: bold; +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .modalFooter { + flex-direction: column; + gap: 10px; + } +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css.d.ts b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modalFooter': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.tsx new file mode 100644 index 000000000..25a2f85c2 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/Edit/ManageCustomFormatsEditModalContent.tsx @@ -0,0 +1,125 @@ +import React, { useCallback, useState } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageCustomFormatsEditModalContent.css'; + +interface SavePayload { + includeCustomFormatWhenRenaming?: boolean; +} + +interface ManageCustomFormatsEditModalContentProps { + customFormatIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const enableOptions = [ + { + key: NO_CHANGE, + get value() { + return translate('NoChange'); + }, + isDisabled: true, + }, + { + key: 'enabled', + get value() { + return translate('Enabled'); + }, + }, + { + key: 'disabled', + get value() { + return translate('Disabled'); + }, + }, +]; + +function ManageCustomFormatsEditModalContent( + props: ManageCustomFormatsEditModalContentProps +) { + const { customFormatIds, onSavePress, onModalClose } = props; + + const [includeCustomFormatWhenRenaming, setIncludeCustomFormatWhenRenaming] = + useState(NO_CHANGE); + + const save = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (includeCustomFormatWhenRenaming !== NO_CHANGE) { + hasChanges = true; + payload.includeCustomFormatWhenRenaming = + includeCustomFormatWhenRenaming === 'enabled'; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, [includeCustomFormatWhenRenaming, onSavePress, onModalClose]); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'includeCustomFormatWhenRenaming': + setIncludeCustomFormatWhenRenaming(value); + break; + default: + console.warn( + `EditCustomFormatsModalContent Unknown Input: '${name}'` + ); + } + }, + [] + ); + + const selectedCount = customFormatIds.length; + + return ( + + {translate('EditSelectedCustomFormats')} + + + + {translate('IncludeCustomFormatWhenRenaming')} + + + + + + +
+ {translate('CountCustomFormatsSelected', { + count: selectedCount, + })} +
+ +
+ + + +
+
+
+ ); +} + +export default ManageCustomFormatsEditModalContent; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModal.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModal.tsx new file mode 100644 index 000000000..dd3456437 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageCustomFormatsModalContent from './ManageCustomFormatsModalContent'; + +interface ManageCustomFormatsModalProps { + isOpen: boolean; + onModalClose(): void; +} + +function ManageCustomFormatsModal(props: ManageCustomFormatsModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageCustomFormatsModal; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css new file mode 100644 index 000000000..6ea04a0c8 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css @@ -0,0 +1,16 @@ +.leftButtons, +.rightButtons { + display: flex; + flex: 1 0 50%; + flex-wrap: wrap; +} + +.rightButtons { + justify-content: flex-end; +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: 10px; +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css.d.ts b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css.d.ts new file mode 100644 index 000000000..7b392fff9 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; + 'leftButtons': string; + 'rightButtons': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.tsx new file mode 100644 index 000000000..aabaf67c1 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalContent.tsx @@ -0,0 +1,244 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { CustomFormatAppState } from 'App/State/SettingsAppState'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import Column from 'Components/Table/Column'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { kinds } from 'Helpers/Props'; +import { + bulkDeleteCustomFormats, + bulkEditCustomFormats, + setManageCustomFormatsSort, +} from 'Store/Actions/settingsActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { SelectStateInputProps } from 'typings/props'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import ManageCustomFormatsEditModal from './Edit/ManageCustomFormatsEditModal'; +import ManageCustomFormatsModalRow from './ManageCustomFormatsModalRow'; +import styles from './ManageCustomFormatsModalContent.css'; + +// TODO: This feels janky to do, but not sure of a better way currently +type OnSelectedChangeCallback = React.ComponentProps< + typeof ManageCustomFormatsModalRow +>['onSelectedChange']; + +const COLUMNS: Column[] = [ + { + name: 'name', + label: () => translate('Name'), + isSortable: true, + isVisible: true, + }, + { + name: 'includeCustomFormatWhenRenaming', + label: () => translate('IncludeCustomFormatWhenRenaming'), + isSortable: true, + isVisible: true, + }, + { + name: 'actions', + label: '', + isVisible: true, + }, +]; + +interface ManageCustomFormatsModalContentProps { + onModalClose(): void; +} + +function ManageCustomFormatsModalContent( + props: ManageCustomFormatsModalContentProps +) { + const { onModalClose } = props; + + const { + isFetching, + isPopulated, + isDeleting, + isSaving, + error, + items, + sortKey, + sortDirection, + }: CustomFormatAppState = useSelector( + createClientSideCollectionSelector('settings.customFormats') + ); + const dispatch = useDispatch(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const [selectState, setSelectState] = useSelectState(); + + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds: number[] = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const selectedCount = selectedIds.length; + + const onSortPress = useCallback( + (value: string) => { + dispatch(setManageCustomFormatsSort({ sortKey: value })); + }, + [dispatch] + ); + + const onDeletePress = useCallback(() => { + setIsDeleteModalOpen(true); + }, [setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback(() => { + dispatch(bulkDeleteCustomFormats({ ids: selectedIds })); + setIsDeleteModalOpen(false); + }, [selectedIds, dispatch]); + + const onSavePress = useCallback( + (payload: object) => { + setIsEditModalOpen(false); + + dispatch( + bulkEditCustomFormats({ + ids: selectedIds, + ...payload, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onSelectAllChange = useCallback( + ({ value }: SelectStateInputProps) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const onSelectedChange = useCallback( + ({ id, value, shiftKey = false }) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const errorMessage = getErrorMessage(error, 'Unable to load custom formats.'); + const anySelected = selectedCount > 0; + + return ( + + {translate('ManageCustomFormats')} + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !error && !items.length ? ( + {translate('NoCustomFormatsFound')} + ) : null} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} +
+ + +
+ + {translate('Delete')} + + + + {translate('Edit')} + +
+ + +
+ + + + +
+ ); +} + +export default ManageCustomFormatsModalContent; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css new file mode 100644 index 000000000..355c70378 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css @@ -0,0 +1,12 @@ +.name, +.includeCustomFormatWhenRenaming { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} + +.actions { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 40px; +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css.d.ts b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css.d.ts new file mode 100644 index 000000000..d1719edd8 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'actions': string; + 'includeCustomFormatWhenRenaming': string; + 'name': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx new file mode 100644 index 000000000..57bb7fda0 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsModalRow.tsx @@ -0,0 +1,126 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import { icons } from 'Helpers/Props'; +import { deleteCustomFormat } from 'Store/Actions/settingsActions'; +import { SelectStateInputProps } from 'typings/props'; +import translate from 'Utilities/String/translate'; +import EditCustomFormatModalConnector from '../EditCustomFormatModalConnector'; +import styles from './ManageCustomFormatsModalRow.css'; + +interface ManageCustomFormatsModalRowProps { + id: number; + name: string; + includeCustomFormatWhenRenaming: boolean; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; +} + +function isDeletingSelector() { + return createSelector( + (state: AppState) => state.settings.customFormats.isDeleting, + (isDeleting) => { + return isDeleting; + } + ); +} + +function ManageCustomFormatsModalRow(props: ManageCustomFormatsModalRowProps) { + const { + id, + isSelected, + name, + includeCustomFormatWhenRenaming, + onSelectedChange, + } = props; + + const dispatch = useDispatch(); + const isDeleting = useSelector(isDeletingSelector()); + + const [isEditCustomFormatModalOpen, setIsEditCustomFormatModalOpen] = + useState(false); + + const [isDeleteCustomFormatModalOpen, setIsDeleteCustomFormatModalOpen] = + useState(false); + + const handlelectedChange = useCallback( + (result: SelectStateInputProps) => { + onSelectedChange({ + ...result, + }); + }, + [onSelectedChange] + ); + + const handleEditCustomFormatModalOpen = useCallback(() => { + setIsEditCustomFormatModalOpen(true); + }, [setIsEditCustomFormatModalOpen]); + + const handleEditCustomFormatModalClose = useCallback(() => { + setIsEditCustomFormatModalOpen(false); + }, [setIsEditCustomFormatModalOpen]); + + const handleDeleteCustomFormatPress = useCallback(() => { + setIsEditCustomFormatModalOpen(false); + setIsDeleteCustomFormatModalOpen(true); + }, [setIsEditCustomFormatModalOpen, setIsDeleteCustomFormatModalOpen]); + + const handleDeleteCustomFormatModalClose = useCallback(() => { + setIsDeleteCustomFormatModalOpen(false); + }, [setIsDeleteCustomFormatModalOpen]); + + const handleConfirmDeleteCustomFormat = useCallback(() => { + dispatch(deleteCustomFormat({ id })); + }, [id, dispatch]); + + return ( + + + + {name} + + + {includeCustomFormatWhenRenaming ? translate('Yes') : translate('No')} + + + + + + + + + + + ); +} + +export default ManageCustomFormatsModalRow; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsToolbarButton.tsx b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsToolbarButton.tsx new file mode 100644 index 000000000..91f41dc44 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/Manage/ManageCustomFormatsToolbarButton.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; +import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import ManageCustomFormatsModal from './ManageCustomFormatsModal'; + +function ManageCustomFormatsToolbarButton() { + const [isManageModalOpen, openManageModal, closeManageModal] = + useModalOpenState(false); + + return ( + <> + + + + + ); +} + +export default ManageCustomFormatsToolbarButton; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.js b/frontend/src/Settings/CustomFormats/CustomFormats/Specifications/EditSpecificationModalContent.js index 02a31eda7..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'; @@ -52,12 +52,13 @@ function EditSpecificationModalContent(props) { fields && fields.some((x) => x.label === translate('CustomFormatsSpecificationRegularExpression')) &&
-
\\^$.|?*+()[{ have special meanings and need escaping with a \\' }} /> - {'More details'} {'Here'} +
- {'Regular expressions can be tested '} - Here + +
+
+
} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js index d9e543469..0dc410fcb 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js @@ -5,12 +5,12 @@ import { createSelector } from 'reselect'; import { deleteDownloadClient, fetchDownloadClients } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import DownloadClients from './DownloadClients'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.downloadClients', sortByName), + createSortedSectionSelector('settings.downloadClients', sortByProp('name')), createTagsSelector(), (downloadClients, tagList) => { return { diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js index d3d51c7fc..8d7d994f7 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js @@ -15,6 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton'; import translate from 'Utilities/String/translate'; import styles from './EditDownloadClientModalContent.css'; @@ -37,6 +38,7 @@ class EditDownloadClientModalContent extends Component { onModalClose, onSavePress, onTestPress, + onAdvancedSettingsPress, onDeleteDownloadClientPress, ...otherProps } = this.props; @@ -206,6 +208,12 @@ class EditDownloadClientModalContent extends Component { } + + { + this.props.toggleAdvancedSettings(); + }; + // // Render @@ -65,6 +76,7 @@ class EditDownloadClientModalContentConnector extends Component { {...this.props} onSavePress={this.onSavePress} onTestPress={this.onTestPress} + onAdvancedSettingsPress={this.onAdvancedSettingsPress} onInputChange={this.onInputChange} onFieldChange={this.onFieldChange} /> @@ -82,6 +94,7 @@ EditDownloadClientModalContentConnector.propTypes = { setDownloadClientFieldValue: PropTypes.func.isRequired, saveDownloadClient: PropTypes.func.isRequired, testDownloadClient: PropTypes.func.isRequired, + toggleAdvancedSettings: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx index 18ae5170a..7599cb9b0 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx @@ -32,7 +32,7 @@ const enableOptions = [ get value() { return translate('NoChange'); }, - disabled: true, + isDisabled: true, }, { key: 'enabled', diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css index c106388ab..6ea04a0c8 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css @@ -13,4 +13,4 @@ composes: button from '~Components/Link/Button.css'; margin-right: 10px; -} \ No newline at end of file +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx index 2722f02fa..b2c1208cb 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx @@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import Column from 'Components/Table/Column'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import useSelectState from 'Helpers/Hooks/useSelectState'; import { kinds } from 'Helpers/Props'; -import SortDirection from 'Helpers/Props/SortDirection'; import { bulkDeleteDownloadClients, bulkEditDownloadClients, @@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps< typeof ManageDownloadClientsModalRow >['onSelectedChange']; -const COLUMNS = [ +const COLUMNS: Column[] = [ { name: 'name', label: () => translate('Name'), @@ -82,8 +82,6 @@ const COLUMNS = [ interface ManageDownloadClientsModalContentProps { onModalClose(): void; - sortKey?: string; - sortDirection?: SortDirection; } function ManageDownloadClientsModalContent( @@ -220,9 +218,9 @@ function ManageDownloadClientsModalContent( {error ?
{errorMessage}
: null} - {isPopulated && !error && !items.length && ( + {isPopulated && !error && !items.length ? ( {translate('NoDownloadClientsFound')} - )} + ) : null} {isPopulated && !!items.length && !isFetching && !isFetching ? ( diff --git a/frontend/src/Settings/General/LoggingSettings.js b/frontend/src/Settings/General/LoggingSettings.js index 93918a3d2..043867853 100644 --- a/frontend/src/Settings/General/LoggingSettings.js +++ b/frontend/src/Settings/General/LoggingSettings.js @@ -15,12 +15,14 @@ const logLevelOptions = [ function LoggingSettings(props) { const { + advancedSettings, settings, onInputChange } = props; const { - logLevel + logLevel, + logSizeLimit } = settings; return ( @@ -39,11 +41,30 @@ function LoggingSettings(props) { {...logLevel} /> + + + {translate('LogSizeLimit')} + + + ); } LoggingSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, settings: PropTypes.object.isRequired, onInputChange: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/General/UpdateSettings.js b/frontend/src/Settings/General/UpdateSettings.js index 081f5dda2..a151423e5 100644 --- a/frontend/src/Settings/General/UpdateSettings.js +++ b/frontend/src/Settings/General/UpdateSettings.js @@ -18,7 +18,6 @@ function UpdateSettings(props) { const { advancedSettings, settings, - isWindows, packageUpdateMechanism, onInputChange } = props; @@ -44,10 +43,10 @@ function UpdateSettings(props) { value: titleCase(packageUpdateMechanism) }); } else { - updateOptions.push({ key: 'builtIn', value: 'Built-In' }); + updateOptions.push({ key: 'builtIn', value: translate('BuiltIn') }); } - updateOptions.push({ key: 'script', value: 'Script' }); + updateOptions.push({ key: 'script', value: translate('Script') }); return (
@@ -69,62 +68,59 @@ function UpdateSettings(props) { /> - { - !isWindows && -
- - {translate('Automatic')} +
+ + {translate('Automatic')} - - + + + + {translate('Mechanism')} + + + + + { + updateMechanism.value === 'script' && - {translate('Mechanism')} + {translate('ScriptPath')} - - { - updateMechanism.value === 'script' && - - {translate('ScriptPath')} - - - - } -
- } + } +
); } diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js index ed9582e91..d50fb2385 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js @@ -20,6 +20,7 @@ import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import Popover from 'Components/Tooltip/Popover'; import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; +import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton'; import formatShortTimeSpan from 'Utilities/Date/formatShortTimeSpan'; import translate from 'Utilities/String/translate'; import styles from './EditImportListModalContent.css'; @@ -66,6 +67,7 @@ function EditImportListModalContent(props) { onModalClose, onSavePress, onTestPress, + onAdvancedSettingsPress, onDeleteImportListPress, showMetadataProfile, ...otherProps @@ -290,7 +292,7 @@ function EditImportListModalContent(props) { @@ -333,6 +335,12 @@ function EditImportListModalContent(props) { } + + { + this.props.toggleAdvancedSettings(); + }; + // // Render @@ -67,6 +78,7 @@ class EditImportListModalContentConnector extends Component { {...this.props} onSavePress={this.onSavePress} onTestPress={this.onTestPress} + onAdvancedSettingsPress={this.onAdvancedSettingsPress} onInputChange={this.onInputChange} onFieldChange={this.onFieldChange} /> @@ -84,6 +96,7 @@ EditImportListModalContentConnector.propTypes = { setImportListFieldValue: PropTypes.func.isRequired, saveImportList: PropTypes.func.isRequired, testImportList: PropTypes.func.isRequired, + toggleAdvancedSettings: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js b/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js index 5c6bad8e7..5eb47068d 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js +++ b/frontend/src/Settings/ImportLists/ImportLists/ImportListsConnector.js @@ -4,12 +4,12 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { deleteImportList, fetchImportLists, fetchRootFolders } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import ImportLists from './ImportLists'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.importLists', sortByName), + createSortedSectionSelector('settings.importLists', sortByProp('name')), (importLists) => importLists ); } diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx index 5a651ba28..82f7d309c 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx @@ -31,7 +31,7 @@ const autoAddOptions = [ get value() { return translate('NoChange'); }, - disabled: true, + isDisabled: true, }, { key: 'enabled', diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css index c106388ab..6ea04a0c8 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css @@ -13,4 +13,4 @@ composes: button from '~Components/Link/Button.css'; margin-right: 10px; -} \ No newline at end of file +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx index 60619c662..4fee485c9 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx @@ -198,9 +198,9 @@ function ManageImportListsModalContent( {error ?
{errorMessage}
: null} - {isPopulated && !error && !items.length && ( + {isPopulated && !error && !items.length ? ( {translate('NoImportListsFound')} - )} + ) : null} {isPopulated && !!items.length && !isFetching && !isFetching ? (
- {qualityProfile?.name ?? 'None'} + {qualityProfile?.name ?? translate('None')} @@ -71,7 +72,7 @@ function ManageImportListsModalRow(props: ManageImportListsModalRowProps) { - {enableAutomaticAdd ? 'Yes' : 'No'} + {enableAutomaticAdd ? translate('Yes') : translate('No')} diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js index 55235c9da..bda52ac42 100644 --- a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContent.js @@ -160,7 +160,7 @@ function EditIndexerModalContent(props) { { return { diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx index f9b051986..69ad5a988 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx @@ -32,7 +32,7 @@ const enableOptions = [ get value() { return translate('NoChange'); }, - disabled: true, + isDisabled: true, }, { key: 'enabled', diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css index c106388ab..6ea04a0c8 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css @@ -13,4 +13,4 @@ composes: button from '~Components/Link/Button.css'; margin-right: 10px; -} \ No newline at end of file +} diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx index 8137111f5..997d1b566 100644 --- a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx @@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import Column from 'Components/Table/Column'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import useSelectState from 'Helpers/Hooks/useSelectState'; import { kinds } from 'Helpers/Props'; -import SortDirection from 'Helpers/Props/SortDirection'; import { bulkDeleteIndexers, bulkEditIndexers, @@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps< typeof ManageIndexersModalRow >['onSelectedChange']; -const COLUMNS = [ +const COLUMNS: Column[] = [ { name: 'name', label: () => translate('Name'), @@ -82,8 +82,6 @@ const COLUMNS = [ interface ManageIndexersModalContentProps { onModalClose(): void; - sortKey?: string; - sortDirection?: SortDirection; } function ManageIndexersModalContent(props: ManageIndexersModalContentProps) { @@ -215,9 +213,9 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) { {error ?
{errorMessage}
: null} - {isPopulated && !error && !items.length && ( + {isPopulated && !error && !items.length ? ( {translate('NoIndexersFound')} - )} + ) : null} {isPopulated && !!items.length && !isFetching && !isFetching ? (
- { - !isWindows && - - - {translate('SkipFreeSpaceCheck')} - + + {translate('SkipFreeSpaceCheck')} - - - } + + state.settings.advancedSettings, (state) => state.settings.namingExamples, createSettingsSectionSelector(SECTION), - (advancedSettings, examples, sectionSettings) => { + (advancedSettings, namingExamples, sectionSettings) => { return { advancedSettings, - examples: examples.item, - examplesPopulated: !_.isEmpty(examples.item), + examples: namingExamples.item, + examplesPopulated: namingExamples.isPopulated, ...sectionSettings }; } diff --git a/frontend/src/Settings/MediaManagement/Naming/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} -
- - { - items.map((item) => { - return ( - - ); - }) - } - -
- } -
- ); -} - -QueuedTasks.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - items: PropTypes.array.isRequired -}; - -export default QueuedTasks; diff --git a/frontend/src/System/Tasks/Queued/QueuedTasks.tsx b/frontend/src/System/Tasks/Queued/QueuedTasks.tsx new file mode 100644 index 000000000..e79deed7c --- /dev/null +++ b/frontend/src/System/Tasks/Queued/QueuedTasks.tsx @@ -0,0 +1,74 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import FieldSet from 'Components/FieldSet'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import { fetchCommands } from 'Store/Actions/commandActions'; +import translate from 'Utilities/String/translate'; +import QueuedTaskRow from './QueuedTaskRow'; + +const columns = [ + { + name: 'trigger', + label: '', + isVisible: true, + }, + { + name: 'commandName', + label: () => translate('Name'), + isVisible: true, + }, + { + name: 'queued', + label: () => translate('Queued'), + isVisible: true, + }, + { + name: 'started', + label: () => translate('Started'), + isVisible: true, + }, + { + name: 'ended', + label: () => translate('Ended'), + isVisible: true, + }, + { + name: 'duration', + label: () => translate('Duration'), + isVisible: true, + }, + { + name: 'actions', + isVisible: true, + }, +]; + +export default function QueuedTasks() { + const dispatch = useDispatch(); + const { isFetching, isPopulated, items } = useSelector( + (state: AppState) => state.commands + ); + + useEffect(() => { + dispatch(fetchCommands()); + }, [dispatch]); + + return ( +
+ {isFetching && !isPopulated && } + + {isPopulated && ( + + + {items.map((item) => { + return ; + })} + +
+ )} +
+ ); +} diff --git a/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js b/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js deleted file mode 100644 index 5fa4d9ead..000000000 --- a/frontend/src/System/Tasks/Queued/QueuedTasksConnector.js +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchCommands } from 'Store/Actions/commandActions'; -import QueuedTasks from './QueuedTasks'; - -function createMapStateToProps() { - return createSelector( - (state) => state.commands, - (commands) => { - return commands; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchCommands: fetchCommands -}; - -class QueuedTasksConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchCommands(); - } - - // - // Render - - render() { - return ( - - ); - } -} - -QueuedTasksConnector.propTypes = { - dispatchFetchCommands: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(QueuedTasksConnector); diff --git a/frontend/src/System/Tasks/Tasks.js b/frontend/src/System/Tasks/Tasks.js index 032dbede8..03a3b6ce4 100644 --- a/frontend/src/System/Tasks/Tasks.js +++ b/frontend/src/System/Tasks/Tasks.js @@ -2,7 +2,7 @@ import React from 'react'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import translate from 'Utilities/String/translate'; -import QueuedTasksConnector from './Queued/QueuedTasksConnector'; +import QueuedTasks from './Queued/QueuedTasks'; import ScheduledTasksConnector from './Scheduled/ScheduledTasksConnector'; function Tasks() { @@ -10,7 +10,7 @@ function Tasks() { - + ); diff --git a/frontend/src/System/Updates/UpdateChanges.js b/frontend/src/System/Updates/UpdateChanges.js deleted file mode 100644 index 3588069a0..000000000 --- a/frontend/src/System/Updates/UpdateChanges.js +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; -import styles from './UpdateChanges.css'; - -class UpdateChanges extends Component { - - // - // Render - - render() { - const { - title, - changes - } = this.props; - - if (changes.length === 0) { - return null; - } - - return ( -
-
{title}
-
    - { - changes.map((change, index) => { - return ( -
  • - -
  • - ); - }) - } -
-
- ); - } - -} - -UpdateChanges.propTypes = { - title: PropTypes.string.isRequired, - changes: PropTypes.arrayOf(PropTypes.string) -}; - -export default UpdateChanges; diff --git a/frontend/src/System/Updates/UpdateChanges.tsx b/frontend/src/System/Updates/UpdateChanges.tsx new file mode 100644 index 000000000..3e5ba1c9b --- /dev/null +++ b/frontend/src/System/Updates/UpdateChanges.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +import styles from './UpdateChanges.css'; + +interface UpdateChangesProps { + title: string; + changes: string[]; +} + +function UpdateChanges(props: UpdateChangesProps) { + const { title, changes } = props; + + if (changes.length === 0) { + return null; + } + + const uniqueChanges = [...new Set(changes)]; + + return ( +
+
{title}
+
    + {uniqueChanges.map((change, index) => { + const checkChange = change.replace( + /#\d{4,5}\b/g, + (match) => + `[${match}](https://github.com/Lidarr/Lidarr/issues/${match.substring( + 1 + )})` + ); + + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + +export default UpdateChanges; diff --git a/frontend/src/System/Updates/Updates.js b/frontend/src/System/Updates/Updates.js deleted file mode 100644 index 528441cbe..000000000 --- a/frontend/src/System/Updates/Updates.js +++ /dev/null @@ -1,249 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; -import Alert from 'Components/Alert'; -import Icon from 'Components/Icon'; -import Label from 'Components/Label'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import { icons, kinds } from 'Helpers/Props'; -import formatDate from 'Utilities/Date/formatDate'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import translate from 'Utilities/String/translate'; -import UpdateChanges from './UpdateChanges'; -import styles from './Updates.css'; - -class Updates extends Component { - - // - // Render - - render() { - const { - currentVersion, - isFetching, - isPopulated, - updatesError, - generalSettingsError, - items, - isInstallingUpdate, - updateMechanism, - updateMechanismMessage, - shortDateFormat, - longDateFormat, - timeFormat, - onInstallLatestPress - } = this.props; - - const hasError = !!(updatesError || generalSettingsError); - const hasUpdates = isPopulated && !hasError && items.length > 0; - const noUpdates = isPopulated && !hasError && !items.length; - const hasUpdateToInstall = hasUpdates && _.some(items, { installable: true, latest: true }); - const noUpdateToInstall = hasUpdates && !hasUpdateToInstall; - - const externalUpdaterPrefix = 'Unable to update Lidarr directly,'; - const externalUpdaterMessages = { - external: 'Lidarr is configured to use an external update mechanism', - apt: 'use apt to install the update', - docker: 'update the docker container to receive the update' - }; - - return ( - - - { - !isPopulated && !hasError && - - } - - { - noUpdates && - - {translate('NoUpdatesAreAvailable')} - - } - - { - hasUpdateToInstall && -
- { - updateMechanism === 'builtIn' || updateMechanism === 'script' ? - - Install Latest - : - - - - -
- {externalUpdaterPrefix} -
-
- } - - { - isFetching && - - } -
- } - - { - noUpdateToInstall && -
- -
- The latest version of Lidarr is already installed -
- - { - isFetching && - - } -
- } - - { - hasUpdates && -
- { - items.map((update) => { - const hasChanges = !!update.changes; - - return ( -
-
-
{update.version}
-
-
- {formatDate(update.releaseDate, shortDateFormat)} -
- - { - update.branch === 'master' ? - null : - - } - - { - update.version === currentVersion ? - : - null - } - - { - update.version !== currentVersion && update.installedOn ? - : - null - } -
- - { - !hasChanges && -
- {translate('MaintenanceRelease')} -
- } - - { - hasChanges && -
- - - -
- } -
- ); - }) - } -
- } - - { - !!updatesError && -
- Failed to fetch updates -
- } - - { - !!generalSettingsError && -
- Failed to update settings -
- } -
-
- ); - } - -} - -Updates.propTypes = { - currentVersion: PropTypes.string.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - updatesError: PropTypes.object, - generalSettingsError: PropTypes.object, - items: PropTypes.array.isRequired, - isInstallingUpdate: PropTypes.bool.isRequired, - updateMechanism: PropTypes.string, - updateMechanismMessage: PropTypes.string, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onInstallLatestPress: PropTypes.func.isRequired -}; - -export default Updates; diff --git a/frontend/src/System/Updates/Updates.tsx b/frontend/src/System/Updates/Updates.tsx new file mode 100644 index 000000000..300ab1f99 --- /dev/null +++ b/frontend/src/System/Updates/Updates.tsx @@ -0,0 +1,303 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import { icons, kinds } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { fetchGeneralSettings } from 'Store/Actions/settingsActions'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { UpdateMechanism } from 'typings/Settings/General'; +import formatDate from 'Utilities/Date/formatDate'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import translate from 'Utilities/String/translate'; +import UpdateChanges from './UpdateChanges'; +import styles from './Updates.css'; + +const VERSION_REGEX = /\d+\.\d+\.\d+\.\d+/i; + +function createUpdatesSelector() { + return createSelector( + (state: AppState) => state.system.updates, + (state: AppState) => state.settings.general, + (updates, generalSettings) => { + const { error: updatesError, items } = updates; + + const isFetching = updates.isFetching || generalSettings.isFetching; + const isPopulated = updates.isPopulated && generalSettings.isPopulated; + + return { + isFetching, + isPopulated, + updatesError, + generalSettingsError: generalSettings.error, + items, + updateMechanism: generalSettings.item.updateMechanism, + }; + } + ); +} + +function Updates() { + const currentVersion = useSelector((state: AppState) => state.app.version); + const { packageUpdateMechanismMessage } = useSelector( + createSystemStatusSelector() + ); + const { shortDateFormat, longDateFormat, timeFormat } = useSelector( + createUISettingsSelector() + ); + const isInstallingUpdate = useSelector( + createCommandExecutingSelector(commandNames.APPLICATION_UPDATE) + ); + + const { + isFetching, + isPopulated, + updatesError, + generalSettingsError, + items, + updateMechanism, + } = useSelector(createUpdatesSelector()); + + const dispatch = useDispatch(); + const [isMajorUpdateModalOpen, setIsMajorUpdateModalOpen] = useState(false); + const hasError = !!(updatesError || generalSettingsError); + const hasUpdates = isPopulated && !hasError && items.length > 0; + const noUpdates = isPopulated && !hasError && !items.length; + + const externalUpdaterPrefix = translate('UpdateAppDirectlyLoadError'); + const externalUpdaterMessages: Partial> = { + external: translate('ExternalUpdater'), + apt: translate('AptUpdater'), + docker: translate('DockerUpdater'), + }; + + const { isMajorUpdate, hasUpdateToInstall } = useMemo(() => { + const majorVersion = parseInt( + currentVersion.match(VERSION_REGEX)?.[0] ?? '0' + ); + + const latestVersion = items[0]?.version; + const latestMajorVersion = parseInt( + latestVersion?.match(VERSION_REGEX)?.[0] ?? '0' + ); + + return { + isMajorUpdate: latestMajorVersion > majorVersion, + hasUpdateToInstall: items.some( + (update) => update.installable && update.latest + ), + }; + }, [currentVersion, items]); + + const noUpdateToInstall = hasUpdates && !hasUpdateToInstall; + + const handleInstallLatestPress = useCallback(() => { + if (isMajorUpdate) { + setIsMajorUpdateModalOpen(true); + } else { + dispatch(executeCommand({ name: commandNames.APPLICATION_UPDATE })); + } + }, [isMajorUpdate, setIsMajorUpdateModalOpen, dispatch]); + + const handleInstallLatestMajorVersionPress = useCallback(() => { + setIsMajorUpdateModalOpen(false); + + dispatch( + executeCommand({ + name: commandNames.APPLICATION_UPDATE, + installMajorUpdate: true, + }) + ); + }, [setIsMajorUpdateModalOpen, dispatch]); + + const handleCancelMajorVersionPress = useCallback(() => { + setIsMajorUpdateModalOpen(false); + }, [setIsMajorUpdateModalOpen]); + + useEffect(() => { + dispatch(fetchUpdates()); + dispatch(fetchGeneralSettings()); + }, [dispatch]); + + return ( + + + {isPopulated || hasError ? null : } + + {noUpdates ? ( + {translate('NoUpdatesAreAvailable')} + ) : null} + + {hasUpdateToInstall ? ( +
+ {updateMechanism === 'builtIn' || updateMechanism === 'script' ? ( + + {translate('InstallLatest')} + + ) : ( + <> + + +
+ {externalUpdaterPrefix}{' '} + +
+ + )} + + {isFetching ? ( + + ) : null} +
+ ) : null} + + {noUpdateToInstall && ( +
+ +
{translate('OnLatestVersion')}
+ + {isFetching && ( + + )} +
+ )} + + {hasUpdates && ( +
+ {items.map((update) => { + return ( +
+
+
{update.version}
+
+
+ {formatDate(update.releaseDate, shortDateFormat)} +
+ + {update.branch === 'master' ? null : ( + + )} + + {update.version === currentVersion ? ( + + ) : null} + + {update.version !== currentVersion && update.installedOn ? ( + + ) : null} +
+ + {update.changes ? ( +
+ + + +
+ ) : ( +
{translate('MaintenanceRelease')}
+ )} +
+ ); + })} +
+ )} + + {updatesError ? ( + + {translate('FailedToFetchUpdates')} + + ) : null} + + {generalSettingsError ? ( + + {translate('FailedToFetchSettings')} + + ) : null} + + +
{translate('InstallMajorVersionUpdateMessage')}
+
+ +
+ + } + confirmLabel={translate('Install')} + onConfirm={handleInstallLatestMajorVersionPress} + onCancel={handleCancelMajorVersionPress} + /> +
+
+ ); +} + +export default Updates; diff --git a/frontend/src/System/Updates/UpdatesConnector.js b/frontend/src/System/Updates/UpdatesConnector.js deleted file mode 100644 index 77d75dbda..000000000 --- a/frontend/src/System/Updates/UpdatesConnector.js +++ /dev/null @@ -1,98 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { fetchGeneralSettings } from 'Store/Actions/settingsActions'; -import { fetchUpdates } from 'Store/Actions/systemActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import Updates from './Updates'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app.version, - createSystemStatusSelector(), - (state) => state.system.updates, - (state) => state.settings.general, - createUISettingsSelector(), - createCommandExecutingSelector(commandNames.APPLICATION_UPDATE), - ( - currentVersion, - status, - updates, - generalSettings, - uiSettings, - isInstallingUpdate - ) => { - const { - error: updatesError, - items - } = updates; - - const isFetching = updates.isFetching || generalSettings.isFetching; - const isPopulated = updates.isPopulated && generalSettings.isPopulated; - - return { - currentVersion, - isFetching, - isPopulated, - updatesError, - generalSettingsError: generalSettings.error, - items, - isInstallingUpdate, - updateMechanism: generalSettings.item.updateMechanism, - updateMechanismMessage: status.packageUpdateMechanismMessage, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchUpdates: fetchUpdates, - dispatchFetchGeneralSettings: fetchGeneralSettings, - dispatchExecuteCommand: executeCommand -}; - -class UpdatesConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchUpdates(); - this.props.dispatchFetchGeneralSettings(); - } - - // - // Listeners - - onInstallLatestPress = () => { - this.props.dispatchExecuteCommand({ name: commandNames.APPLICATION_UPDATE }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -UpdatesConnector.propTypes = { - dispatchFetchUpdates: PropTypes.func.isRequired, - dispatchFetchGeneralSettings: PropTypes.func.isRequired, - dispatchExecuteCommand: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(UpdatesConnector); diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js index aa59e866f..0e387f39f 100644 --- a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js +++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js @@ -146,7 +146,7 @@ class TrackFileEditorModalContent extends Component { }); return acc; - }, [{ key: 'selectQuality', value: 'Select Quality', disabled: true }]); + }, [{ key: 'selectQuality', value: translate('SelectQuality'), isDisabled: true }]); const hasSelectedFiles = this.getSelectedIds().length > 0; diff --git a/frontend/src/TrackFile/TrackFile.ts b/frontend/src/TrackFile/TrackFile.ts index ce9379816..ef4dc65f3 100644 --- a/frontend/src/TrackFile/TrackFile.ts +++ b/frontend/src/TrackFile/TrackFile.ts @@ -13,6 +13,7 @@ export interface TrackFile extends ModelBase { releaseGroup: string; quality: QualityModel; customFormats: CustomFormat[]; + indexerFlags: number; mediaInfo: MediaInfo; qualityCutoffNotMet: boolean; } diff --git a/frontend/src/Utilities/Array/sortByName.js b/frontend/src/Utilities/Array/sortByName.js deleted file mode 100644 index 1956d3bac..000000000 --- a/frontend/src/Utilities/Array/sortByName.js +++ /dev/null @@ -1,5 +0,0 @@ -function sortByName(a, b) { - return a.name.localeCompare(b.name); -} - -export default sortByName; diff --git a/frontend/src/Utilities/Array/sortByProp.ts b/frontend/src/Utilities/Array/sortByProp.ts new file mode 100644 index 000000000..8fbde08c9 --- /dev/null +++ b/frontend/src/Utilities/Array/sortByProp.ts @@ -0,0 +1,13 @@ +import { StringKey } from 'typings/Helpers/KeysMatching'; + +export function sortByProp< + // eslint-disable-next-line no-use-before-define + T extends Record, + K extends StringKey +>(sortKey: K) { + return (a: T, b: T) => { + return a[sortKey].localeCompare(b[sortKey], undefined, { numeric: true }); + }; +} + +export default sortByProp; diff --git a/frontend/src/Utilities/Date/getRelativeDate.js b/frontend/src/Utilities/Date/getRelativeDate.ts similarity index 65% rename from frontend/src/Utilities/Date/getRelativeDate.js rename to frontend/src/Utilities/Date/getRelativeDate.ts index 812064272..178d14fb7 100644 --- a/frontend/src/Utilities/Date/getRelativeDate.js +++ b/frontend/src/Utilities/Date/getRelativeDate.ts @@ -6,15 +6,33 @@ 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) { diff --git a/frontend/src/Utilities/String/translate.ts b/frontend/src/Utilities/String/translate.ts index 98a0418ad..5571ef6b0 100644 --- a/frontend/src/Utilities/String/translate.ts +++ b/frontend/src/Utilities/String/translate.ts @@ -17,7 +17,7 @@ export async function fetchTranslations(): Promise { translations = data.strings; resolve(true); - } catch (error) { + } catch { resolve(false); } }); @@ -27,6 +27,12 @@ export default function translate( key: string, tokens: Record = {} ) { + const { isProduction = true } = window.Lidarr; + + if (!isProduction && !(key in translations)) { + console.warn(`Missing translation for key: ${key}`); + } + const translation = translations[key] || key; tokens.appName = 'Lidarr'; diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js index e13d6b539..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 { + + + + { 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 ( + + + + { 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 @@