diff --git a/.devcontainer/Prowlarr.code-workspace b/.devcontainer/Prowlarr.code-workspace new file mode 100644 index 000000000..a46158e44 --- /dev/null +++ b/.devcontainer/Prowlarr.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..70473224d --- /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": "Prowlarr", + "image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "nodeGypDependencies": true, + "version": "16", + "nvmVersion": "latest" + } + }, + "forwardPorts": [9696], + "customizations": { + "vscode": { + "extensions": ["esbenp.prettier-vscode"] + } + } +} 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/labeler.yml b/.github/labeler.yml index 21aacef8c..74160b634 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,19 +1,31 @@ 'Area: API': - - src/Prowlarr.Api.V1/**/* + - changed-files: + - any-glob-to-any-file: + - src/Prowlarr.Api.V1/**/* 'Area: Db-migration': - - src/NzbDrone.Core/Datastore/Migration/* + - changed-files: + - any-glob-to-any-file: + - src/NzbDrone.Core/Datastore/Migration/* 'Area: Download Clients': - - src/NzbDrone.Core/Download/Clients/**/* + - changed-files: + - any-glob-to-any-file: + - src/NzbDrone.Core/Download/Clients/**/* 'Area: Indexer': - - src/NzbDrone.Core/Indexers/**/* + - changed-files: + - any-glob-to-any-file: + - src/NzbDrone.Core/Indexers/**/* 'Area: Notifications': - - src/NzbDrone.Core/Notifications/**/* + - changed-files: + - any-glob-to-any-file: + - src/NzbDrone.Core/Notifications/**/* 'Area: UI': - - frontend/**/* - - package.json - - yarn.lock + - changed-files: + - any-glob-to-any-file: + - frontend/**/* + - package.json + - yarn.lock \ No newline at end of file diff --git a/.github/workflows/label-actions.yml b/.github/workflows/label-actions.yml index 8f35f6bd6..77c35366c 100644 --- a/.github/workflows/label-actions.yml +++ b/.github/workflows/label-actions.yml @@ -18,6 +18,6 @@ jobs: action: runs-on: ubuntu-latest steps: - - uses: dessant/label-actions@v3 + - uses: dessant/label-actions@v4 with: process-only: 'issues, prs' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 857cfb4a7..ab2292824 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -9,4 +9,4 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@v4 + - uses: actions/labeler@v5 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 d903078ef..689b44415 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,7 @@ coverage*.xml coverage*.json setup/Output/ *.~is +.mono # VS outout folders bin 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..d13f9426e --- /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 Prowlarr", + "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/Prowlarr", + "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..b3e22f6d1 --- /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/Prowlarr.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/Prowlarr.sln", + "-property:GenerateFullPaths=true", + "-consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/Prowlarr.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/Logo/dottrace.svg b/Logo/dottrace.svg deleted file mode 100644 index b879517cd..000000000 --- a/Logo/dottrace.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/jetbrains.svg b/Logo/jetbrains.svg deleted file mode 100644 index 75d4d2177..000000000 --- a/Logo/jetbrains.svg +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/resharper.svg b/Logo/resharper.svg deleted file mode 100644 index 24c987a78..000000000 --- a/Logo/resharper.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/rider.svg b/Logo/rider.svg deleted file mode 100644 index 82da35b0b..000000000 --- a/Logo/rider.svg +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - rider - - - - - - - - - - - - - - diff --git a/Logo/webstorm.svg b/Logo/webstorm.svg deleted file mode 100644 index 39ab7eb97..000000000 --- a/Logo/webstorm.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/README.md b/README.md index a2afa58cb..e8c60546a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Prowlarr [![Build Status](https://dev.azure.com/Prowlarr/Prowlarr/_apis/build/status/Prowlarr.Prowlarr?branchName=develop)](https://dev.azure.com/Prowlarr/Prowlarr/_build/latest?definitionId=1&branchName=develop) -[![Translated](https://translate.servarr.com/widgets/servarr/-/prowlarr/svg-badge.svg)](https://translate.servarr.com/engage/prowlarr/?utm_source=widget) -[![Docker Pulls](https://img.shields.io/docker/pulls/hotio/prowlarr.svg)](https://wiki.servarr.com/prowlarr/installation#docker) +[![Translation status](https://translate.servarr.com/widget/servarr/prowlarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/?utm_source=widget) +[![Docker Pulls](https://img.shields.io/docker/pulls/hotio/prowlarr.svg)](https://wiki.servarr.com/prowlarr/installation/docker) ![Github Downloads](https://img.shields.io/github/downloads/Prowlarr/Prowlarr/total.svg) [![Backers on Open Collective](https://opencollective.com/Prowlarr/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/Prowlarr/sponsors/badge.svg)](#sponsors) @@ -68,16 +68,16 @@ Support this project by becoming a sponsor. Your logo will show up here with a l ## JetBrains -Thank you to [JetBrains JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools. +Thank you to [JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools. -- [ReSharper ReSharper](http://www.jetbrains.com/resharper/) -- [WebStorm WebStorm](http://www.jetbrains.com/webstorm/) -- [Rider Rider](http://www.jetbrains.com/rider/) -- [dotTrace dotTrace](http://www.jetbrains.com/dottrace/) +* [ReSharper ReSharper](http://www.jetbrains.com/resharper/) +* [WebStorm WebStorm](http://www.jetbrains.com/webstorm/) +* [Rider Rider](http://www.jetbrains.com/rider/) +* [dotTrace dotTrace](http://www.jetbrains.com/dottrace/) ### License - [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) -- Copyright 2010-2022 +- Copyright 2010-2024 Icon Credit - [Box vector created by freepik - www.freepik.com](https://www.freepik.com/vectors/box) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index fbf4e8f44..dc667e803 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -9,18 +9,18 @@ variables: testsFolder: './_tests' yarnCacheFolder: $(Pipeline.Workspace)/.yarn nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages - majorVersion: '1.8.5' + majorVersion: '1.35.0' minorVersion: $[counter('minorVersion', 1)] prowlarrVersion: '$(majorVersion).$(minorVersion)' buildName: '$(Build.SourceBranchName).$(prowlarrVersion)' sentryOrg: 'servarr' sentryUrl: 'https://sentry.servarr.com' - dotnetVersion: '6.0.408' - nodeVersion: '16.X' - innoVersion: '6.2.0' + dotnetVersion: '6.0.427' + nodeVersion: '20.X' + innoVersion: '6.2.2' 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 @@ -1075,10 +1075,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 @@ -1169,12 +1169,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: 'prowlarr' - scannerMode: 'MSBuild' + scannerMode: 'dotnet' projectKey: 'Prowlarr_Prowlarr' projectName: 'Prowlarr' projectVersion: '$(prowlarrVersion)' @@ -1187,25 +1187,21 @@ 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: - Analyze + - Installer - Unit_Test - Integration - Automation diff --git a/build.sh b/build.sh index d282db86a..5139dba52 100755 --- a/build.sh +++ b/build.sh @@ -254,7 +254,7 @@ InstallInno() ProgressStart "Installing portable Inno Setup" rm -rf _inno - curl -s --output innosetup.exe "https://files.jrsoftware.org/is/6/innosetup-${INNOVERSION:-6.2.0}.exe" + curl -s --output innosetup.exe "https://files.jrsoftware.org/is/6/innosetup-${INNOVERSION:-6.2.2}.exe" mkdir _inno ./innosetup.exe //portable=1 //silent //currentuser //dir=.\\_inno rm innosetup.exe diff --git a/docs.sh b/docs.sh old mode 100644 new mode 100755 index ae11bc83f..38b0e0fbc --- 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,17 +26,23 @@ slnFile=src/Prowlarr.sln platform=Posix + if [ "$PLATFORM" = "Windows" ]; then + application=Prowlarr.Console.dll +else + application=Prowlarr.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 7.3.2 Swashbuckle.AspNetCore.Cli -dotnet tool run swagger tofile --output ./src/Prowlarr.Api.V1/openapi.json "$outputFolder/net6.0/$RUNTIME/prowlarr.console.dll" v1 & +dotnet tool run swagger tofile --output ./src/Prowlarr.Api.V1/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v1 & -sleep 30 +sleep 45 kill %1 diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 8d844cb8b..56eaaeaab 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -357,11 +357,16 @@ module.exports = { ], rules: Object.assign(typescriptEslintRecommended.rules, { - 'no-shadow': 'off', - // These should be enabled after cleaning things up - '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'after-used', + argsIgnorePattern: '^_', + ignoreRestSiblings: true + } + ], '@typescript-eslint/explicit-function-return-type': 'off', - 'react/prop-types': 'off', + 'no-shadow': 'off', 'prettier/prettier': 'error', 'simple-import-sort/imports': [ 'error', @@ -374,7 +379,41 @@ module.exports = { ['^@?\\w', `^(${dirs})(/.*|$)`, '^\\.', '^\\..*css$'] ] } - ] + ], + + // React Hooks + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'error', + + // React + 'react/function-component-definition': 'error', + 'react/hook-use-state': 'error', + 'react/jsx-boolean-value': ['error', 'always'], + 'react/jsx-curly-brace-presence': [ + 'error', + { props: 'never', children: 'never' } + ], + 'react/jsx-fragments': 'error', + 'react/jsx-handler-names': [ + 'error', + { + eventHandlerPrefix: 'on', + eventHandlerPropPrefix: 'on' + } + ], + 'react/jsx-no-bind': ['error', { ignoreRefs: true }], + 'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }], + 'react/jsx-pascal-case': ['error', { allowAllCaps: true }], + 'react/jsx-sort-props': [ + 'error', + { + callbacksLast: true, + noSortAlphabetically: true, + reservedFirst: true + } + ], + 'react/prop-types': 'off', + 'react/self-closing-comp': 'error' }) }, { diff --git a/frontend/babel.config.js b/frontend/babel.config.js index 4d60cc820..ade9f24a2 100644 --- a/frontend/babel.config.js +++ b/frontend/babel.config.js @@ -2,16 +2,18 @@ const loose = true; module.exports = { plugins: [ + '@babel/plugin-transform-logical-assignment-operators', + // Stage 1 '@babel/plugin-proposal-export-default-from', - ['@babel/plugin-proposal-optional-chaining', { loose }], - ['@babel/plugin-proposal-nullish-coalescing-operator', { loose }], + ['@babel/plugin-transform-optional-chaining', { loose }], + ['@babel/plugin-transform-nullish-coalescing-operator', { loose }], // Stage 2 - '@babel/plugin-proposal-export-namespace-from', + '@babel/plugin-transform-export-namespace-from', // Stage 3 - ['@babel/plugin-proposal-class-properties', { loose }], + ['@babel/plugin-transform-class-properties', { loose }], '@babel/plugin-syntax-dynamic-import' ], env: { diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js index 5336d6583..ceacc4f04 100644 --- a/frontend/build/webpack.config.js +++ b/frontend/build/webpack.config.js @@ -25,6 +25,7 @@ module.exports = (env) => { const config = { mode: isProduction ? 'production' : 'development', devtool: isProduction ? 'source-map' : 'eval-source-map', + target: 'web', stats: { children: false @@ -65,7 +66,7 @@ module.exports = (env) => { output: { path: distFolder, publicPath: '/', - filename: '[name]-[contenthash].js', + filename: isProduction ? '[name]-[contenthash].js' : '[name].js', sourceMapFilename: '[file].map' }, @@ -90,7 +91,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({ @@ -169,7 +170,7 @@ module.exports = (env) => { loose: true, debug: false, useBuiltIns: 'entry', - corejs: 3 + corejs: '3.39' } ] ] @@ -190,7 +191,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/App/App.js b/frontend/src/App/App.tsx similarity index 57% rename from frontend/src/App/App.js rename to frontend/src/App/App.tsx index 1eea6e082..dba90a697 100644 --- a/frontend/src/App/App.js +++ b/frontend/src/App/App.tsx @@ -1,31 +1,30 @@ -import { ConnectedRouter } from 'connected-react-router'; -import PropTypes from 'prop-types'; +import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router'; import React from 'react'; import DocumentTitle from 'react-document-title'; import { Provider } from 'react-redux'; +import { Store } from 'redux'; import PageConnector from 'Components/Page/PageConnector'; import ApplyTheme from './ApplyTheme'; import AppRoutes from './AppRoutes'; -function App({ store, history }) { +interface AppProps { + store: Store; + history: ConnectedRouterProps['history']; +} + +function App({ store, history }: AppProps) { return ( - - - - - + + + + ); } -App.propTypes = { - store: PropTypes.object.isRequired, - history: PropTypes.object.isRequired -}; - export default App; diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js deleted file mode 100644 index f7a578da2..000000000 --- a/frontend/src/App/AppRoutes.js +++ /dev/null @@ -1,184 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { Redirect, Route } from 'react-router-dom'; -import NotFound from 'Components/NotFound'; -import Switch from 'Components/Router/Switch'; -import HistoryConnector from 'History/HistoryConnector'; -import IndexerIndex from 'Indexer/Index/IndexerIndex'; -import IndexerStats from 'Indexer/Stats/IndexerStats'; -import SearchIndexConnector from 'Search/SearchIndexConnector'; -import ApplicationSettings from 'Settings/Applications/ApplicationSettings'; -import DevelopmentSettingsConnector from 'Settings/Development/DevelopmentSettingsConnector'; -import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; -import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; -import IndexerSettings from 'Settings/Indexers/IndexerSettings'; -import NotificationSettings from 'Settings/Notifications/NotificationSettings'; -import Settings from 'Settings/Settings'; -import TagSettings from 'Settings/Tags/TagSettings'; -import UISettingsConnector from 'Settings/UI/UISettingsConnector'; -import BackupsConnector from 'System/Backup/BackupsConnector'; -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 getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; - -function AppRoutes(props) { - const { - app - } = props; - - return ( - - {/* - Indexers - */} - - - - { - window.Prowlarr.urlBase && - { - return ( - - ); - }} - /> - } - - - - {/* - Search - */} - - - - {/* - Activity - */} - - - - {/* - Settings - */} - - - - - - - - - - - - - - - - - - - - {/* - System - */} - - - - - - - - - - - - - - {/* - Not Found - */} - - - - ); -} - -AppRoutes.propTypes = { - app: PropTypes.func.isRequired -}; - -export default AppRoutes; diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx new file mode 100644 index 000000000..d451a12fb --- /dev/null +++ b/frontend/src/App/AppRoutes.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { Redirect, Route } from 'react-router-dom'; +import NotFound from 'Components/NotFound'; +import Switch from 'Components/Router/Switch'; +import HistoryConnector from 'History/HistoryConnector'; +import IndexerIndex from 'Indexer/Index/IndexerIndex'; +import IndexerStats from 'Indexer/Stats/IndexerStats'; +import SearchIndexConnector from 'Search/SearchIndexConnector'; +import ApplicationSettings from 'Settings/Applications/ApplicationSettings'; +import DevelopmentSettingsConnector from 'Settings/Development/DevelopmentSettingsConnector'; +import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; +import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; +import IndexerSettings from 'Settings/Indexers/IndexerSettings'; +import NotificationSettings from 'Settings/Notifications/NotificationSettings'; +import Settings from 'Settings/Settings'; +import TagSettings from 'Settings/Tags/TagSettings'; +import UISettingsConnector from 'Settings/UI/UISettingsConnector'; +import BackupsConnector from 'System/Backup/BackupsConnector'; +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 Updates from 'System/Updates/Updates'; +import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; + +function RedirectWithUrlBase() { + return ; +} + +function AppRoutes() { + return ( + + {/* + Indexers + */} + + + + {window.Prowlarr.urlBase && ( + + )} + + + + {/* + Search + */} + + + + {/* + Activity + */} + + + + {/* + Settings + */} + + + + + + + + + + + + + + + + + + + + {/* + System + */} + + + + + + + + + + + + + + {/* + Not Found + */} + + + + ); +} + +export default AppRoutes; diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js deleted file mode 100644 index abc7f8832..000000000 --- a/frontend/src/App/AppUpdatedModal.js +++ /dev/null @@ -1,30 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector'; - -function AppUpdatedModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - ); -} - -AppUpdatedModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default AppUpdatedModal; diff --git a/frontend/src/App/AppUpdatedModal.tsx b/frontend/src/App/AppUpdatedModal.tsx new file mode 100644 index 000000000..696d36fb2 --- /dev/null +++ b/frontend/src/App/AppUpdatedModal.tsx @@ -0,0 +1,28 @@ +import React, { useCallback } from 'react'; +import Modal from 'Components/Modal/Modal'; +import AppUpdatedModalContent from './AppUpdatedModalContent'; + +interface AppUpdatedModalProps { + isOpen: boolean; + onModalClose: (...args: unknown[]) => unknown; +} + +function AppUpdatedModal(props: AppUpdatedModalProps) { + const { isOpen, onModalClose } = props; + + const handleModalClose = useCallback(() => { + location.reload(); + }, []); + + return ( + + + + ); +} + +export default AppUpdatedModal; diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js deleted file mode 100644 index a21afbc5a..000000000 --- a/frontend/src/App/AppUpdatedModalConnector.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux'; -import AppUpdatedModal from './AppUpdatedModal'; - -function createMapDispatchToProps(dispatch, props) { - return { - onModalClose() { - location.reload(); - } - }; -} - -export default connect(null, createMapDispatchToProps)(AppUpdatedModal); diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js deleted file mode 100644 index 71dc7702c..000000000 --- a/frontend/src/App/AppUpdatedModalContent.js +++ /dev/null @@ -1,139 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { kinds } from 'Helpers/Props'; -import UpdateChanges from 'System/Updates/UpdateChanges'; -import translate from 'Utilities/String/translate'; -import styles from './AppUpdatedModalContent.css'; - -function mergeUpdates(items, version, prevVersion) { - let installedIndex = items.findIndex((u) => u.version === version); - let installedPreviouslyIndex = items.findIndex((u) => u.version === prevVersion); - - if (installedIndex === -1) { - installedIndex = 0; - } - - if (installedPreviouslyIndex === -1) { - installedPreviouslyIndex = items.length; - } else if (installedPreviouslyIndex === installedIndex && items.length) { - installedPreviouslyIndex += 1; - } - - const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex); - - if (!appliedUpdates.length) { - return null; - } - - const appliedChanges = { new: [], fixed: [] }; - appliedUpdates.forEach((u) => { - if (u.changes) { - appliedChanges.new.push(... u.changes.new); - appliedChanges.fixed.push(... u.changes.fixed); - } - }); - - const mergedUpdate = Object.assign({}, appliedUpdates[0], { changes: appliedChanges }); - - if (!appliedChanges.new.length && !appliedChanges.fixed.length) { - mergedUpdate.changes = null; - } - - return mergedUpdate; -} - -function AppUpdatedModalContent(props) { - const { - version, - prevVersion, - isPopulated, - error, - items, - onSeeChangesPress, - onModalClose - } = props; - - const update = mergeUpdates(items, version, prevVersion); - - return ( - - - {translate('AppUpdated', { appName: 'Prowlarr' })} - - - -
- -
- - { - isPopulated && !error && !!update && -
- { - !update.changes && -
{translate('MaintenanceRelease')}
- } - - { - !!update.changes && -
-
- {translate('WhatsNew')} -
- - - - -
- } -
- } - - { - !isPopulated && !error && - - } -
- - - - - - -
- ); -} - -AppUpdatedModalContent.propTypes = { - version: PropTypes.string.isRequired, - prevVersion: PropTypes.string, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - onSeeChangesPress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default AppUpdatedModalContent; diff --git a/frontend/src/App/AppUpdatedModalContent.tsx b/frontend/src/App/AppUpdatedModalContent.tsx new file mode 100644 index 000000000..0bd5df6d3 --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.tsx @@ -0,0 +1,145 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { kinds } from 'Helpers/Props'; +import { fetchUpdates } from 'Store/Actions/systemActions'; +import UpdateChanges from 'System/Updates/UpdateChanges'; +import Update from 'typings/Update'; +import translate from 'Utilities/String/translate'; +import AppState from './State/AppState'; +import styles from './AppUpdatedModalContent.css'; + +function mergeUpdates(items: Update[], version: string, prevVersion?: string) { + let installedIndex = items.findIndex((u) => u.version === version); + let installedPreviouslyIndex = items.findIndex( + (u) => u.version === prevVersion + ); + + if (installedIndex === -1) { + installedIndex = 0; + } + + if (installedPreviouslyIndex === -1) { + installedPreviouslyIndex = items.length; + } else if (installedPreviouslyIndex === installedIndex && items.length) { + installedPreviouslyIndex += 1; + } + + const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex); + + if (!appliedUpdates.length) { + return null; + } + + const appliedChanges: Update['changes'] = { new: [], fixed: [] }; + + appliedUpdates.forEach((u: Update) => { + if (u.changes) { + appliedChanges.new.push(...u.changes.new); + appliedChanges.fixed.push(...u.changes.fixed); + } + }); + + const mergedUpdate: Update = Object.assign({}, appliedUpdates[0], { + changes: appliedChanges, + }); + + if (!appliedChanges.new.length && !appliedChanges.fixed.length) { + mergedUpdate.changes = null; + } + + return mergedUpdate; +} + +interface AppUpdatedModalContentProps { + onModalClose: () => void; +} + +function AppUpdatedModalContent(props: AppUpdatedModalContentProps) { + const dispatch = useDispatch(); + const { version, prevVersion } = useSelector((state: AppState) => state.app); + const { isPopulated, error, items } = useSelector( + (state: AppState) => state.system.updates + ); + const previousVersion = usePrevious(version); + + const { onModalClose } = props; + + const update = mergeUpdates(items, version, prevVersion); + + const handleSeeChangesPress = useCallback(() => { + window.location.href = `${window.Prowlarr.urlBase}/system/updates`; + }, []); + + useEffect(() => { + dispatch(fetchUpdates()); + }, [dispatch]); + + useEffect(() => { + if (version !== previousVersion) { + dispatch(fetchUpdates()); + } + }, [version, previousVersion, dispatch]); + + return ( + + {translate('AppUpdated')} + + +
+ +
+ + {isPopulated && !error && !!update ? ( +
+ {update.changes ? ( +
+ {translate('MaintenanceRelease')} +
+ ) : null} + + {update.changes ? ( +
+
{translate('WhatsNew')}
+ + + + +
+ ) : null} +
+ ) : null} + + {!isPopulated && !error ? : null} +
+ + + + + + +
+ ); +} + +export default AppUpdatedModalContent; diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js deleted file mode 100644 index 97dd0aeb9..000000000 --- a/frontend/src/App/AppUpdatedModalContentConnector.js +++ /dev/null @@ -1,78 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchUpdates } from 'Store/Actions/systemActions'; -import AppUpdatedModalContent from './AppUpdatedModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app.version, - (state) => state.app.prevVersion, - (state) => state.system.updates, - (version, prevVersion, updates) => { - const { - isPopulated, - error, - items - } = updates; - - return { - version, - prevVersion, - isPopulated, - error, - items - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - dispatchFetchUpdates() { - dispatch(fetchUpdates()); - }, - - onSeeChangesPress() { - window.location = `${window.Prowlarr.urlBase}/system/updates`; - } - }; -} - -class AppUpdatedModalContentConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.dispatchFetchUpdates(); - } - - componentDidUpdate(prevProps) { - if (prevProps.version !== this.props.version) { - this.props.dispatchFetchUpdates(); - } - } - - // - // Render - - render() { - const { - dispatchFetchUpdates, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -AppUpdatedModalContentConnector.propTypes = { - version: PropTypes.string.isRequired, - dispatchFetchUpdates: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector); diff --git a/frontend/src/App/ApplyTheme.js b/frontend/src/App/ApplyTheme.js deleted file mode 100644 index bd4d6a6c8..000000000 --- a/frontend/src/App/ApplyTheme.js +++ /dev/null @@ -1,50 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Fragment, useCallback, useEffect } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import themes from 'Styles/Themes'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.ui.item.theme || window.Prowlarr.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..ec9cd037f --- /dev/null +++ b/frontend/src/App/ApplyTheme.tsx @@ -0,0 +1,33 @@ +import { useCallback, useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import themes from 'Styles/Themes'; +import AppState from './State/AppState'; + +function createThemeSelector() { + return createSelector( + (state: AppState) => state.settings.ui.item.theme || window.Prowlarr.theme, + (theme) => { + return theme; + } + ); +} + +function ApplyTheme() { + 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 null; +} + +export default ApplyTheme; diff --git a/frontend/src/App/ColorImpairedContext.js b/frontend/src/App/ColorImpairedContext.ts similarity index 100% rename from frontend/src/App/ColorImpairedContext.js rename to frontend/src/App/ColorImpairedContext.ts diff --git a/frontend/src/App/ConnectionLostModal.js b/frontend/src/App/ConnectionLostModal.js deleted file mode 100644 index 71cfa907e..000000000 --- a/frontend/src/App/ConnectionLostModal.js +++ /dev/null @@ -1,56 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Button from 'Components/Link/Button'; -import Modal from 'Components/Modal/Modal'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import styles from './ConnectionLostModal.css'; - -function ConnectionLostModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - {translate('ConnectionLost')} - - - -
- {translate('ConnectionLostToBackend', { appName: 'Prowlarr' })} -
- -
- {translate('ConnectionLostReconnect', { appName: 'Prowlarr' })} -
-
- - - -
-
- ); -} - -ConnectionLostModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ConnectionLostModal; diff --git a/frontend/src/App/ConnectionLostModal.tsx b/frontend/src/App/ConnectionLostModal.tsx new file mode 100644 index 000000000..f08f2c0e2 --- /dev/null +++ b/frontend/src/App/ConnectionLostModal.tsx @@ -0,0 +1,45 @@ +import React, { useCallback } from 'react'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ConnectionLostModal.css'; + +interface ConnectionLostModalProps { + isOpen: boolean; +} + +function ConnectionLostModal(props: ConnectionLostModalProps) { + const { isOpen } = props; + + const handleModalClose = useCallback(() => { + location.reload(); + }, []); + + return ( + + + {translate('ConnectionLost')} + + +
{translate('ConnectionLostToBackend')}
+ +
+ {translate('ConnectionLostReconnect')} +
+
+ + + +
+
+ ); +} + +export default ConnectionLostModal; diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js deleted file mode 100644 index 8ab8e3cd0..000000000 --- a/frontend/src/App/ConnectionLostModalConnector.js +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from 'react-redux'; -import ConnectionLostModal from './ConnectionLostModal'; - -function createMapDispatchToProps(dispatch, props) { - return { - onModalClose() { - location.reload(); - } - }; -} - -export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal); diff --git a/frontend/src/App/State/AppSectionState.ts b/frontend/src/App/State/AppSectionState.ts index d511963fc..f89eb25f7 100644 --- a/frontend/src/App/State/AppSectionState.ts +++ b/frontend/src/App/State/AppSectionState.ts @@ -1,4 +1,6 @@ +import Column from 'Components/Table/Column'; import SortDirection from 'Helpers/Props/SortDirection'; +import { FilterBuilderProp, PropertyFilter } from './AppState'; export interface Error { responseJSON: { @@ -17,7 +19,19 @@ export interface AppSectionSaveState { } export interface PagedAppSectionState { + page: number; pageSize: number; + totalPages: number; + totalRecords?: number; +} +export interface TableAppSectionState { + columns: Column[]; +} + +export interface AppSectionFilterState { + selectedFilterKey: string; + filters: PropertyFilter[]; + filterBuilderProps: FilterBuilderProp[]; } export interface AppSectionSchemaState { @@ -33,6 +47,7 @@ export interface AppSectionItemState { isFetching: boolean; isPopulated: boolean; error: Error; + pendingChanges: Partial; item: T; } diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 3d8f78443..0f0e82c0d 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,5 +1,7 @@ import CommandAppState from './CommandAppState'; +import HistoryAppState from './HistoryAppState'; import IndexerAppState, { + IndexerHistoryAppState, IndexerIndexAppState, IndexerStatusAppState, } from './IndexerAppState'; @@ -40,8 +42,23 @@ export interface CustomFilter { filers: PropertyFilter[]; } +export interface AppSectionState { + isConnected: boolean; + isReconnecting: boolean; + version: string; + prevVersion?: string; + dimensions: { + isSmallScreen: boolean; + width: number; + height: number; + }; +} + interface AppState { + app: AppSectionState; commands: CommandAppState; + history: HistoryAppState; + indexerHistory: IndexerHistoryAppState; indexerIndex: IndexerIndexAppState; indexerStats: IndexerStatsAppState; indexerStatus: IndexerStatusAppState; diff --git a/frontend/src/App/State/HistoryAppState.ts b/frontend/src/App/State/HistoryAppState.ts new file mode 100644 index 000000000..3bb0e85f5 --- /dev/null +++ b/frontend/src/App/State/HistoryAppState.ts @@ -0,0 +1,14 @@ +import AppSectionState, { + AppSectionFilterState, +} from 'App/State/AppSectionState'; +import Column from 'Components/Table/Column'; +import History from 'typings/History'; + +interface HistoryAppState + extends AppSectionState, + AppSectionFilterState { + pageSize: number; + columns: Column[]; +} + +export default HistoryAppState; diff --git a/frontend/src/App/State/IndexerAppState.ts b/frontend/src/App/State/IndexerAppState.ts index c75c8abb0..4c0145d0d 100644 --- a/frontend/src/App/State/IndexerAppState.ts +++ b/frontend/src/App/State/IndexerAppState.ts @@ -1,6 +1,7 @@ import Column from 'Components/Table/Column'; import SortDirection from 'Helpers/Props/SortDirection'; import Indexer, { IndexerStatus } from 'Indexer/Indexer'; +import History from 'typings/History'; import AppSectionState, { AppSectionDeleteState, AppSectionSaveState, @@ -30,8 +31,12 @@ interface IndexerAppState AppSectionDeleteState, AppSectionSaveState { itemMap: Record; + + isTestingAll: boolean; } export type IndexerStatusAppState = AppSectionState; +export type IndexerHistoryAppState = AppSectionState; + export default IndexerAppState; diff --git a/frontend/src/App/State/IndexerStatsAppState.ts b/frontend/src/App/State/IndexerStatsAppState.ts index a696860b3..8d3ae660a 100644 --- a/frontend/src/App/State/IndexerStatsAppState.ts +++ b/frontend/src/App/State/IndexerStatsAppState.ts @@ -1,9 +1,11 @@ import { AppSectionItemState } from 'App/State/AppSectionState'; -import { Filter } from 'App/State/AppState'; +import { Filter, FilterBuilderProp } from 'App/State/AppState'; +import Indexer from 'Indexer/Indexer'; import { IndexerStats } from 'typings/IndexerStats'; export interface IndexerStatsAppState extends AppSectionItemState { + filterBuilderProps: FilterBuilderProp[]; selectedFilterKey: string; filters: Filter[]; } diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 9dc6dfa2c..33c6c936d 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -3,10 +3,12 @@ import AppSectionState, { AppSectionItemState, AppSectionSaveState, } from 'App/State/AppSectionState'; +import { IndexerCategory } from 'Indexer/Indexer'; import Application from 'typings/Application'; import DownloadClient from 'typings/DownloadClient'; import Notification from 'typings/Notification'; -import { UiSettings } from 'typings/UiSettings'; +import General from 'typings/Settings/General'; +import UiSettings from 'typings/Settings/UiSettings'; export interface AppProfileAppState extends AppSectionState, @@ -22,6 +24,17 @@ export interface ApplicationAppState export interface DownloadClientAppState extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState { + isTestingAll: boolean; +} + +export interface GeneralAppState + extends AppSectionItemState, + AppSectionSaveState {} + +export interface IndexerCategoryAppState + extends AppSectionState, AppSectionDeleteState, AppSectionSaveState {} @@ -35,6 +48,8 @@ interface SettingsAppState { appProfiles: AppProfileAppState; applications: ApplicationAppState; downloadClients: DownloadClientAppState; + general: GeneralAppState; + indexerCategories: IndexerCategoryAppState; notifications: NotificationAppState; ui: UiSettingsAppState; } diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts index d43c1d0ee..8bc1b03e2 100644 --- a/frontend/src/App/State/SystemAppState.ts +++ b/frontend/src/App/State/SystemAppState.ts @@ -1,10 +1,19 @@ +import Health from 'typings/Health'; import SystemStatus from 'typings/SystemStatus'; -import { AppSectionItemState } from './AppSectionState'; +import Task from 'typings/Task'; +import Update from 'typings/Update'; +import AppSectionState, { AppSectionItemState } from './AppSectionState'; +export type HealthAppState = AppSectionState; export type SystemStatusAppState = AppSectionItemState; +export type TaskAppState = AppSectionState; +export type UpdateAppState = AppSectionState; interface SystemAppState { + health: HealthAppState; status: SystemStatusAppState; + tasks: TaskAppState; + updates: UpdateAppState; } export default SystemAppState; diff --git a/frontend/src/Commands/Command.ts b/frontend/src/Commands/Command.ts index 45a5beed7..b9b31bf63 100644 --- a/frontend/src/Commands/Command.ts +++ b/frontend/src/Commands/Command.ts @@ -12,7 +12,6 @@ export interface CommandBody { lastStartTime: string; trigger: string; suppressMessages: boolean; - seriesId?: number; } interface Command extends ModelBase { diff --git a/frontend/src/Components/Chart/BarChart.js b/frontend/src/Components/Chart/BarChart.js index b9d7f0acc..83176c989 100644 --- a/frontend/src/Components/Chart/BarChart.js +++ b/frontend/src/Components/Chart/BarChart.js @@ -2,6 +2,7 @@ import Chart from 'chart.js/auto'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { kinds } from 'Helpers/Props'; +import { defaultFontFamily } from 'Styles/Variables/fonts'; function getColors(kind) { @@ -39,7 +40,15 @@ class BarChart extends Component { plugins: { title: { display: true, - text: this.props.title + align: 'start', + text: this.props.title, + padding: { + bottom: 30 + }, + font: { + size: 14, + family: defaultFontFamily + } }, legend: { display: this.props.legend diff --git a/frontend/src/Components/Chart/DoughnutChart.js b/frontend/src/Components/Chart/DoughnutChart.js index dd5052e23..d10979aa1 100644 --- a/frontend/src/Components/Chart/DoughnutChart.js +++ b/frontend/src/Components/Chart/DoughnutChart.js @@ -1,6 +1,7 @@ import Chart from 'chart.js/auto'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import { defaultFontFamily } from 'Styles/Variables/fonts'; function getColors(kind) { @@ -22,7 +23,15 @@ class DoughnutChart extends Component { plugins: { title: { display: true, - text: this.props.title + align: 'start', + text: this.props.title, + padding: { + bottom: 30 + }, + font: { + size: 14, + family: defaultFontFamily + } }, legend: { position: 'bottom' diff --git a/frontend/src/Components/Chart/StackedBarChart.js b/frontend/src/Components/Chart/StackedBarChart.js index d6e4879d2..b69fd8e03 100644 --- a/frontend/src/Components/Chart/StackedBarChart.js +++ b/frontend/src/Components/Chart/StackedBarChart.js @@ -1,6 +1,7 @@ import Chart from 'chart.js/auto'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import { defaultFontFamily } from 'Styles/Variables/fonts'; function getColors(index) { @@ -36,7 +37,19 @@ class StackedBarChart extends Component { plugins: { title: { display: true, - text: this.props.title + align: 'start', + text: this.props.title, + padding: { + bottom: 30 + }, + font: { + size: 14, + family: defaultFontFamily + } + }, + tooltip: { + mode: 'index', + position: 'average' } } }, diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js index aac01a6b5..931557045 100644 --- a/frontend/src/Components/DescriptionList/DescriptionListItem.js +++ b/frontend/src/Components/DescriptionList/DescriptionListItem.js @@ -10,6 +10,7 @@ class DescriptionListItem extends Component { render() { const { + className, titleClassName, descriptionClassName, title, @@ -17,7 +18,7 @@ class DescriptionListItem extends Component { } = this.props; return ( -
+
@@ -35,6 +36,7 @@ class DescriptionListItem extends Component { } DescriptionListItem.propTypes = { + className: PropTypes.string, titleClassName: PropTypes.string, descriptionClassName: PropTypes.string, title: PropTypes.string, diff --git a/frontend/src/Components/Error/ErrorBoundaryError.tsx b/frontend/src/Components/Error/ErrorBoundaryError.tsx index 022cf5a45..51d286311 100644 --- a/frontend/src/Components/Error/ErrorBoundaryError.tsx +++ b/frontend/src/Components/Error/ErrorBoundaryError.tsx @@ -63,11 +63,7 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
{info.componentStack}
)} - { -
- Version: {window.Prowlarr.version} -
- } +
Version: {window.Prowlarr.version}
); diff --git a/frontend/src/Components/Filter/Builder/CategoryFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/CategoryFilterBuilderRowValue.tsx new file mode 100644 index 000000000..6a7dddcfc --- /dev/null +++ b/frontend/src/Components/Filter/Builder/CategoryFilterBuilderRowValue.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import { IndexerCategory } from 'Indexer/Indexer'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; +import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; + +const indexerCategoriesSelector = createSelector( + (state: AppState) => state.settings.indexerCategories, + (categories) => categories.items +); + +function CategoryFilterBuilderRowValue(props: FilterBuilderRowValueProps) { + const categories: IndexerCategory[] = useSelector(indexerCategoriesSelector); + + const tagList = categories.reduce( + (acc: { id: number; name: string }[], element) => { + acc.push({ + id: element.id, + name: `${element.name} (${element.id})`, + }); + + if (element.subCategories && element.subCategories.length > 0) { + element.subCategories.forEach((subCat) => { + acc.push({ + id: subCat.id, + name: `${subCat.name} (${subCat.id})`, + }); + }); + } + + return acc; + }, + [] + ); + + return ; +} + +export default CategoryFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js index 033b9a69a..0c4a31657 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js @@ -1,3 +1,4 @@ +import { maxBy } from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import FormInputGroup from 'Components/Form/FormInputGroup'; @@ -50,7 +51,7 @@ class FilterBuilderModalContent extends Component { if (id) { dispatchSetFilter({ selectedFilterKey: id }); } else { - const last = customFilters[customFilters.length -1]; + const last = maxBy(customFilters, 'id'); dispatchSetFilter({ selectedFilterKey: last.id }); } @@ -108,7 +109,7 @@ class FilterBuilderModalContent extends Component { this.setState({ labelErrors: [ { - message: 'Label is required' + message: translate('LabelIsRequired') } ] }); @@ -146,13 +147,13 @@ class FilterBuilderModalContent extends Component { return ( - Custom Filter + {translate('CustomFilter')}
- Label + {translate('Label')}
diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js index ec676f87c..b02844c61 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -3,10 +3,13 @@ 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 AppProfileFilterBuilderRowValueConnector from './AppProfileFilterBuilderRowValueConnector'; import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; +import CategoryFilterBuilderRowValue from './CategoryFilterBuilderRowValue'; import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; +import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue'; import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; import PrivacyFilterBuilderRowValue from './PrivacyFilterBuilderRowValue'; import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; @@ -55,9 +58,15 @@ function getRowValueConnector(selectedFilterBuilderProp) { case filterBuilderValueTypes.BOOL: return BoolFilterBuilderRowValue; + case filterBuilderValueTypes.CATEGORY: + return CategoryFilterBuilderRowValue; + case filterBuilderValueTypes.DATE: return DateFilterBuilderRowValue; + case filterBuilderValueTypes.HISTORY_EVENT_TYPE: + return HistoryEventTypeFilterBuilderRowValue; + case filterBuilderValueTypes.INDEXER: return IndexerFilterBuilderRowValueConnector; @@ -204,7 +213,7 @@ class FilterBuilderRow extends Component { key: name, value: typeof label === 'function' ? label() : label }; - }).sort((a, b) => a.value.localeCompare(b.value)); + }).sort(sortByProp('value')); const ValueComponent = getRowValueConnector(selectedFilterBuilderProp); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js index a7aed80b6..d1419327a 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { filterBuilderTypes } from 'Helpers/Props'; import * as filterTypes from 'Helpers/Props/filterTypes'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import FilterBuilderRowValue from './FilterBuilderRowValue'; function createTagListSelector() { @@ -38,7 +38,7 @@ function createTagListSelector() { } return acc; - }, []).sort(sortByName); + }, []).sort(sortByProp('name')); } return _.uniqBy(items, 'id'); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueProps.ts b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueProps.ts new file mode 100644 index 000000000..5bf9e5785 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueProps.ts @@ -0,0 +1,16 @@ +import { FilterBuilderProp } from 'App/State/AppState'; + +interface FilterBuilderRowOnChangeProps { + name: string; + value: unknown[]; +} + +interface FilterBuilderRowValueProps { + filterType?: string; + filterValue: string | number | object | string[] | number[] | object[]; + selectedFilterBuilderProp: FilterBuilderProp; + sectionItem: unknown[]; + onChange: (payload: FilterBuilderRowOnChangeProps) => void; +} + +export default FilterBuilderRowValueProps; diff --git a/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx new file mode 100644 index 000000000..03c5f7227 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import translate from 'Utilities/String/translate'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; +import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; + +const EVENT_TYPE_OPTIONS = [ + { + id: 1, + get name() { + return translate('Grabbed'); + }, + }, + { + id: 3, + get name() { + return translate('IndexerRss'); + }, + }, + { + id: 2, + get name() { + return translate('IndexerQuery'); + }, + }, + { + id: 4, + get name() { + return translate('IndexerAuth'); + }, + }, +]; + +function HistoryEventTypeFilterBuilderRowValue( + props: FilterBuilderRowValueProps +) { + return ; +} + +export default HistoryEventTypeFilterBuilderRowValue; 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 07660426e..99cb6ec5c 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js @@ -5,6 +5,7 @@ import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; +import sortByProp from 'Utilities/Array/sortByProp'; import translate from 'Utilities/String/translate'; import CustomFilter from './CustomFilter'; import styles from './CustomFiltersModalContent.css'; @@ -30,22 +31,24 @@ function CustomFiltersModalContent(props) { { - customFilters.map((customFilter) => { - return ( - - ); - }) + customFilters + .sort((a, b) => sortByProp(a, b, 'label')) + .map((customFilter) => { + return ( + + ); + }) }
diff --git a/frontend/src/Components/Form/AppProfileSelectInputConnector.js b/frontend/src/Components/Form/AppProfileSelectInputConnector.js index 1aef10c30..0ab181e2f 100644 --- a/frontend/src/Components/Form/AppProfileSelectInputConnector.js +++ b/frontend/src/Components/Form/AppProfileSelectInputConnector.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 SelectInput from './SelectInput'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.appProfiles', sortByName), + createSortedSectionSelector('settings.appProfiles', sortByProp('name')), (state, { includeNoChange }) => includeNoChange, (state, { includeMixed }) => includeMixed, (appProfiles, includeNoChange, includeMixed) => { @@ -24,16 +24,20 @@ function createMapStateToProps() { if (includeNoChange) { values.unshift({ key: 'noChange', - value: translate('NoChange'), - disabled: true + get value() { + return translate('NoChange'); + }, + isDisabled: true }); } if (includeMixed) { values.unshift({ key: 'mixed', - value: '(Mixed)', - disabled: true + get value() { + return `(${translate('Mixed')})`; + }, + isDisabled: true }); } diff --git a/frontend/src/Components/Form/AvailabilitySelectInput.js b/frontend/src/Components/Form/AvailabilitySelectInput.js deleted file mode 100644 index af9bdb2d6..000000000 --- a/frontend/src/Components/Form/AvailabilitySelectInput.js +++ /dev/null @@ -1,54 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import SelectInput from './SelectInput'; - -const availabilityOptions = [ - { key: 'announced', value: 'Announced' }, - { key: 'inCinemas', value: 'In Cinemas' }, - { key: 'released', value: 'Released' }, - { key: 'preDB', value: 'PreDB' } -]; - -function AvailabilitySelectInput(props) { - const values = [...availabilityOptions]; - - const { - includeNoChange, - includeMixed - } = props; - - if (includeNoChange) { - values.unshift({ - key: 'noChange', - value: 'No Change', - disabled: true - }); - } - - if (includeMixed) { - values.unshift({ - key: 'mixed', - value: '(Mixed)', - disabled: true - }); - } - - return ( - - ); -} - -AvailabilitySelectInput.propTypes = { - includeNoChange: PropTypes.bool.isRequired, - includeMixed: PropTypes.bool.isRequired -}; - -AvailabilitySelectInput.defaultProps = { - includeNoChange: false, - includeMixed: false -}; - -export default AvailabilitySelectInput; diff --git a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js index 162c79885..9cf7a429a 100644 --- a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js +++ b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js @@ -3,7 +3,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() { @@ -21,16 +22,17 @@ function createMapStateToProps() { const values = items .filter((downloadClient) => downloadClient.protocol === protocolFilter) - .sort(sortByName) + .sort(sortByProp('name')) .map((downloadClient) => ({ key: downloadClient.id, - value: downloadClient.name + value: downloadClient.name, + hint: `(${downloadClient.id})` })); if (includeAny) { values.unshift({ key: 0, - value: '(Any)' + value: `(${translate('Any')})` }); } diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js index cc4215025..79b1c999c 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; }; @@ -271,26 +264,29 @@ class EnhancedSelectInput extends Component { this.setState({ isOpen: !this.state.isOpen }); }; - onSelect = (value) => { - if (Array.isArray(this.props.value)) { - let newValue = null; - const index = this.props.value.indexOf(value); + onSelect = (newValue) => { + const { name, value, values, onChange } = this.props; + + if (Array.isArray(value)) { + let arrayValue = null; + const index = value.indexOf(newValue); + if (index === -1) { - newValue = this.props.values.map((v) => v.key).filter((v) => (v === value) || this.props.value.includes(v)); + arrayValue = values.map((v) => v.key).filter((v) => (v === newValue) || value.includes(v)); } else { - newValue = [...this.props.value]; - newValue.splice(index, 1); + arrayValue = [...value]; + arrayValue.splice(index, 1); } - this.props.onChange({ - name: this.props.name, - value: newValue + onChange({ + name, + value: arrayValue }); } else { this.setState({ isOpen: false }); - this.props.onChange({ - name: this.props.name, - value + onChange({ + name, + value: newValue }); } }; @@ -457,6 +453,10 @@ class EnhancedSelectInput extends Component { order: 851, enabled: true, fn: this.onComputeMaxHeight + }, + preventOverflow: { + enabled: true, + boundariesElement: 'viewport' } }} > @@ -485,7 +485,7 @@ class EnhancedSelectInput extends Component { values.map((v, index) => { const hasParent = v.parentKey !== undefined; const depth = hasParent ? 1 : 0; - const parentSelected = hasParent && value.includes(v.parentKey); + const parentSelected = hasParent && Array.isArray(value) && value.includes(v.parentKey); return ( {error.errorMessage} + + { + error.detailedDescription ? + } + tooltip={error.detailedDescription} + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> : + null + } ); }) @@ -39,6 +53,18 @@ function Form(props) { kind={kinds.WARNING} > {warning.errorMessage} + + { + warning.detailedDescription ? + } + tooltip={warning.detailedDescription} + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> : + null + } ); }) diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js deleted file mode 100644 index a7145363a..000000000 --- a/frontend/src/Components/Form/FormInputButton.js +++ /dev/null @@ -1,54 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import Button from 'Components/Link/Button'; -import SpinnerButton from 'Components/Link/SpinnerButton'; -import { kinds } from 'Helpers/Props'; -import styles from './FormInputButton.css'; - -function FormInputButton(props) { - const { - className, - canSpin, - isLastButton, - ...otherProps - } = props; - - if (canSpin) { - return ( - - ); - } - - return ( -
- - diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index 069a4cdf7..6eef54eab 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -8,7 +8,7 @@ import Scroller from 'Components/Scroller/Scroller'; import { icons } from 'Helpers/Props'; import locationShape from 'Helpers/Props/Shapes/locationShape'; import dimensions from 'Styles/Variables/dimensions'; -import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector'; +import HealthStatus from 'System/Status/Health/HealthStatus'; import translate from 'Utilities/String/translate'; import MessagesConnector from './Messages/MessagesConnector'; import PageSidebarItem from './PageSidebarItem'; @@ -87,7 +87,7 @@ const links = [ { title: () => translate('Status'), to: '/system/status', - statusComponent: HealthStatusConnector + statusComponent: HealthStatus }, { title: () => translate('Tasks'), diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.css b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css index 5e3e3b52c..409062f97 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebarItem.css +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css @@ -24,6 +24,7 @@ composes: link; padding: 10px 24px; + padding-left: 35px; } .isActiveLink { @@ -41,10 +42,6 @@ text-align: center; } -.noIcon { - margin-left: 25px; -} - .status { float: right; } diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.css.d.ts b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css.d.ts index 77e23c767..5bf0eb815 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebarItem.css.d.ts +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.css.d.ts @@ -8,7 +8,6 @@ interface CssExports { 'isActiveParentLink': string; 'item': string; 'link': string; - 'noIcon': string; 'status': string; } export const cssExports: CssExports; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js index 754071c79..8d0e4e790 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebarItem.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebarItem.js @@ -63,9 +63,7 @@ class PageSidebarItem extends Component { } - - {typeof title === 'function' ? title() : title} - + {typeof title === 'function' ? title() : title} { !!StatusComponent && diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.css b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css index 0b6918296..e9a1b666d 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarButton.css +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.css @@ -22,11 +22,14 @@ display: flex; align-items: center; justify-content: center; + overflow: hidden; height: 24px; } .label { padding: 0 3px; + max-width: 100%; + max-height: 100%; color: var(--toolbarLabelColor); font-size: $extraSmallFontSize; line-height: calc($extraSmallFontSize + 1px); diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js index c93603aa9..675bdfd02 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js @@ -23,6 +23,7 @@ function PageToolbarButton(props) { isDisabled && styles.isDisabled )} isDisabled={isDisabled || isSpinning} + title={label} {...otherProps} > { + const section = 'settings.applications'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + handleCommand = (body) => { if (body.action === 'sync') { this.props.dispatchFetchCommands(); @@ -150,8 +160,8 @@ class SignalRConnector extends Component { const resource = body.resource; const status = resource.status; - // Both sucessful and failed commands need to be - // completed, otherwise they spin until they timeout. + // Both successful and failed commands need to be + // completed, otherwise they spin until they time out. if (status === 'completed' || status === 'failed') { this.props.dispatchFinishCommand(resource); @@ -160,6 +170,16 @@ class SignalRConnector extends Component { } }; + 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(); }; @@ -168,14 +188,33 @@ class SignalRConnector extends Component { this.props.dispatchFetchIndexerStatus(); }; - handleIndexer = (body) => { - const action = body.action; + handleIndexer = ({ action, resource }) => { const section = 'indexers'; - if (action === 'updated') { - this.props.dispatchUpdateItem({ section, ...body.resource }); + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); } else if (action === 'deleted') { - this.props.dispatchRemoveItem({ section, id: body.resource.id }); + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + + handleIndexerproxy = ({ action, resource }) => { + const section = 'settings.indexerProxies'; + + if (action === 'created' || action === 'updated') { + this.props.dispatchUpdateItem({ section, ...resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: resource.id }); + } + }; + + 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 }); } }; diff --git a/frontend/src/Components/Table/Cells/RelativeDateCell.js b/frontend/src/Components/Table/Cells/RelativeDateCell.js index 207b97752..4bf94cf57 100644 --- a/frontend/src/Components/Table/Cells/RelativeDateCell.js +++ b/frontend/src/Components/Table/Cells/RelativeDateCell.js @@ -1,58 +1,66 @@ import PropTypes from 'prop-types'; -import React, { PureComponent } from 'react'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import formatDateTime from 'Utilities/Date/formatDateTime'; import getRelativeDate from 'Utilities/Date/getRelativeDate'; import TableRowCell from './TableRowCell'; import styles from './RelativeDateCell.css'; -class RelativeDateCell extends PureComponent { +function createRelativeDateCellSelector() { + return createSelector(createUISettingsSelector(), (uiSettings) => { + return { + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat + }; + }); +} +function RelativeDateCell(props) { // // Render - render() { - const { - className, - date, - includeSeconds, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - component: Component, - dispatch, - ...otherProps - } = this.props; + const { + className, + date, + includeSeconds, + component: Component, + dispatch, + ...otherProps + } = props; - if (!date) { - return ( - - ); - } + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = + useSelector(createRelativeDateCellSelector()); - return ( - - {getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds, timeForToday: true })} - - ); + if (!date) { + return ; } + + return ( + + {getRelativeDate(date, shortDateFormat, showRelativeDates, { + timeFormat, + includeSeconds, + timeForToday: true + })} + + ); } RelativeDateCell.propTypes = { className: PropTypes.string.isRequired, date: PropTypes.string, includeSeconds: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, component: PropTypes.elementType, dispatch: PropTypes.func }; diff --git a/frontend/src/Components/Table/Column.ts b/frontend/src/Components/Table/Column.ts index 31a696df7..24674c3fc 100644 --- a/frontend/src/Components/Table/Column.ts +++ b/frontend/src/Components/Table/Column.ts @@ -2,9 +2,11 @@ import React from 'react'; type PropertyFunction = () => T; +// TODO: Convert to generic so `name` can be a type interface Column { name: string; label: string | PropertyFunction | React.ReactNode; + className?: string; columnLabel?: string; isSortable?: boolean; isVisible: boolean; diff --git a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js index 7d6f4f2c1..80613de68 100644 --- a/frontend/src/Components/Table/TableOptions/TableOptionsModal.js +++ b/frontend/src/Components/Table/TableOptions/TableOptionsModal.js @@ -192,7 +192,7 @@ class TableOptionsModal extends Component { void; +} + +function usePaging(options: PagingOptions) { + const { page, totalPages, gotoPage } = options; + const dispatch = useDispatch(); + + const handleFirstPagePress = useCallback(() => { + dispatch(gotoPage({ page: 1 })); + }, [dispatch, gotoPage]); + + const handlePreviousPagePress = useCallback(() => { + dispatch(gotoPage({ page: Math.max(page - 1, 1) })); + }, [page, dispatch, gotoPage]); + + const handleNextPagePress = useCallback(() => { + dispatch(gotoPage({ page: Math.min(page + 1, totalPages) })); + }, [page, totalPages, dispatch, gotoPage]); + + const handleLastPagePress = useCallback(() => { + dispatch(gotoPage({ page: totalPages })); + }, [totalPages, dispatch, gotoPage]); + + const handlePageSelect = useCallback( + (page: number) => { + dispatch(gotoPage({ page })); + }, + [dispatch, gotoPage] + ); + + return useMemo(() => { + return { + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + }; + }, [ + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + ]); +} + +export default usePaging; diff --git a/frontend/src/Components/TagList.js b/frontend/src/Components/TagList.js index f4d4e2af4..fe700b8fe 100644 --- a/frontend/src/Components/TagList.js +++ b/frontend/src/Components/TagList.js @@ -1,14 +1,15 @@ 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'; function TagList({ tags, tagList }) { const sortedTags = tags .map((tagId) => tagList.find((tag) => tag.id === tagId)) - .filter((t) => t !== undefined) - .sort((a, b) => a.label.localeCompare(b.label)); + .filter((tag) => !!tag) + .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/DownloadClient/DownloadProtocol.ts b/frontend/src/DownloadClient/DownloadProtocol.ts new file mode 100644 index 000000000..417db8178 --- /dev/null +++ b/frontend/src/DownloadClient/DownloadProtocol.ts @@ -0,0 +1,3 @@ +type DownloadProtocol = 'usenet' | 'torrent' | 'unknown'; + +export default DownloadProtocol; diff --git a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js index 9e561eae2..17a04e403 100644 --- a/frontend/src/FirstRun/AuthenticationRequiredModalContent.js +++ b/frontend/src/FirstRun/AuthenticationRequiredModalContent.js @@ -34,7 +34,8 @@ function AuthenticationRequiredModalContent(props) { authenticationMethod, authenticationRequired, username, - password + password, + passwordConfirmation } = settings; const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; @@ -63,7 +64,7 @@ function AuthenticationRequiredModalContent(props) { className={styles.authRequiredAlert} kind={kinds.WARNING} > - {translate('AuthenticationRequiredWarning', { appName: 'Prowlarr' })} + {translate('AuthenticationRequiredWarning')} { @@ -76,7 +77,7 @@ function AuthenticationRequiredModalContent(props) { type={inputTypes.SELECT} name="authenticationMethod" values={authenticationMethodOptions} - helpText={translate('AuthenticationMethodHelpText', { appName: 'Prowlarr' })} + helpText={translate('AuthenticationMethodHelpText')} helpTextWarning={authenticationMethod.value === 'none' ? translate('AuthenticationMethodHelpTextWarning') : undefined} helpLink="https://wiki.servarr.com/prowlarr/faq#forced-authentication" onChange={onInputChange} @@ -120,6 +121,18 @@ function AuthenticationRequiredModalContent(props) { {...password} /> + + + {translate('PasswordConfirmation')} + + +
: null } diff --git a/frontend/src/Helpers/Hooks/useCurrentPage.ts b/frontend/src/Helpers/Hooks/useCurrentPage.ts new file mode 100644 index 000000000..3caf66df2 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useCurrentPage.ts @@ -0,0 +1,9 @@ +import { useHistory } from 'react-router-dom'; + +function useCurrentPage() { + const history = useHistory(); + + return history.action === 'POP'; +} + +export default useCurrentPage; diff --git a/frontend/src/Helpers/Hooks/useModalOpenState.ts b/frontend/src/Helpers/Hooks/useModalOpenState.ts new file mode 100644 index 000000000..24cffb2f1 --- /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, setIsOpen] = useState(initialState); + + const setModalOpen = useCallback(() => { + setIsOpen(true); + }, [setIsOpen]); + + const setModalClosed = useCallback(() => { + setIsOpen(false); + }, [setIsOpen]); + + return [isOpen, setModalOpen, setModalClosed]; +} diff --git a/frontend/src/Helpers/Props/TooltipPosition.ts b/frontend/src/Helpers/Props/TooltipPosition.ts new file mode 100644 index 000000000..885c73470 --- /dev/null +++ b/frontend/src/Helpers/Props/TooltipPosition.ts @@ -0,0 +1,3 @@ +type TooltipPosition = 'top' | 'right' | 'bottom' | 'left'; + +export default TooltipPosition; diff --git a/frontend/src/Helpers/Props/align.js b/frontend/src/Helpers/Props/align.ts similarity index 100% rename from frontend/src/Helpers/Props/align.js rename to frontend/src/Helpers/Props/align.ts diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js index 7fed535f2..73ef41956 100644 --- a/frontend/src/Helpers/Props/filterBuilderValueTypes.js +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js @@ -2,9 +2,10 @@ export const BOOL = 'bool'; export const BYTES = 'bytes'; export const DATE = 'date'; export const DEFAULT = 'default'; +export const HISTORY_EVENT_TYPE = 'historyEventType'; export const INDEXER = 'indexer'; export const PROTOCOL = 'protocol'; export const PRIVACY = 'privacy'; export const APP_PROFILE = 'appProfile'; -export const MOVIE_STATUS = 'movieStatus'; +export const CATEGORY = 'category'; export const TAG = 'tag'; diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 834452242..773748996 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -43,6 +43,7 @@ import { faChevronCircleRight as fasChevronCircleRight, faChevronCircleUp as fasChevronCircleUp, faCircle as fasCircle, + faCircleDown as fasCircleDown, faCloud as fasCloud, faCloudDownloadAlt as fasCloudDownloadAlt, faCog as fasCog, @@ -141,6 +142,7 @@ export const CHECK_INDETERMINATE = fasMinus; export const CHECK_CIRCLE = fasCheckCircle; export const CHECK_SQUARE = fasSquareCheck; export const CIRCLE = fasCircle; +export const CIRCLE_DOWN = fasCircleDown; export const CIRCLE_OUTLINE = farCircle; export const CLEAR = fasTrashAlt; export const CLIPBOARD = fasCopy; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index 6c4564341..f9cd58e6d 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -1,6 +1,5 @@ export const AUTO_COMPLETE = 'autoComplete'; export const APP_PROFILE_SELECT = 'appProfileSelect'; -export const AVAILABILITY_SELECT = 'availabilitySelect'; export const CAPTCHA = 'captcha'; export const CARDIGANNCAPTCHA = 'cardigannCaptcha'; export const CHECK = 'check'; @@ -27,7 +26,6 @@ export const TAG_SELECT = 'tagSelect'; export const all = [ AUTO_COMPLETE, APP_PROFILE_SELECT, - AVAILABILITY_SELECT, CAPTCHA, CARDIGANNCAPTCHA, CHECK, diff --git a/frontend/src/Helpers/Props/kinds.js b/frontend/src/Helpers/Props/kinds.ts similarity index 72% rename from frontend/src/Helpers/Props/kinds.js rename to frontend/src/Helpers/Props/kinds.ts index b0f5ac87f..7ce606716 100644 --- a/frontend/src/Helpers/Props/kinds.js +++ b/frontend/src/Helpers/Props/kinds.ts @@ -7,7 +7,6 @@ export const PRIMARY = 'primary'; export const PURPLE = 'purple'; export const SUCCESS = 'success'; export const WARNING = 'warning'; -export const QUEUE = 'queue'; export const all = [ DANGER, @@ -19,5 +18,15 @@ export const all = [ PURPLE, SUCCESS, WARNING, - QUEUE -]; +] as const; + +export type Kind = + | 'danger' + | 'default' + | 'disabled' + | 'info' + | 'inverse' + | 'primary' + | 'purple' + | 'success' + | 'warning'; diff --git a/frontend/src/Helpers/Props/sizes.js b/frontend/src/Helpers/Props/sizes.ts similarity index 71% rename from frontend/src/Helpers/Props/sizes.js rename to frontend/src/Helpers/Props/sizes.ts index d7f85df5e..ca7a50fbf 100644 --- a/frontend/src/Helpers/Props/sizes.js +++ b/frontend/src/Helpers/Props/sizes.ts @@ -4,4 +4,6 @@ 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 all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE] as const; + +export type Size = 'extraSmall' | 'small' | 'medium' | 'large' | 'extraLarge'; diff --git a/frontend/src/History/Details/HistoryDetails.js b/frontend/src/History/Details/HistoryDetails.js index e0ae06eb1..6d5ab260e 100644 --- a/frontend/src/History/Details/HistoryDetails.js +++ b/frontend/src/History/Details/HistoryDetails.js @@ -3,6 +3,7 @@ import React from 'react'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; import Link from 'Components/Link/Link'; +import formatDateTime from 'Utilities/Date/formatDateTime'; import translate from 'Utilities/String/translate'; import styles from './HistoryDetails.css'; @@ -10,7 +11,10 @@ function HistoryDetails(props) { const { indexer, eventType, - data + date, + data, + shortDateFormat, + timeFormat } = props; if (eventType === 'indexerQuery' || eventType === 'indexerRss') { @@ -21,7 +25,10 @@ function HistoryDetails(props) { limit, offset, source, - url + host, + url, + elapsedTime, + cached } = data; return ( @@ -86,6 +93,15 @@ function HistoryDetails(props) { null } + { + data ? + : + null + } + { data ? : null } + + { + elapsedTime ? + : + null + } + + { + date ? + : + null + } ); } @@ -101,10 +135,19 @@ function HistoryDetails(props) { if (eventType === 'releaseGrabbed') { const { source, + host, grabTitle, - url + url, + publishedDate, + infoUrl, + downloadClient, + downloadClientName, + elapsedTime, + grabMethod } = data; + const downloadClientNameInfo = downloadClientName ?? downloadClient; + return ( { @@ -125,6 +168,15 @@ function HistoryDetails(props) { null } + { + data ? + : + null + } + { data ? {infoUrl}} + /> : + null + } + + { + publishedDate ? + : + null + } + + { + downloadClientNameInfo ? + : + null + } + { data ? : null } + + { + elapsedTime ? + : + null + } + + { + grabMethod ? + : + null + } + + { + date ? + : + null + } ); } if (eventType === 'indexerAuth') { + const { elapsedTime } = data; + return ( : null } + + { + elapsedTime ? + : + null + } + + { + date ? + : + null + } ); } @@ -171,6 +297,15 @@ function HistoryDetails(props) { title={translate('Name')} data={data.query} /> + + { + date ? + : + null + } ); } @@ -178,6 +313,7 @@ function HistoryDetails(props) { HistoryDetails.propTypes = { indexer: PropTypes.object.isRequired, eventType: PropTypes.string.isRequired, + date: PropTypes.string.isRequired, data: PropTypes.object.isRequired, shortDateFormat: PropTypes.string.isRequired, timeFormat: PropTypes.string.isRequired diff --git a/frontend/src/History/Details/HistoryDetailsModal.css b/frontend/src/History/Details/HistoryDetailsModal.css deleted file mode 100644 index 271d422ff..000000000 --- a/frontend/src/History/Details/HistoryDetailsModal.css +++ /dev/null @@ -1,5 +0,0 @@ -.markAsFailedButton { - composes: button from '~Components/Link/Button.css'; - - margin-right: auto; -} diff --git a/frontend/src/History/Details/HistoryDetailsModal.js b/frontend/src/History/Details/HistoryDetailsModal.js index e6f960c48..560955de3 100644 --- a/frontend/src/History/Details/HistoryDetailsModal.js +++ b/frontend/src/History/Details/HistoryDetailsModal.js @@ -1,16 +1,13 @@ import PropTypes from 'prop-types'; import React from 'react'; import Button from 'Components/Link/Button'; -import SpinnerButton from 'Components/Link/SpinnerButton'; import Modal from 'Components/Modal/Modal'; import ModalBody from 'Components/Modal/ModalBody'; import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; -import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import HistoryDetails from './HistoryDetails'; -import styles from './HistoryDetailsModal.css'; function getHeaderTitle(eventType) { switch (eventType) { @@ -32,11 +29,10 @@ function HistoryDetailsModal(props) { isOpen, eventType, indexer, + date, data, - isMarkingAsFailed, shortDateFormat, timeFormat, - onMarkAsFailedPress, onModalClose } = props; @@ -54,6 +50,7 @@ function HistoryDetailsModal(props) { - { - eventType === 'grabbed' && - - Mark as Failed - - } -
- ); - } -} - -HistoryRowParameter.propTypes = { - title: PropTypes.string.isRequired, - value: PropTypes.string.isRequired -}; - -export default HistoryRowParameter; diff --git a/frontend/src/History/HistoryRowParameter.tsx b/frontend/src/History/HistoryRowParameter.tsx new file mode 100644 index 000000000..ad83d5d77 --- /dev/null +++ b/frontend/src/History/HistoryRowParameter.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import Link from 'Components/Link/Link'; +import { HistoryQueryType } from 'typings/History'; +import styles from './HistoryRowParameter.css'; + +interface HistoryRowParameterProps { + title: string; + value: string; + queryType: HistoryQueryType; +} + +function HistoryRowParameter(props: HistoryRowParameterProps) { + const { title, value, queryType } = props; + + const type = title.toLowerCase(); + + let link = null; + + if (type === 'imdb') { + link = {value}; + } else if (type === 'tmdb') { + link = ( + + {value} + + ); + } else if (type === 'tvdb') { + link = ( + + {value} + + ); + } else if (type === 'tvmaze') { + link = {value}; + } + + return ( +
+
+ {title} +
+ +
{link ? link : value}
+
+ ); +} + +export default HistoryRowParameter; diff --git a/frontend/src/Indexer/Add/AddIndexerModal.js b/frontend/src/Indexer/Add/AddIndexerModal.js deleted file mode 100644 index 4c4db24b9..000000000 --- a/frontend/src/Indexer/Add/AddIndexerModal.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import AddIndexerModalContentConnector from './AddIndexerModalContentConnector'; -import styles from './AddIndexerModal.css'; - -function AddIndexerModal({ isOpen, onModalClose, onSelectIndexer, ...otherProps }) { - return ( - - - - ); -} - -AddIndexerModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired, - onSelectIndexer: PropTypes.func.isRequired -}; - -export default AddIndexerModal; diff --git a/frontend/src/Indexer/Add/AddIndexerModal.tsx b/frontend/src/Indexer/Add/AddIndexerModal.tsx new file mode 100644 index 000000000..be22eec57 --- /dev/null +++ b/frontend/src/Indexer/Add/AddIndexerModal.tsx @@ -0,0 +1,44 @@ +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import { clearIndexerSchema } from 'Store/Actions/indexerActions'; +import AddIndexerModalContent from './AddIndexerModalContent'; +import styles from './AddIndexerModal.css'; + +interface AddIndexerModalProps { + isOpen: boolean; + onSelectIndexer(): void; + onModalClose(): void; +} + +function AddIndexerModal({ + isOpen, + onSelectIndexer, + onModalClose, + ...otherProps +}: AddIndexerModalProps) { + const dispatch = useDispatch(); + + const onModalClosePress = useCallback(() => { + dispatch(clearIndexerSchema()); + onModalClose(); + }, [dispatch, onModalClose]); + + return ( + + + + ); +} + +export default AddIndexerModal; diff --git a/frontend/src/Indexer/Add/AddIndexerModalContent.css b/frontend/src/Indexer/Add/AddIndexerModalContent.css index a58eccfbc..e824c5475 100644 --- a/frontend/src/Indexer/Add/AddIndexerModalContent.css +++ b/frontend/src/Indexer/Add/AddIndexerModalContent.css @@ -19,12 +19,18 @@ margin-bottom: 16px; } -.alert { +.notice { composes: alert from '~Components/Alert.css'; margin-bottom: 20px; } +.alert { + composes: alert from '~Components/Alert.css'; + + text-align: center; +} + .scroller { flex: 1 1 auto; } diff --git a/frontend/src/Indexer/Add/AddIndexerModalContent.css.d.ts b/frontend/src/Indexer/Add/AddIndexerModalContent.css.d.ts index cbedc72a4..5978832e4 100644 --- a/frontend/src/Indexer/Add/AddIndexerModalContent.css.d.ts +++ b/frontend/src/Indexer/Add/AddIndexerModalContent.css.d.ts @@ -10,6 +10,7 @@ interface CssExports { 'indexers': string; 'modalBody': string; 'modalFooter': string; + 'notice': string; 'scroller': string; } export const cssExports: CssExports; diff --git a/frontend/src/Indexer/Add/AddIndexerModalContent.js b/frontend/src/Indexer/Add/AddIndexerModalContent.js deleted file mode 100644 index 81e911467..000000000 --- a/frontend/src/Indexer/Add/AddIndexerModalContent.js +++ /dev/null @@ -1,318 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import EnhancedSelectInput from 'Components/Form/EnhancedSelectInput'; -import NewznabCategorySelectInputConnector from 'Components/Form/NewznabCategorySelectInputConnector'; -import TextInput from 'Components/Form/TextInput'; -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 Scroller from 'Components/Scroller/Scroller'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import { kinds, scrollDirections } from 'Helpers/Props'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -import translate from 'Utilities/String/translate'; -import SelectIndexerRowConnector from './SelectIndexerRowConnector'; -import styles from './AddIndexerModalContent.css'; - -const columns = [ - { - name: 'protocol', - label: () => translate('Protocol'), - isSortable: true, - isVisible: true - }, - { - name: 'sortName', - label: () => translate('Name'), - isSortable: true, - isVisible: true - }, - { - name: 'language', - label: () => translate('Language'), - isSortable: true, - isVisible: true - }, - { - name: 'description', - label: () => translate('Description'), - isSortable: false, - isVisible: true - }, - { - name: 'privacy', - label: () => translate('Privacy'), - isSortable: true, - isVisible: true - } -]; - -const protocols = [ - { - key: 'torrent', - value: 'torrent' - }, - { - key: 'usenet', - value: 'nzb' - } -]; - -const privacyLevels = [ - { - key: 'private', - get value() { - return translate('Private'); - } - }, - { - key: 'semiPrivate', - get value() { - return translate('SemiPrivate'); - } - }, - { - key: 'public', - get value() { - return translate('Public'); - } - } -]; - -class AddIndexerModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - filter: '', - filterProtocols: [], - filterLanguages: [], - filterPrivacyLevels: [], - filterCategories: [] - }; - } - - // - // Listeners - - onFilterChange = ({ value }) => { - this.setState({ filter: value }); - }; - - // - // Render - - render() { - const { - indexers, - onIndexerSelect, - sortKey, - sortDirection, - isFetching, - isPopulated, - error, - onSortPress, - onModalClose - } = this.props; - - const languages = Array.from(new Set(indexers.map(({ language }) => language))) - .sort((a, b) => a.localeCompare(b)) - .map((language) => ({ key: language, value: language })); - - const filteredIndexers = indexers.filter((indexer) => { - const { - filter, - filterProtocols, - filterLanguages, - filterPrivacyLevels, - filterCategories - } = this.state; - - if (!indexer.name.toLowerCase().includes(filter.toLocaleLowerCase()) && !indexer.description.toLowerCase().includes(filter.toLocaleLowerCase())) { - return false; - } - - if (filterProtocols.length && !filterProtocols.includes(indexer.protocol)) { - return false; - } - - if (filterLanguages.length && !filterLanguages.includes(indexer.language)) { - return false; - } - - if (filterPrivacyLevels.length && !filterPrivacyLevels.includes(indexer.privacy)) { - return false; - } - - if (filterCategories.length) { - const { categories = [] } = indexer.capabilities || {}; - const flat = ({ id, subCategories = [] }) => [id, ...subCategories.flatMap(flat)]; - const flatCategories = categories - .filter((item) => item.id < 100000) - .flatMap(flat); - - if (!filterCategories.every((item) => flatCategories.includes(item))) { - return false; - } - } - - return true; - }); - - const errorMessage = getErrorMessage(error, translate('UnableToLoadIndexers')); - - return ( - - - {translate('AddIndexer')} - - - - - -
-
- - this.setState({ filterProtocols: value })} - /> -
- -
- - this.setState({ filterLanguages: value })} - /> -
- -
- - this.setState({ filterPrivacyLevels: value })} - /> -
- -
- - this.setState({ filterCategories: value })} - /> -
-
- - -
- {translate('ProwlarrSupportsAnyIndexer')} -
-
- - - { - isFetching ? : null - } - { - error ? {errorMessage} : null - } - { - isPopulated && !!indexers.length ? - - - { - filteredIndexers.map((indexer) => ( - - )) - } - -
: - null - } - { - isPopulated && !!indexers.length && !filteredIndexers.length ? - - {translate('NoIndexersFound')} - : - null - } -
-
- - -
- { - isPopulated ? - translate('CountIndexersAvailable', { count: filteredIndexers.length }) : - null - } -
- -
- -
-
-
- ); - } -} - -AddIndexerModalContent.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - sortKey: PropTypes.string, - sortDirection: PropTypes.string, - onSortPress: PropTypes.func.isRequired, - indexers: PropTypes.arrayOf(PropTypes.object).isRequired, - onIndexerSelect: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default AddIndexerModalContent; diff --git a/frontend/src/Indexer/Add/AddIndexerModalContent.tsx b/frontend/src/Indexer/Add/AddIndexerModalContent.tsx new file mode 100644 index 000000000..be1413769 --- /dev/null +++ b/frontend/src/Indexer/Add/AddIndexerModalContent.tsx @@ -0,0 +1,434 @@ +import { some } from 'lodash'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import IndexerAppState from 'App/State/IndexerAppState'; +import Alert from 'Components/Alert'; +import EnhancedSelectInput from 'Components/Form/EnhancedSelectInput'; +import NewznabCategorySelectInputConnector from 'Components/Form/NewznabCategorySelectInputConnector'; +import TextInput from 'Components/Form/TextInput'; +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 Scroller from 'Components/Scroller/Scroller'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import { kinds, scrollDirections } from 'Helpers/Props'; +import Indexer, { IndexerCategory } from 'Indexer/Indexer'; +import { + fetchIndexerSchema, + selectIndexerSchema, + setIndexerSchemaSort, +} from 'Store/Actions/indexerActions'; +import createAllIndexersSelector from 'Store/Selectors/createAllIndexersSelector'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { SortCallback } from 'typings/callbacks'; +import sortByProp from 'Utilities/Array/sortByProp'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import SelectIndexerRow from './SelectIndexerRow'; +import styles from './AddIndexerModalContent.css'; + +const COLUMNS = [ + { + name: 'protocol', + label: () => translate('Protocol'), + isSortable: true, + isVisible: true, + }, + { + name: 'sortName', + label: () => translate('Name'), + isSortable: true, + isVisible: true, + }, + { + name: 'language', + label: () => translate('Language'), + isSortable: true, + isVisible: true, + }, + { + name: 'description', + label: () => translate('Description'), + isSortable: false, + isVisible: true, + }, + { + name: 'privacy', + label: () => translate('Privacy'), + isSortable: true, + isVisible: true, + }, + { + name: 'categories', + label: () => translate('Categories'), + isSortable: false, + isVisible: true, + }, +]; + +const PROTOCOLS = [ + { + key: 'torrent', + value: 'torrent', + }, + { + key: 'usenet', + value: 'nzb', + }, +]; + +const PRIVACY_LEVELS = [ + { + key: 'private', + get value() { + return translate('Private'); + }, + }, + { + key: 'semiPrivate', + get value() { + return translate('SemiPrivate'); + }, + }, + { + key: 'public', + get value() { + return translate('Public'); + }, + }, +]; + +interface IndexerSchema extends Indexer { + isExistingIndexer: boolean; +} + +function createAddIndexersSelector() { + return createSelector( + createClientSideCollectionSelector('indexers.schema'), + createAllIndexersSelector(), + (indexers: IndexerAppState, allIndexers) => { + const { isFetching, isPopulated, error, items, sortDirection, sortKey } = + indexers; + + const indexerList: IndexerSchema[] = items.map((item) => { + const { definitionName } = item; + return { + ...item, + isExistingIndexer: some(allIndexers, { definitionName }), + }; + }); + + return { + isFetching, + isPopulated, + error, + indexers: indexerList, + sortKey, + sortDirection, + }; + } + ); +} + +interface AddIndexerModalContentProps { + onSelectIndexer(): void; + onModalClose(): void; +} + +function AddIndexerModalContent(props: AddIndexerModalContentProps) { + const { onSelectIndexer, onModalClose } = props; + + const { isFetching, isPopulated, error, indexers, sortKey, sortDirection } = + useSelector(createAddIndexersSelector()); + const dispatch = useDispatch(); + + const [filter, setFilter] = useState(''); + const [filterProtocols, setFilterProtocols] = useState([]); + const [filterLanguages, setFilterLanguages] = useState([]); + const [filterPrivacyLevels, setFilterPrivacyLevels] = useState([]); + const [filterCategories, setFilterCategories] = useState([]); + + useEffect( + () => { + dispatch(fetchIndexerSchema()); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const onFilterChange = useCallback( + ({ value }: { value: string }) => { + setFilter(value); + }, + [setFilter] + ); + + const onFilterProtocolsChange = useCallback( + ({ value }: { value: string[] }) => { + setFilterProtocols(value); + }, + [setFilterProtocols] + ); + + const onFilterLanguagesChange = useCallback( + ({ value }: { value: string[] }) => { + setFilterLanguages(value); + }, + [setFilterLanguages] + ); + + const onFilterPrivacyLevelsChange = useCallback( + ({ value }: { value: string[] }) => { + setFilterPrivacyLevels(value); + }, + [setFilterPrivacyLevels] + ); + + const onFilterCategoriesChange = useCallback( + ({ value }: { value: number[] }) => { + setFilterCategories(value); + }, + [setFilterCategories] + ); + + const onIndexerSelect = useCallback( + ({ + implementation, + implementationName, + name, + }: { + implementation: string; + implementationName: string; + name: string; + }) => { + dispatch( + selectIndexerSchema({ + implementation, + implementationName, + name, + }) + ); + + onSelectIndexer(); + }, + [dispatch, onSelectIndexer] + ); + + const onSortPress = useCallback( + (sortKey, sortDirection) => { + dispatch(setIndexerSchemaSort({ sortKey, sortDirection })); + }, + [dispatch] + ); + + const languages = useMemo( + () => + Array.from(new Set(indexers.map(({ language }) => language))) + .map((language) => ({ key: language, value: language })) + .sort(sortByProp('value')), + [indexers] + ); + + const filteredIndexers = useMemo(() => { + const flat = ({ + id, + subCategories = [], + }: { + id: number; + subCategories: IndexerCategory[]; + }): number[] => [id, ...subCategories.flatMap(flat)]; + + return indexers.filter((indexer) => { + if ( + filter.length && + !indexer.name.toLowerCase().includes(filter.toLocaleLowerCase()) && + !indexer.description.toLowerCase().includes(filter.toLocaleLowerCase()) + ) { + return false; + } + + if ( + filterProtocols.length && + !filterProtocols.includes(indexer.protocol) + ) { + return false; + } + + if ( + filterLanguages.length && + !filterLanguages.includes(indexer.language) + ) { + return false; + } + + if ( + filterPrivacyLevels.length && + !filterPrivacyLevels.includes(indexer.privacy) + ) { + return false; + } + + if (filterCategories.length) { + const { categories = [] } = indexer.capabilities || {}; + + const flatCategories = categories + .filter((item) => item.id < 100000) + .flatMap(flat); + + if ( + !filterCategories.every((categoryId) => + flatCategories.includes(categoryId) + ) + ) { + return false; + } + } + + return true; + }); + }, [ + indexers, + filter, + filterProtocols, + filterLanguages, + filterPrivacyLevels, + filterCategories, + ]); + + const errorMessage = getErrorMessage( + error, + translate('UnableToLoadIndexers') + ); + + return ( + + {translate('AddIndexer')} + + + + +
+
+ + + +
+ +
+ + + +
+ +
+ + +
+ +
+ + + +
+
+ + +
{translate('ProwlarrSupportsAnyIndexer')}
+
+ + + {isFetching ? : null} + + {error ? ( + + {errorMessage} + + ) : null} + + {isPopulated && !!indexers.length ? ( + + + {filteredIndexers.map((indexer) => ( + + ))} + +
+ ) : null} + + {isPopulated && !!indexers.length && !filteredIndexers.length ? ( + + {translate('NoIndexersFound')} + + ) : null} +
+
+ + +
+ {isPopulated + ? translate('CountIndexersAvailable', { + count: filteredIndexers.length, + }) + : null} +
+ +
+ +
+
+
+ ); +} + +export default AddIndexerModalContent; diff --git a/frontend/src/Indexer/Add/AddIndexerModalContentConnector.js b/frontend/src/Indexer/Add/AddIndexerModalContentConnector.js deleted file mode 100644 index 9b9b2d824..000000000 --- a/frontend/src/Indexer/Add/AddIndexerModalContentConnector.js +++ /dev/null @@ -1,83 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchIndexerSchema, selectIndexerSchema, setIndexerSchemaSort } from 'Store/Actions/indexerActions'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import AddIndexerModalContent from './AddIndexerModalContent'; - -function createMapStateToProps() { - return createSelector( - createClientSideCollectionSelector('indexers.schema'), - (indexers) => { - const { - isFetching, - isPopulated, - error, - items, - sortDirection, - sortKey - } = indexers; - - return { - isFetching, - isPopulated, - error, - indexers: items, - sortKey, - sortDirection - }; - } - ); -} - -const mapDispatchToProps = { - fetchIndexerSchema, - selectIndexerSchema, - setIndexerSchemaSort -}; - -class AddIndexerModalContentConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - this.props.fetchIndexerSchema(); - } - - // - // Listeners - - onIndexerSelect = ({ implementation, implementationName, name }) => { - this.props.selectIndexerSchema({ implementation, implementationName, name }); - this.props.onSelectIndexer(); - }; - - onSortPress = (sortKey, sortDirection) => { - this.props.setIndexerSchemaSort({ sortKey, sortDirection }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -AddIndexerModalContentConnector.propTypes = { - fetchIndexerSchema: PropTypes.func.isRequired, - selectIndexerSchema: PropTypes.func.isRequired, - setIndexerSchemaSort: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired, - onSelectIndexer: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(AddIndexerModalContentConnector); diff --git a/frontend/src/Indexer/Add/SelectIndexerRow.js b/frontend/src/Indexer/Add/SelectIndexerRow.js deleted file mode 100644 index 7e1c994bf..000000000 --- a/frontend/src/Indexer/Add/SelectIndexerRow.js +++ /dev/null @@ -1,90 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRowButton from 'Components/Table/TableRowButton'; -import { icons } from 'Helpers/Props'; -import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; -import firstCharToUpper from 'Utilities/String/firstCharToUpper'; -import translate from 'Utilities/String/translate'; -import styles from './SelectIndexerRow.css'; - -class SelectIndexerRow extends Component { - - // - // Listeners - - onPress = () => { - const { - implementation, - implementationName, - name - } = this.props; - - this.props.onIndexerSelect({ implementation, implementationName, name }); - }; - - // - // Render - - render() { - const { - protocol, - privacy, - name, - language, - description, - isExistingIndexer - } = this.props; - - return ( - - - - - - - {name} - { - isExistingIndexer ? - : - null - } - - - - {language} - - - - {description} - - - - {translate(firstCharToUpper(privacy))} - - - ); - } -} - -SelectIndexerRow.propTypes = { - name: PropTypes.string.isRequired, - protocol: PropTypes.string.isRequired, - privacy: PropTypes.string.isRequired, - language: PropTypes.string.isRequired, - description: PropTypes.string.isRequired, - implementation: PropTypes.string.isRequired, - implementationName: PropTypes.string.isRequired, - onIndexerSelect: PropTypes.func.isRequired, - isExistingIndexer: PropTypes.bool.isRequired -}; - -export default SelectIndexerRow; diff --git a/frontend/src/Indexer/Add/SelectIndexerRow.tsx b/frontend/src/Indexer/Add/SelectIndexerRow.tsx new file mode 100644 index 000000000..157050e41 --- /dev/null +++ b/frontend/src/Indexer/Add/SelectIndexerRow.tsx @@ -0,0 +1,78 @@ +import React, { useCallback } from 'react'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRowButton from 'Components/Table/TableRowButton'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { icons } from 'Helpers/Props'; +import CapabilitiesLabel from 'Indexer/Index/Table/CapabilitiesLabel'; +import PrivacyLabel from 'Indexer/Index/Table/PrivacyLabel'; +import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; +import { IndexerCapabilities, IndexerPrivacy } from 'Indexer/Indexer'; +import translate from 'Utilities/String/translate'; +import styles from './SelectIndexerRow.css'; + +interface SelectIndexerRowProps { + name: string; + protocol: DownloadProtocol; + privacy: IndexerPrivacy; + language: string; + description: string; + capabilities: IndexerCapabilities; + implementation: string; + implementationName: string; + isExistingIndexer: boolean; + onIndexerSelect(...args: unknown[]): void; +} + +function SelectIndexerRow(props: SelectIndexerRowProps) { + const { + name, + protocol, + privacy, + language, + description, + capabilities, + implementation, + implementationName, + isExistingIndexer, + onIndexerSelect, + } = props; + + const onPress = useCallback(() => { + onIndexerSelect({ implementation, implementationName, name }); + }, [implementation, implementationName, name, onIndexerSelect]); + + return ( + + + + + + + {name} + {isExistingIndexer ? ( + + ) : null} + + + {language} + + {description} + + + + + + + + + + ); +} + +export default SelectIndexerRow; diff --git a/frontend/src/Indexer/Add/SelectIndexerRowConnector.js b/frontend/src/Indexer/Add/SelectIndexerRowConnector.js deleted file mode 100644 index f507689c8..000000000 --- a/frontend/src/Indexer/Add/SelectIndexerRowConnector.js +++ /dev/null @@ -1,18 +0,0 @@ - -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createExistingIndexerSelector from 'Store/Selectors/createExistingIndexerSelector'; -import SelectIndexerRow from './SelectIndexerRow'; - -function createMapStateToProps() { - return createSelector( - createExistingIndexerSelector(), - (isExistingIndexer, dimensions) => { - return { - isExistingIndexer - }; - } - ); -} - -export default connect(createMapStateToProps)(SelectIndexerRow); diff --git a/frontend/src/Indexer/Edit/EditIndexerModalContent.js b/frontend/src/Indexer/Edit/EditIndexerModalContent.js index 1fdc06eb0..7dabc50d9 100644 --- a/frontend/src/Indexer/Edit/EditIndexerModalContent.js +++ b/frontend/src/Indexer/Edit/EditIndexerModalContent.js @@ -97,7 +97,7 @@ function EditIndexerModalContent(props) { @@ -144,6 +144,7 @@ function EditIndexerModalContent(props) { }) : null } + diff --git a/frontend/src/Indexer/Index/IndexerIndex.tsx b/frontend/src/Indexer/Index/IndexerIndex.tsx index 644c81493..e20e269f8 100644 --- a/frontend/src/Indexer/Index/IndexerIndex.tsx +++ b/frontend/src/Indexer/Index/IndexerIndex.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { SelectProvider } from 'App/SelectContext'; import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState'; @@ -22,12 +28,17 @@ import AddIndexerModal from 'Indexer/Add/AddIndexerModal'; import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector'; import NoIndexer from 'Indexer/NoIndexer'; import { executeCommand } from 'Store/Actions/commandActions'; -import { testAllIndexers } from 'Store/Actions/indexerActions'; +import { + cloneIndexer, + fetchIndexers, + testAllIndexers, +} from 'Store/Actions/indexerActions'; import { setIndexerFilter, setIndexerSort, setIndexerTableOption, } from 'Store/Actions/indexerIndexActions'; +import { fetchIndexerStatus } from 'Store/Actions/indexerStatusActions'; import scrollPositions from 'Store/scrollPositions'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; @@ -82,6 +93,11 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => { ); const [isSelectMode, setIsSelectMode] = useState(false); + useEffect(() => { + dispatch(fetchIndexers()); + dispatch(fetchIndexerStatus()); + }, [dispatch]); + const onAddIndexerPress = useCallback(() => { setIsAddIndexerModalOpen(true); }, [setIsAddIndexerModalOpen]); @@ -98,6 +114,15 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => { setIsEditIndexerModalOpen(false); }, [setIsEditIndexerModalOpen]); + const onCloneIndexerPress = useCallback( + (id: number) => { + dispatch(cloneIndexer({ id })); + + setIsEditIndexerModalOpen(true); + }, + [dispatch, setIsEditIndexerModalOpen] + ); + const onAppIndexerSyncPress = useCallback(() => { dispatch( executeCommand({ @@ -303,6 +328,7 @@ const IndexerIndex = withScrollPosition((props: IndexerIndexProps) => { jumpToCharacter={jumpToCharacter} isSelectMode={isSelectMode} isSmallScreen={isSmallScreen} + onCloneIndexerPress={onCloneIndexerPress} /> diff --git a/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx b/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx index b0bba5b94..9d42aa389 100644 --- a/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx +++ b/frontend/src/Indexer/Index/Select/Edit/EditIndexerModalContent.tsx @@ -19,6 +19,7 @@ interface SavePayload { seedRatio?: number; seedTime?: number; packSeedTime?: number; + preferMagnetUrl?: boolean; } interface EditIndexerModalContentProps { @@ -35,7 +36,7 @@ const enableOptions = [ get value() { return translate('NoChange'); }, - disabled: true, + isDisabled: true, }, { key: 'true', @@ -65,6 +66,9 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { const [packSeedTime, setPackSeedTime] = useState( null ); + const [preferMagnetUrl, setPreferMagnetUrl] = useState< + null | string | boolean + >(null); const save = useCallback(() => { let hasChanges = false; @@ -105,6 +109,11 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { payload.packSeedTime = packSeedTime as number; } + if (preferMagnetUrl !== null) { + hasChanges = true; + payload.preferMagnetUrl = preferMagnetUrl === 'true'; + } + if (hasChanges) { onSavePress(payload); } @@ -118,6 +127,7 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { seedRatio, seedTime, packSeedTime, + preferMagnetUrl, onSavePress, onModalClose, ]); @@ -146,6 +156,9 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { case 'packSeedTime': setPackSeedTime(value); break; + case 'preferMagnetUrl': + setPreferMagnetUrl(value); + break; default: console.warn(`EditIndexersModalContent Unknown Input: '${name}'`); } @@ -224,6 +237,7 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { name="seedRatio" value={seedRatio} helpText={translate('SeedRatioHelpText')} + isFloat={true} onChange={onInputChange} /> @@ -253,6 +267,18 @@ function EditIndexerModalContent(props: EditIndexerModalContentProps) { onChange={onInputChange} /> + + + {translate('PreferMagnetUrl')} + + + diff --git a/frontend/src/Indexer/Index/Table/CapabilitiesLabel.tsx b/frontend/src/Indexer/Index/Table/CapabilitiesLabel.tsx index 5f742d902..8e30532cc 100644 --- a/frontend/src/Indexer/Index/Table/CapabilitiesLabel.tsx +++ b/frontend/src/Indexer/Index/Table/CapabilitiesLabel.tsx @@ -1,6 +1,8 @@ +import { uniqBy } from 'lodash'; import React from 'react'; import Label from 'Components/Label'; import { IndexerCapabilities } from 'Indexer/Indexer'; +import translate from 'Utilities/String/translate'; interface CapabilitiesLabelProps { capabilities: IndexerCapabilities; @@ -23,17 +25,21 @@ function CapabilitiesLabel(props: CapabilitiesLabelProps) { ); } - const nameList = Array.from( - new Set(filteredList.map((item) => item.name).sort()) + const indexerCategories = uniqBy(filteredList, 'id').sort( + (a, b) => a.id - b.id ); return ( - {nameList.map((category) => { - return ; + {indexerCategories.map((category) => { + return ( + + ); })} - {filteredList.length === 0 ? : null} + {filteredList.length === 0 ? : null} ); } diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexRow.css b/frontend/src/Indexer/Index/Table/IndexerIndexRow.css index a0a0daee4..a20efded3 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexRow.css +++ b/frontend/src/Indexer/Index/Table/IndexerIndexRow.css @@ -11,6 +11,12 @@ flex: 0 0 60px; } +.id { + composes: cell; + + flex: 0 0 60px; +} + .sortName { composes: cell; @@ -23,7 +29,8 @@ .minimumSeeders, .seedRatio, .seedTime, -.packSeedTime { +.packSeedTime, +.preferMagnetUrl { composes: cell; flex: 0 0 90px; diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts b/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts index c5d22cf6d..42821bd74 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts +++ b/frontend/src/Indexer/Index/Table/IndexerIndexRow.css.d.ts @@ -8,8 +8,10 @@ interface CssExports { 'cell': string; 'checkInput': string; 'externalLink': string; + 'id': string; 'minimumSeeders': string; 'packSeedTime': string; + 'preferMagnetUrl': string; 'priority': string; 'privacy': string; 'protocol': string; diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx b/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx index 69afaeeba..e4c3cd32e 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx +++ b/frontend/src/Indexer/Index/Table/IndexerIndexRow.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; import { useSelect } from 'App/SelectContext'; -import Label from 'Components/Label'; +import CheckInput from 'Components/Form/CheckInput'; import IconButton from 'Components/Link/IconButton'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; import Column from 'Components/Table/Column'; @@ -15,10 +15,10 @@ import createIndexerIndexItemSelector from 'Indexer/Index/createIndexerIndexItem import Indexer from 'Indexer/Indexer'; import IndexerTitleLink from 'Indexer/IndexerTitleLink'; import { SelectStateInputProps } from 'typings/props'; -import firstCharToUpper from 'Utilities/String/firstCharToUpper'; import translate from 'Utilities/String/translate'; import CapabilitiesLabel from './CapabilitiesLabel'; import IndexerStatusCell from './IndexerStatusCell'; +import PrivacyLabel from './PrivacyLabel'; import ProtocolLabel from './ProtocolLabel'; import styles from './IndexerIndexRow.css'; @@ -27,13 +27,14 @@ interface IndexerIndexRowProps { sortKey: string; columns: Column[]; isSelectMode: boolean; + onCloneIndexerPress(id: number): void; } function IndexerIndexRow(props: IndexerIndexRowProps) { - const { indexerId, columns, isSelectMode } = props; + const { indexerId, columns, isSelectMode, onCloneIndexerPress } = props; const { indexer, appProfile, status, longDateFormat, timeFormat } = - useSelector(createIndexerIndexItemSelector(props.indexerId)); + useSelector(createIndexerIndexItemSelector(indexerId)); const { id, @@ -74,6 +75,10 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { fields.find((field) => field.name === 'torrentBaseSettings.packSeedTime') ?.value ?? undefined; + const preferMagnetUrl = + fields.find((field) => field.name === 'torrentBaseSettings.preferMagnetUrl') + ?.value ?? undefined; + const rssUrl = `${window.location.origin}${ window.Prowlarr.urlBase }/${id}/api?apikey=${encodeURIComponent( @@ -102,6 +107,10 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { setIsDeleteIndexerModalOpen(false); }, [setIsDeleteIndexerModalOpen]); + const checkInputCallback = useCallback(() => { + // Mock handler to satisfy `onChange` being required for `CheckInput`. + }, []); + const onSelectedChange = useCallback( ({ id, value, shiftKey }: SelectStateInputProps) => { selectDispatch({ @@ -147,12 +156,25 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { ); } + if (name === 'id') { + return ( + + + + ); + } + if (name === 'sortName') { return ( ); @@ -161,7 +183,7 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { if (name === 'privacy') { return ( - + ); } @@ -202,7 +224,7 @@ function IndexerIndexRow(props: IndexerIndexRowProps) { return ( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore ts(2739) - + {preferMagnetUrl === undefined ? null : ( + + )} + + ); + } + if (name === 'actions') { return ( ; isSelectMode: boolean; isSmallScreen: boolean; + onCloneIndexerPress(id: number): void; } const columnsSelector = createSelector( @@ -44,12 +46,8 @@ const columnsSelector = createSelector( (columns) => columns ); -const Row: React.FC> = ({ - index, - style, - data, -}) => { - const { items, sortKey, columns, isSelectMode } = data; +function Row({ index, style, data }: ListChildComponentProps) { + const { items, sortKey, columns, isSelectMode, onCloneIndexerPress } = data; if (index >= items.length) { return null; @@ -71,10 +69,11 @@ const Row: React.FC> = ({ sortKey={sortKey} columns={columns} isSelectMode={isSelectMode} + onCloneIndexerPress={onCloneIndexerPress} />
); -}; +} function getWindowScrollTopPosition() { return document.documentElement.scrollTop || document.body.scrollTop || 0; @@ -89,6 +88,7 @@ function IndexerIndexTable(props: IndexerIndexTableProps) { isSelectMode, isSmallScreen, scrollerRef, + onCloneIndexerPress, } = props; const columns = useSelector(columnsSelector); @@ -198,6 +198,7 @@ function IndexerIndexTable(props: IndexerIndexTableProps) { sortKey, columns, isSelectMode, + onCloneIndexerPress, }} > {Row} diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css b/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css index 90ad3b0e9..839cd49ff 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css +++ b/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css @@ -4,6 +4,12 @@ flex: 0 0 60px; } +.id { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 60px; +} + .sortName { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; @@ -16,7 +22,8 @@ .minimumSeeders, .seedRatio, .seedTime, -.packSeedTime { +.packSeedTime, +.preferMagnetUrl { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; flex: 0 0 90px; diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css.d.ts b/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css.d.ts index 94d39a9bb..020d61f27 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css.d.ts +++ b/frontend/src/Indexer/Index/Table/IndexerIndexTableHeader.css.d.ts @@ -5,8 +5,10 @@ interface CssExports { 'added': string; 'appProfileId': string; 'capabilities': string; + 'id': string; 'minimumSeeders': string; 'packSeedTime': string; + 'preferMagnetUrl': string; 'priority': string; 'privacy': string; 'protocol': string; diff --git a/frontend/src/Indexer/Index/Table/IndexerIndexTableOptions.tsx b/frontend/src/Indexer/Index/Table/IndexerIndexTableOptions.tsx index 6155929df..3aa087790 100644 --- a/frontend/src/Indexer/Index/Table/IndexerIndexTableOptions.tsx +++ b/frontend/src/Indexer/Index/Table/IndexerIndexTableOptions.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, useCallback } from 'react'; +import React, { useCallback } from 'react'; import { useSelector } from 'react-redux'; import FormGroup from 'Components/Form/FormGroup'; import FormInputGroup from 'Components/Form/FormInputGroup'; @@ -32,19 +32,17 @@ function IndexerIndexTableOptions(props: IndexerIndexTableOptionsProps) { ); return ( - - - {translate('ShowSearch')} + + {translate('ShowSearch')} - - - + + ); } diff --git a/frontend/src/Indexer/Index/Table/IndexerStatusCell.tsx b/frontend/src/Indexer/Index/Table/IndexerStatusCell.tsx index a3d694e9d..1a2350302 100644 --- a/frontend/src/Indexer/Index/Table/IndexerStatusCell.tsx +++ b/frontend/src/Indexer/Index/Table/IndexerStatusCell.tsx @@ -8,6 +8,30 @@ import translate from 'Utilities/String/translate'; import DisabledIndexerInfo from './DisabledIndexerInfo'; import styles from './IndexerStatusCell.css'; +function getIconKind(enabled: boolean, redirect: boolean) { + if (enabled) { + return redirect ? kinds.INFO : kinds.SUCCESS; + } + + return kinds.DEFAULT; +} + +function getIconName(enabled: boolean, redirect: boolean) { + if (enabled) { + return redirect ? icons.REDIRECT : icons.CHECK; + } + + return icons.BLOCKLIST; +} + +function getIconTooltip(enabled: boolean, redirect: boolean) { + if (enabled) { + return redirect ? translate('EnabledRedirected') : translate('Enabled'); + } + + return translate('Disabled'); +} + interface IndexerStatusCellProps { className: string; enabled: boolean; @@ -30,22 +54,14 @@ function IndexerStatusCell(props: IndexerStatusCellProps) { ...otherProps } = props; - const enableKind = redirect ? kinds.INFO : kinds.SUCCESS; - const enableIcon = redirect ? icons.REDIRECT : icons.CHECK; - const enableTitle = redirect - ? translate('EnabledRedirected') - : translate('Enabled'); - return ( - { - - } + {status ? ( + {translate(firstCharToUpper(privacy))} + + ); +} + +export default PrivacyLabel; diff --git a/frontend/src/Indexer/Index/Table/ProtocolLabel.css b/frontend/src/Indexer/Index/Table/ProtocolLabel.css index 110c7e01c..c94e383b1 100644 --- a/frontend/src/Indexer/Index/Table/ProtocolLabel.css +++ b/frontend/src/Indexer/Index/Table/ProtocolLabel.css @@ -11,3 +11,7 @@ border-color: var(--usenetColor); background-color: var(--usenetColor); } + +.unknown { + composes: label from '~Components/Label.css'; +} diff --git a/frontend/src/Indexer/Index/Table/ProtocolLabel.css.d.ts b/frontend/src/Indexer/Index/Table/ProtocolLabel.css.d.ts index f3b389e3d..ba0cb260d 100644 --- a/frontend/src/Indexer/Index/Table/ProtocolLabel.css.d.ts +++ b/frontend/src/Indexer/Index/Table/ProtocolLabel.css.d.ts @@ -2,6 +2,7 @@ // Please do not change this file! interface CssExports { 'torrent': string; + 'unknown': string; 'usenet': string; } export const cssExports: CssExports; diff --git a/frontend/src/Indexer/Index/Table/ProtocolLabel.tsx b/frontend/src/Indexer/Index/Table/ProtocolLabel.tsx index 08009109e..c1824452a 100644 --- a/frontend/src/Indexer/Index/Table/ProtocolLabel.tsx +++ b/frontend/src/Indexer/Index/Table/ProtocolLabel.tsx @@ -1,18 +1,15 @@ import React from 'react'; import Label from 'Components/Label'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; import styles from './ProtocolLabel.css'; interface ProtocolLabelProps { - protocol: string; + protocol: DownloadProtocol; } -function ProtocolLabel(props: ProtocolLabelProps) { - const { protocol } = props; - +function ProtocolLabel({ protocol }: ProtocolLabelProps) { const protocolName = protocol === 'usenet' ? 'nzb' : protocol; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore ts(7053) return ; } diff --git a/frontend/src/Indexer/Indexer.ts b/frontend/src/Indexer/Indexer.ts index 96a67f446..c363d472c 100644 --- a/frontend/src/Indexer/Indexer.ts +++ b/frontend/src/Indexer/Indexer.ts @@ -1,4 +1,5 @@ import ModelBase from 'App/ModelBase'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; export interface IndexerStatus extends ModelBase { disabledTill: Date; @@ -24,6 +25,8 @@ export interface IndexerCapabilities extends ModelBase { categories: IndexerCategory[]; } +export type IndexerPrivacy = 'public' | 'semiPrivate' | 'private'; + export interface IndexerField extends ModelBase { order: number; name: string; @@ -36,6 +39,7 @@ export interface IndexerField extends ModelBase { interface Indexer extends ModelBase { name: string; + definitionName: string; description: string; encoding: string; language: string; @@ -46,8 +50,8 @@ interface Indexer extends ModelBase { supportsSearch: boolean; supportsRedirect: boolean; supportsPagination: boolean; - protocol: string; - privacy: string; + protocol: DownloadProtocol; + privacy: IndexerPrivacy; priority: number; fields: IndexerField[]; tags: number[]; diff --git a/frontend/src/Indexer/IndexerTitleLink.tsx b/frontend/src/Indexer/IndexerTitleLink.tsx index 82f294d69..be6e32db3 100644 --- a/frontend/src/Indexer/IndexerTitleLink.tsx +++ b/frontend/src/Indexer/IndexerTitleLink.tsx @@ -1,16 +1,16 @@ -import PropTypes from 'prop-types'; import React, { useCallback, useState } from 'react'; import Link from 'Components/Link/Link'; import IndexerInfoModal from './Info/IndexerInfoModal'; import styles from './IndexerTitleLink.css'; interface IndexerTitleLinkProps { - indexerName: string; indexerId: number; + title: string; + onCloneIndexerPress(id: number): void; } function IndexerTitleLink(props: IndexerTitleLinkProps) { - const { indexerName, indexerId } = props; + const { title, indexerId, onCloneIndexerPress } = props; const [isIndexerInfoModalOpen, setIsIndexerInfoModalOpen] = useState(false); @@ -25,20 +25,17 @@ function IndexerTitleLink(props: IndexerTitleLinkProps) { return (
- {indexerName} + {title}
); } -IndexerTitleLink.propTypes = { - indexerName: PropTypes.string.isRequired, -}; - export default IndexerTitleLink; diff --git a/frontend/src/Indexer/Info/History/IndexerHistory.tsx b/frontend/src/Indexer/Info/History/IndexerHistory.tsx new file mode 100644 index 000000000..8cf62bde8 --- /dev/null +++ b/frontend/src/Indexer/Info/History/IndexerHistory.tsx @@ -0,0 +1,139 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import { IndexerHistoryAppState } from 'App/State/IndexerAppState'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import { kinds } from 'Helpers/Props'; +import Indexer from 'Indexer/Indexer'; +import { + clearIndexerHistory, + fetchIndexerHistory, +} from 'Store/Actions/indexerHistoryActions'; +import { createIndexerSelectorForHook } from 'Store/Selectors/createIndexerSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import translate from 'Utilities/String/translate'; +import IndexerHistoryRow from './IndexerHistoryRow'; + +const columns = [ + { + name: 'eventType', + isVisible: true, + }, + { + name: 'query', + label: () => translate('Query'), + isVisible: true, + }, + { + name: 'parameters', + label: () => translate('Parameters'), + isVisible: true, + }, + { + name: 'date', + label: () => translate('Date'), + isVisible: true, + }, + { + name: 'source', + label: () => translate('Source'), + isVisible: true, + }, + { + name: 'details', + label: () => translate('Details'), + isVisible: true, + }, +]; + +function createIndexerHistorySelector() { + return createSelector( + (state: AppState) => state.indexerHistory, + createUISettingsSelector(), + (state: AppState) => state.history.pageSize, + (indexerHistory: IndexerHistoryAppState, uiSettings, pageSize) => { + return { + ...indexerHistory, + shortDateFormat: uiSettings.shortDateFormat, + timeFormat: uiSettings.timeFormat, + pageSize, + }; + } + ); +} + +interface IndexerHistoryProps { + indexerId: number; +} + +function IndexerHistory(props: IndexerHistoryProps) { + const { + isFetching, + isPopulated, + error, + items, + shortDateFormat, + timeFormat, + pageSize, + } = useSelector(createIndexerHistorySelector()); + + const indexer = useSelector( + createIndexerSelectorForHook(props.indexerId) + ) as Indexer; + + const dispatch = useDispatch(); + + useEffect(() => { + dispatch( + fetchIndexerHistory({ indexerId: props.indexerId, limit: pageSize }) + ); + + return () => { + dispatch(clearIndexerHistory()); + }; + }, [props, pageSize, dispatch]); + + const hasItems = !!items.length; + + if (isFetching) { + return ; + } + + if (!isFetching && !!error) { + return ( + {translate('IndexerHistoryLoadError')} + ); + } + + if (isPopulated && !hasItems && !error) { + return {translate('NoIndexerHistory')}; + } + + if (isPopulated && hasItems && !error) { + return ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ); + } + + return null; +} + +export default IndexerHistory; diff --git a/frontend/src/Indexer/Info/History/IndexerHistoryRow.css b/frontend/src/Indexer/Info/History/IndexerHistoryRow.css new file mode 100644 index 000000000..d8bba1fe7 --- /dev/null +++ b/frontend/src/Indexer/Info/History/IndexerHistoryRow.css @@ -0,0 +1,23 @@ +.query { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 120px; +} + +.elapsedTime, +.source { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 150px; +} + +.details { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 70px; +} + +.parametersContent { + display: flex; + flex-wrap: wrap; +} diff --git a/frontend/src/Indexer/Info/History/IndexerHistoryRow.css.d.ts b/frontend/src/Indexer/Info/History/IndexerHistoryRow.css.d.ts new file mode 100644 index 000000000..28da0e31c --- /dev/null +++ b/frontend/src/Indexer/Info/History/IndexerHistoryRow.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'details': string; + 'elapsedTime': string; + 'parametersContent': string; + 'query': string; + 'source': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Indexer/Info/History/IndexerHistoryRow.tsx b/frontend/src/Indexer/Info/History/IndexerHistoryRow.tsx new file mode 100644 index 000000000..28d45654c --- /dev/null +++ b/frontend/src/Indexer/Info/History/IndexerHistoryRow.tsx @@ -0,0 +1,106 @@ +import React, { useCallback, useState } from 'react'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRow from 'Components/Table/TableRow'; +import { icons } from 'Helpers/Props'; +import HistoryDetailsModal from 'History/Details/HistoryDetailsModal'; +import HistoryEventTypeCell from 'History/HistoryEventTypeCell'; +import { historyParameters } from 'History/HistoryRow'; +import HistoryRowParameter from 'History/HistoryRowParameter'; +import Indexer from 'Indexer/Indexer'; +import { HistoryData } from 'typings/History'; +import translate from 'Utilities/String/translate'; +import styles from './IndexerHistoryRow.css'; + +interface IndexerHistoryRowProps { + data: HistoryData; + date: string; + eventType: string; + successful: boolean; + indexer: Indexer; + shortDateFormat: string; + timeFormat: string; +} + +function IndexerHistoryRow(props: IndexerHistoryRowProps) { + const { + data, + date, + eventType, + successful, + indexer, + shortDateFormat, + timeFormat, + } = props; + + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const onDetailsModalPress = useCallback(() => { + setIsDetailsModalOpen(true); + }, [setIsDetailsModalOpen]); + + const onDetailsModalClose = useCallback(() => { + setIsDetailsModalOpen(false); + }, [setIsDetailsModalOpen]); + + const parameters = historyParameters.filter( + (parameter) => + parameter.key in data && data[parameter.key as keyof HistoryData] + ); + + return ( + + + + {data.query} + + +
+ {parameters.map((parameter) => { + return ( + + ); + })} +
+
+ + + + + {data.source ? data.source : null} + + + + + + + +
+ ); +} + +export default IndexerHistoryRow; diff --git a/frontend/src/Indexer/Info/IndexerInfoModal.tsx b/frontend/src/Indexer/Info/IndexerInfoModal.tsx index df2ead86d..c15af5247 100644 --- a/frontend/src/Indexer/Info/IndexerInfoModal.tsx +++ b/frontend/src/Indexer/Info/IndexerInfoModal.tsx @@ -7,16 +7,18 @@ interface IndexerInfoModalProps { isOpen: boolean; indexerId: number; onModalClose(): void; + onCloneIndexerPress(id: number): void; } function IndexerInfoModal(props: IndexerInfoModalProps) { - const { isOpen, onModalClose, indexerId } = props; + const { isOpen, indexerId, onModalClose, onCloneIndexerPress } = props; return ( - + ); diff --git a/frontend/src/Indexer/Info/IndexerInfoModalContent.css b/frontend/src/Indexer/Info/IndexerInfoModalContent.css index 84fe0a573..9e8b59a88 100644 --- a/frontend/src/Indexer/Info/IndexerInfoModalContent.css +++ b/frontend/src/Indexer/Info/IndexerInfoModalContent.css @@ -9,3 +9,41 @@ margin-right: auto; } + +.tabs { + margin-top: -32px; +} + +.tabList { + margin: 0; + padding: 0; +} + +.tab { + position: relative; + bottom: -1px; + display: inline-block; + padding: 6px 12px; + border: 1px solid transparent; + border-top: none; + list-style: none; + cursor: pointer; +} + +.selectedTab { + border-color: var(--borderColor); + border-radius: 0 0 5px 5px; + background-color: rgba(239, 239, 239, 0.4); + color: var(--black); +} + +.tabContent { + margin-top: 20px; +} + +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + diff --git a/frontend/src/Indexer/Info/IndexerInfoModalContent.css.d.ts b/frontend/src/Indexer/Info/IndexerInfoModalContent.css.d.ts index 48f09127f..c9f832fd9 100644 --- a/frontend/src/Indexer/Info/IndexerInfoModalContent.css.d.ts +++ b/frontend/src/Indexer/Info/IndexerInfoModalContent.css.d.ts @@ -3,6 +3,12 @@ interface CssExports { 'deleteButton': string; 'description': string; + 'modalFooter': string; + 'selectedTab': string; + 'tab': string; + 'tabContent': string; + 'tabList': string; + 'tabs': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Indexer/Info/IndexerInfoModalContent.tsx b/frontend/src/Indexer/Info/IndexerInfoModalContent.tsx index 8cb10993a..344d91a98 100644 --- a/frontend/src/Indexer/Info/IndexerInfoModalContent.tsx +++ b/frontend/src/Indexer/Info/IndexerInfoModalContent.tsx @@ -1,7 +1,7 @@ import { uniqBy } from 'lodash'; import React, { useCallback, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; +import { Tab, TabList, TabPanel, Tabs } from 'react-tabs'; +import Alert from 'Components/Alert'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; @@ -22,31 +22,23 @@ import TagListConnector from 'Components/TagListConnector'; import { kinds } from 'Helpers/Props'; import DeleteIndexerModal from 'Indexer/Delete/DeleteIndexerModal'; import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector'; -import Indexer from 'Indexer/Indexer'; -import { createIndexerSelectorForHook } from 'Store/Selectors/createIndexerSelector'; +import PrivacyLabel from 'Indexer/Index/Table/PrivacyLabel'; +import Indexer, { IndexerCapabilities } from 'Indexer/Indexer'; +import useIndexer from 'Indexer/useIndexer'; import translate from 'Utilities/String/translate'; +import IndexerHistory from './History/IndexerHistory'; import styles from './IndexerInfoModalContent.css'; -function createIndexerInfoItemSelector(indexerId: number) { - return createSelector( - createIndexerSelectorForHook(indexerId), - (indexer?: Indexer) => { - return { - indexer, - }; - } - ); -} +const TABS = ['details', 'categories', 'history', 'stats']; interface IndexerInfoModalContentProps { indexerId: number; onModalClose(): void; + onCloneIndexerPress(id: number): void; } function IndexerInfoModalContent(props: IndexerInfoModalContentProps) { - const { indexer } = useSelector( - createIndexerInfoItemSelector(props.indexerId) - ); + const { indexerId, onModalClose, onCloneIndexerPress } = props; const { id, @@ -58,267 +50,329 @@ function IndexerInfoModalContent(props: IndexerInfoModalContentProps) { fields, tags, protocol, - capabilities, - } = indexer as Indexer; + privacy, + capabilities = {} as IndexerCapabilities, + } = useIndexer(indexerId) as Indexer; - const { onModalClose } = props; + const [selectedTab, setSelectedTab] = useState(TABS[0]); + const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false); + const [isDeleteIndexerModalOpen, setIsDeleteIndexerModalOpen] = + useState(false); + + const handleTabSelect = useCallback( + (selectedIndex: number) => { + const selectedTab = TABS[selectedIndex]; + setSelectedTab(selectedTab); + }, + [setSelectedTab] + ); + + const handleEditIndexerPress = useCallback(() => { + setIsEditIndexerModalOpen(true); + }, [setIsEditIndexerModalOpen]); + + const handleEditIndexerModalClose = useCallback(() => { + setIsEditIndexerModalOpen(false); + }, [setIsEditIndexerModalOpen]); + + const handleDeleteIndexerPress = useCallback(() => { + setIsEditIndexerModalOpen(false); + setIsDeleteIndexerModalOpen(true); + }, [setIsDeleteIndexerModalOpen]); + + const handleDeleteIndexerModalClose = useCallback(() => { + setIsDeleteIndexerModalOpen(false); + onModalClose(); + }, [setIsDeleteIndexerModalOpen, onModalClose]); + + const handleCloneIndexerPressWrapper = useCallback(() => { + onCloneIndexerPress(id); + onModalClose(); + }, [id, onCloneIndexerPress, onModalClose]); const baseUrl = fields.find((field) => field.name === 'baseUrl')?.value ?? (Array.isArray(indexerUrls) ? indexerUrls[0] : undefined); + const indexerUrl = baseUrl?.replace(/(:\/\/)api\./, '$1'); + const vipExpiration = fields.find((field) => field.name === 'vipExpiration')?.value ?? undefined; - const [isEditIndexerModalOpen, setIsEditIndexerModalOpen] = useState(false); - const [isDeleteIndexerModalOpen, setIsDeleteIndexerModalOpen] = - useState(false); - - const onEditIndexerPress = useCallback(() => { - setIsEditIndexerModalOpen(true); - }, [setIsEditIndexerModalOpen]); - - const onEditIndexerModalClose = useCallback(() => { - setIsEditIndexerModalOpen(false); - }, [setIsEditIndexerModalOpen]); - - const onDeleteIndexerPress = useCallback(() => { - setIsEditIndexerModalOpen(false); - setIsDeleteIndexerModalOpen(true); - }, [setIsDeleteIndexerModalOpen]); - - const onDeleteIndexerModalClose = useCallback(() => { - setIsDeleteIndexerModalOpen(false); - onModalClose(); - }, [setIsDeleteIndexerModalOpen, onModalClose]); - return ( {`${name}`} -
-
- - - - - - {vipExpiration ? ( - - ) : null} - - {translate('IndexerSite')} - - - {baseUrl ? ( - - {baseUrl.replace(/(:\/\/)api\./, '$1')} - - ) : ( - '-' - )} - - - {protocol === 'usenet' - ? translate('NewznabUrl') - : translate('TorznabUrl')} - - - {`${window.location.origin}${window.Prowlarr.urlBase}/${id}/api`} - - {tags.length > 0 ? ( - <> - - {translate('Tags')} - - - - - - ) : null} - -
-
+ + + + {translate('Details')} + -
-
- - - - {capabilities.searchParams[0]} - - ) - } - /> - { - return ( - - ); - }) - } - /> - { - return ( - - ); - }) - } - /> - { - return ( - - ); - }) - } - /> - { - return ( - - ); - }) - } - /> - -
-
+ + {translate('Categories')} + - {capabilities?.categories?.length > 0 ? ( -
- - {uniqBy(capabilities.categories, 'id') - .sort((a, b) => a.id - b.id) - .map((category) => { - return ( - - - {category.id} - {category.name} - - {category?.subCategories?.length > 0 - ? uniqBy(category.subCategories, 'id') - .sort((a, b) => a.id - b.id) - .map((subCategory) => { + + {translate('History')} + + + +
+
+
+ + + + + + : '-'} + /> + {vipExpiration ? ( + + ) : null} + + {translate('IndexerSite')} + + + {indexerUrl ? ( + {indexerUrl} + ) : ( + '-' + )} + + + {protocol === 'usenet' + ? translate('NewznabUrl') + : translate('TorznabUrl')} + + + {`${window.location.origin}${window.Prowlarr.urlBase}/${id}/api`} + + {tags.length > 0 ? ( + <> + + {translate('Tags')} + + + + + + ) : null} + +
+
+ +
+
+ + + 0 ? ( + + ) : ( + translate('NotSupported') + ) + } + /> + 0 + ? capabilities.tvSearchParams.map((p) => { return ( - - {subCategory.id} - - {subCategory.name} - - + ); }) - : null} - - ); - })} -
-
- ) : null} + : translate('NotSupported') + } + /> + 0 + ? capabilities.movieSearchParams.map((p) => { + return ( + + ); + }) + : translate('NotSupported') + } + /> + 0 + ? capabilities.bookSearchParams.map((p) => { + return ( + + ); + }) + : translate('NotSupported') + } + /> + 0 + ? capabilities.musicSearchParams.map((p) => { + return ( + + ); + }) + : translate('NotSupported') + } + /> + +
+ + + + +
+ {capabilities?.categories?.length > 0 ? ( +
+ + {uniqBy(capabilities.categories, 'id') + .sort((a, b) => a.id - b.id) + .map((category) => { + return ( + + + {category.id} + {category.name} + + {category?.subCategories?.length > 0 + ? uniqBy(category.subCategories, 'id') + .sort((a, b) => a.id - b.id) + .map((subCategory) => { + return ( + + + {subCategory.id} + + + {subCategory.name} + + + ); + }) + : null} + + ); + })} +
+
+ ) : ( + + {translate('NoIndexerCategories')} + + )} +
+
+ +
+ +
+
+ - - - - + +
+ + +
+
+ + +
); diff --git a/frontend/src/Indexer/NoIndexer.css b/frontend/src/Indexer/NoIndexer.css index 38a01f391..4ad534de3 100644 --- a/frontend/src/Indexer/NoIndexer.css +++ b/frontend/src/Indexer/NoIndexer.css @@ -1,4 +1,6 @@ .message { + composes: alert from '~Components/Alert.css'; + margin-top: 10px; margin-bottom: 30px; text-align: center; diff --git a/frontend/src/Indexer/NoIndexer.tsx b/frontend/src/Indexer/NoIndexer.tsx index 75650cad6..bf5afa1fe 100644 --- a/frontend/src/Indexer/NoIndexer.tsx +++ b/frontend/src/Indexer/NoIndexer.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import Alert from 'Components/Alert'; import Button from 'Components/Link/Button'; import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; @@ -14,11 +15,9 @@ function NoIndexer(props: NoIndexerProps) { if (totalItems > 0) { return ( -
-
- {translate('AllIndexersHiddenDueToFilter')} -
-
+ + {translate('AllIndexersHiddenDueToFilter')} + ); } @@ -29,7 +28,7 @@ function NoIndexer(props: NoIndexerProps) {
-
diff --git a/frontend/src/Indexer/Stats/IndexerStats.css b/frontend/src/Indexer/Stats/IndexerStats.css index 249dcc448..975f5ddae 100644 --- a/frontend/src/Indexer/Stats/IndexerStats.css +++ b/frontend/src/Indexer/Stats/IndexerStats.css @@ -1,22 +1,52 @@ .fullWidthChart { display: inline-block; - padding: 15px 25px; width: 100%; - height: 300px; } .halfWidthChart { display: inline-block; - padding: 15px 25px; width: 50%; +} + +.quarterWidthChart { + display: inline-block; + width: 25%; +} + +.chartContainer { + margin: 5px; + padding: 15px 25px; height: 300px; + border-radius: 10px; + background-color: var(--chartBackgroundColor); +} + +.statContainer { + margin: 5px; + padding: 15px 25px; + height: 150px; + border-radius: 10px; + background-color: var(--chartBackgroundColor); +} + +.statTitle { + font-weight: bold; + font-size: 14px; +} + +.stat { + font-weight: bold; + font-size: 60px; } @media only screen and (max-width: $breakpointSmall) { .halfWidthChart { display: inline-block; - padding: 15px 25px; width: 100%; - height: 300px; + } + + .quarterWidthChart { + display: inline-block; + width: 100%; } } diff --git a/frontend/src/Indexer/Stats/IndexerStats.css.d.ts b/frontend/src/Indexer/Stats/IndexerStats.css.d.ts index ce2364202..e2aae98c6 100644 --- a/frontend/src/Indexer/Stats/IndexerStats.css.d.ts +++ b/frontend/src/Indexer/Stats/IndexerStats.css.d.ts @@ -1,8 +1,13 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { + 'chartContainer': string; 'fullWidthChart': string; 'halfWidthChart': string; + 'quarterWidthChart': string; + 'stat': string; + 'statContainer': string; + 'statTitle': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Indexer/Stats/IndexerStats.tsx b/frontend/src/Indexer/Stats/IndexerStats.tsx index 0a61fc8fc..bccd49cbe 100644 --- a/frontend/src/Indexer/Stats/IndexerStats.tsx +++ b/frontend/src/Indexer/Stats/IndexerStats.tsx @@ -8,43 +8,54 @@ import BarChart from 'Components/Chart/BarChart'; import DoughnutChart from 'Components/Chart/DoughnutChart'; import StackedBarChart from 'Components/Chart/StackedBarChart'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import { align, kinds } from 'Helpers/Props'; +import { align, icons, kinds } from 'Helpers/Props'; import { fetchIndexerStats, setIndexerStatsFilter, } from 'Store/Actions/indexerStatsActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import { IndexerStatsHost, IndexerStatsIndexer, IndexerStatsUserAgent, } from 'typings/IndexerStats'; +import abbreviateNumber from 'Utilities/Number/abbreviateNumber'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; import translate from 'Utilities/String/translate'; -import IndexerStatsFilterMenu from './IndexerStatsFilterMenu'; +import IndexerStatsFilterModal from './IndexerStatsFilterModal'; import styles from './IndexerStats.css'; function getAverageResponseTimeData(indexerStats: IndexerStatsIndexer[]) { - const data = indexerStats.map((indexer) => { - return { - label: indexer.indexerName, - value: indexer.averageResponseTime, - }; - }); + const statistics = [...indexerStats].sort((a, b) => + a.averageResponseTime === b.averageResponseTime + ? b.averageGrabResponseTime - a.averageGrabResponseTime + : b.averageResponseTime - a.averageResponseTime + ); - data.sort((a, b) => { - return b.value - a.value; - }); - - return data; + return { + labels: statistics.map((indexer) => indexer.indexerName), + datasets: [ + { + label: translate('AverageQueries'), + data: statistics.map((indexer) => indexer.averageResponseTime), + }, + { + label: translate('AverageGrabs'), + data: statistics.map((indexer) => indexer.averageGrabResponseTime), + }, + ], + }; } function getFailureRateData(indexerStats: IndexerStatsIndexer[]) { - const data = indexerStats.map((indexer) => { - return { + const data = [...indexerStats] + .map((indexer) => ({ label: indexer.indexerName, value: (indexer.numberOfFailedQueries + @@ -55,109 +66,102 @@ function getFailureRateData(indexerStats: IndexerStatsIndexer[]) { indexer.numberOfRssQueries + indexer.numberOfAuthQueries + indexer.numberOfGrabs), - }; - }); + })) + .filter((s) => s.value > 0); - data.sort((a, b) => { - return b.value - a.value; - }); + data.sort((a, b) => b.value - a.value); return data; } function getTotalRequestsData(indexerStats: IndexerStatsIndexer[]) { - const data = { - labels: indexerStats.map((indexer) => indexer.indexerName), + const statistics = [...indexerStats] + .filter( + (s) => + s.numberOfQueries > 0 || + s.numberOfRssQueries > 0 || + s.numberOfAuthQueries > 0 + ) + .sort( + (a, b) => + b.numberOfQueries + + b.numberOfRssQueries + + b.numberOfAuthQueries - + (a.numberOfQueries + a.numberOfRssQueries + a.numberOfAuthQueries) + ); + + return { + labels: statistics.map((indexer) => indexer.indexerName), datasets: [ { label: translate('SearchQueries'), - data: indexerStats.map((indexer) => indexer.numberOfQueries), + data: statistics.map((indexer) => indexer.numberOfQueries), }, { label: translate('RssQueries'), - data: indexerStats.map((indexer) => indexer.numberOfRssQueries), + data: statistics.map((indexer) => indexer.numberOfRssQueries), }, { label: translate('AuthQueries'), - data: indexerStats.map((indexer) => indexer.numberOfAuthQueries), + data: statistics.map((indexer) => indexer.numberOfAuthQueries), }, ], }; - - return data; } function getNumberGrabsData(indexerStats: IndexerStatsIndexer[]) { - const data = indexerStats.map((indexer) => { - return { + const data = [...indexerStats] + .map((indexer) => ({ label: indexer.indexerName, value: indexer.numberOfGrabs - indexer.numberOfFailedGrabs, - }; - }); + })) + .filter((s) => s.value > 0); - data.sort((a, b) => { - return b.value - a.value; - }); + data.sort((a, b) => b.value - a.value); return data; } function getUserAgentGrabsData(indexerStats: IndexerStatsUserAgent[]) { - const data = indexerStats.map((indexer) => { - return { - label: indexer.userAgent ? indexer.userAgent : 'Other', - value: indexer.numberOfGrabs, - }; - }); + const data = indexerStats.map((indexer) => ({ + label: indexer.userAgent ? indexer.userAgent : 'Other', + value: indexer.numberOfGrabs, + })); - data.sort((a, b) => { - return b.value - a.value; - }); + data.sort((a, b) => b.value - a.value); return data; } function getUserAgentQueryData(indexerStats: IndexerStatsUserAgent[]) { - const data = indexerStats.map((indexer) => { - return { - label: indexer.userAgent ? indexer.userAgent : 'Other', - value: indexer.numberOfQueries, - }; - }); + const data = indexerStats.map((indexer) => ({ + label: indexer.userAgent ? indexer.userAgent : 'Other', + value: indexer.numberOfQueries, + })); - data.sort((a, b) => { - return b.value - a.value; - }); + data.sort((a, b) => b.value - a.value); return data; } function getHostGrabsData(indexerStats: IndexerStatsHost[]) { - const data = indexerStats.map((indexer) => { - return { - label: indexer.host ? indexer.host : 'Other', - value: indexer.numberOfGrabs, - }; - }); + const data = indexerStats.map((indexer) => ({ + label: indexer.host ? indexer.host : 'Other', + value: indexer.numberOfGrabs, + })); - data.sort((a, b) => { - return b.value - a.value; - }); + data.sort((a, b) => b.value - a.value); return data; } function getHostQueryData(indexerStats: IndexerStatsHost[]) { - const data = indexerStats.map((indexer) => { - return { - label: indexer.host ? indexer.host : 'Other', - value: indexer.numberOfQueries, - }; - }); + const data = indexerStats.map((indexer) => ({ + label: indexer.host ? indexer.host : 'Other', + value: indexer.numberOfQueries, + })); - data.sort((a, b) => { - return b.value - a.value; - }); + data.sort((a, b) => b.value - a.value); return data; } @@ -165,21 +169,36 @@ function getHostQueryData(indexerStats: IndexerStatsHost[]) { const indexerStatsSelector = () => { return createSelector( (state: AppState) => state.indexerStats, - (indexerStats: IndexerStatsAppState) => { - return indexerStats; + createCustomFiltersSelector('indexerStats'), + (indexerStats: IndexerStatsAppState, customFilters) => { + return { + ...indexerStats, + customFilters, + }; } ); }; function IndexerStats() { - const { isFetching, isPopulated, item, error, filters, selectedFilterKey } = - useSelector(indexerStatsSelector()); + const { + isFetching, + isPopulated, + item, + error, + filters, + customFilters, + selectedFilterKey, + } = useSelector(indexerStatsSelector()); const dispatch = useDispatch(); useEffect(() => { dispatch(fetchIndexerStats()); }, [dispatch]); + const onRefreshPress = useCallback(() => { + dispatch(fetchIndexerStats()); + }, [dispatch]); + const onFilterSelect = useCallback( (value: string) => { dispatch(setIndexerStatsFilter({ selectedFilterKey: value })); @@ -188,16 +207,43 @@ function IndexerStats() { ); const isLoaded = !error && isPopulated; + const indexerCount = item.indexers?.length ?? 0; + const userAgentCount = item.userAgents?.length ?? 0; + const queryCount = + item.indexers?.reduce((total, indexer) => { + return ( + total + + indexer.numberOfQueries + + indexer.numberOfRssQueries + + indexer.numberOfAuthQueries + ); + }, 0) ?? 0; + const grabCount = + item.indexers?.reduce((total, indexer) => { + return total + indexer.numberOfGrabs; + }, 0) ?? 0; return ( - + + + + + - @@ -212,58 +258,110 @@ function IndexerStats() { {isLoaded && (
-
- +
+
+
+ {translate('ActiveIndexers')} +
+
{indexerCount}
+
+
+
+
+
+ {translate('TotalQueries')} +
+
+ {abbreviateNumber(queryCount)} +
+
+
+
+
+
+ {translate('TotalGrabs')} +
+
{abbreviateNumber(grabCount)}
+
+
+
+
+
+ {translate('ActiveApps')} +
+
{userAgentCount}
+
- +
+ +
+
+
+
+ +
- +
+ +
- +
+ +
- +
+ +
- +
+ +
- +
+ +
- +
+ +
)} diff --git a/frontend/src/Indexer/Stats/IndexerStatsFilterMenu.tsx b/frontend/src/Indexer/Stats/IndexerStatsFilterMenu.tsx deleted file mode 100644 index 7b30be4c3..000000000 --- a/frontend/src/Indexer/Stats/IndexerStatsFilterMenu.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import { align } from 'Helpers/Props'; - -interface IndexerStatsFilterMenuProps { - selectedFilterKey: string | number; - filters: object[]; - isDisabled: boolean; - onFilterSelect(filterName: string): unknown; -} - -function IndexerStatsFilterMenu(props: IndexerStatsFilterMenuProps) { - const { selectedFilterKey, filters, isDisabled, onFilterSelect } = props; - - return ( - - ); -} - -export default IndexerStatsFilterMenu; diff --git a/frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx b/frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx new file mode 100644 index 000000000..6e3a49dfb --- /dev/null +++ b/frontend/src/Indexer/Stats/IndexerStatsFilterModal.tsx @@ -0,0 +1,56 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setIndexerStatsFilter } from 'Store/Actions/indexerStatsActions'; + +function createIndexerStatsSelector() { + return createSelector( + (state: AppState) => state.indexerStats.item, + (indexerStats) => { + return indexerStats; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.indexerStats.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface IndexerStatsFilterModalProps { + isOpen: boolean; +} + +export default function IndexerStatsFilterModal( + props: IndexerStatsFilterModalProps +) { + const sectionItems = [useSelector(createIndexerStatsSelector())]; + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'indexerStats'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setIndexerStatsFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Indexer/useIndexer.ts b/frontend/src/Indexer/useIndexer.ts new file mode 100644 index 000000000..a1b2ffa9d --- /dev/null +++ b/frontend/src/Indexer/useIndexer.ts @@ -0,0 +1,19 @@ +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +export function createIndexerSelector(indexerId?: number) { + return createSelector( + (state: AppState) => state.indexers.itemMap, + (state: AppState) => state.indexers.items, + (itemMap, allIndexers) => { + return indexerId ? allIndexers[itemMap[indexerId]] : undefined; + } + ); +} + +function useIndexer(indexerId?: number) { + return useSelector(createIndexerSelector(indexerId)); +} + +export default useIndexer; diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.css b/frontend/src/Search/Mobile/SearchIndexOverview.css index 4e184bd0a..e29ff1ef9 100644 --- a/frontend/src/Search/Mobile/SearchIndexOverview.css +++ b/frontend/src/Search/Mobile/SearchIndexOverview.css @@ -47,3 +47,42 @@ $hoverScale: 1.05; right: 0; white-space: nowrap; } + +.downloadLink { + composes: link from '~Components/Link/Link.css'; + + margin: 0 2px; + width: 22px; + color: var(--textColor); + text-align: center; +} + +.manualDownloadContent { + position: relative; + display: inline-block; + margin: 0 2px; + width: 22px; + height: 20.39px; + vertical-align: middle; + line-height: 20.39px; + + &:hover { + color: var(--iconButtonHoverColor); + } +} + +.interactiveIcon { + position: absolute; + top: 4px; + left: 0; + /* width: 100%; */ + text-align: center; +} + +.downloadIcon { + position: absolute; + top: 7px; + left: 8px; + /* width: 100%; */ + text-align: center; +} diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.css.d.ts b/frontend/src/Search/Mobile/SearchIndexOverview.css.d.ts index 266cf7fca..68256eb25 100644 --- a/frontend/src/Search/Mobile/SearchIndexOverview.css.d.ts +++ b/frontend/src/Search/Mobile/SearchIndexOverview.css.d.ts @@ -4,9 +4,13 @@ interface CssExports { 'actions': string; 'container': string; 'content': string; + 'downloadIcon': string; + 'downloadLink': string; 'indexerRow': string; 'info': string; 'infoRow': string; + 'interactiveIcon': string; + 'manualDownloadContent': string; 'title': string; 'titleRow': string; } diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.js b/frontend/src/Search/Mobile/SearchIndexOverview.js deleted file mode 100644 index 1a14ae66c..000000000 --- a/frontend/src/Search/Mobile/SearchIndexOverview.js +++ /dev/null @@ -1,234 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import TextTruncate from 'react-text-truncate'; -import Label from 'Components/Label'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import { icons, kinds } from 'Helpers/Props'; -import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; -import CategoryLabel from 'Search/Table/CategoryLabel'; -import Peers from 'Search/Table/Peers'; -import dimensions from 'Styles/Variables/dimensions'; -import formatAge from 'Utilities/Number/formatAge'; -import formatBytes from 'Utilities/Number/formatBytes'; -import titleCase from 'Utilities/String/titleCase'; -import translate from 'Utilities/String/translate'; -import styles from './SearchIndexOverview.css'; - -const columnPadding = parseInt(dimensions.movieIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen); - -function getContentHeight(rowHeight, isSmallScreen) { - const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; - - return rowHeight - padding; -} - -function getDownloadIcon(isGrabbing, isGrabbed, grabError) { - if (isGrabbing) { - return icons.SPINNER; - } else if (isGrabbed) { - return icons.DOWNLOADING; - } else if (grabError) { - return icons.DOWNLOADING; - } - - return icons.DOWNLOAD; -} - -function getDownloadKind(isGrabbed, grabError) { - if (isGrabbed) { - return kinds.SUCCESS; - } - - if (grabError) { - return kinds.DANGER; - } - - return kinds.DEFAULT; -} - -function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { - if (isGrabbing) { - return ''; - } else if (isGrabbed) { - return translate('AddedToDownloadClient'); - } else if (grabError) { - return grabError; - } - - return translate('AddToDownloadClient'); -} - -class SearchIndexOverview extends Component { - - // - // Listeners - - onGrabPress = () => { - const { - guid, - indexerId, - onGrabPress - } = this.props; - - onGrabPress({ - guid, - indexerId - }); - }; - - // - // Render - - render() { - const { - title, - infoUrl, - protocol, - downloadUrl, - magnetUrl, - categories, - seeders, - leechers, - indexerFlags, - size, - age, - ageHours, - ageMinutes, - indexer, - rowHeight, - isSmallScreen, - isGrabbed, - isGrabbing, - grabError - } = this.props; - - const contentHeight = getContentHeight(rowHeight, isSmallScreen); - - return ( -
-
-
-
-
- - - -
- -
- - - { - downloadUrl || magnetUrl ? - : - null - } -
-
-
- {indexer} -
-
- - - { - protocol === 'torrent' && - - } - - - - - - - - { - indexerFlags.length ? - indexerFlags - .sort((a, b) => a.localeCompare(b)) - .map((flag, index) => { - return ( - - ); - }) : - null - } -
-
-
-
- ); - } -} - -SearchIndexOverview.propTypes = { - guid: PropTypes.string.isRequired, - categories: PropTypes.arrayOf(PropTypes.object).isRequired, - protocol: PropTypes.string.isRequired, - age: PropTypes.number.isRequired, - ageHours: PropTypes.number.isRequired, - ageMinutes: PropTypes.number.isRequired, - publishDate: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - infoUrl: PropTypes.string.isRequired, - downloadUrl: PropTypes.string, - magnetUrl: PropTypes.string, - indexerId: PropTypes.number.isRequired, - indexer: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - files: PropTypes.number, - grabs: PropTypes.number, - seeders: PropTypes.number, - leechers: PropTypes.number, - indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired, - rowHeight: PropTypes.number.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - onGrabPress: PropTypes.func.isRequired, - isGrabbing: PropTypes.bool.isRequired, - isGrabbed: PropTypes.bool.isRequired, - grabError: PropTypes.string -}; - -SearchIndexOverview.defaultProps = { - isGrabbing: false, - isGrabbed: false -}; - -export default SearchIndexOverview; diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.tsx b/frontend/src/Search/Mobile/SearchIndexOverview.tsx new file mode 100644 index 000000000..21a42d70c --- /dev/null +++ b/frontend/src/Search/Mobile/SearchIndexOverview.tsx @@ -0,0 +1,264 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import TextTruncate from 'react-text-truncate'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { icons, kinds } from 'Helpers/Props'; +import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; +import { IndexerCategory } from 'Indexer/Indexer'; +import OverrideMatchModal from 'Search/OverrideMatch/OverrideMatchModal'; +import CategoryLabel from 'Search/Table/CategoryLabel'; +import Peers from 'Search/Table/Peers'; +import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector'; +import dimensions from 'Styles/Variables/dimensions'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatBytes from 'Utilities/Number/formatBytes'; +import titleCase from 'Utilities/String/titleCase'; +import translate from 'Utilities/String/translate'; +import styles from './SearchIndexOverview.css'; + +const columnPadding = parseInt(dimensions.movieIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.movieIndexColumnPaddingSmallScreen +); + +function getDownloadIcon( + isGrabbing: boolean, + isGrabbed: boolean, + grabError?: string +) { + if (isGrabbing) { + return icons.SPINNER; + } else if (isGrabbed) { + return icons.DOWNLOADING; + } else if (grabError) { + return icons.DOWNLOADING; + } + + return icons.DOWNLOAD; +} + +function getDownloadKind(isGrabbed: boolean, grabError?: string) { + if (isGrabbed) { + return kinds.SUCCESS; + } + + if (grabError) { + return kinds.DANGER; + } + + return kinds.DEFAULT; +} + +function getDownloadTooltip( + isGrabbing: boolean, + isGrabbed: boolean, + grabError?: string +) { + if (isGrabbing) { + return ''; + } else if (isGrabbed) { + return translate('AddedToDownloadClient'); + } else if (grabError) { + return grabError; + } + + return translate('AddToDownloadClient'); +} + +interface SearchIndexOverviewProps { + guid: string; + protocol: DownloadProtocol; + age: number; + ageHours: number; + ageMinutes: number; + publishDate: string; + title: string; + infoUrl: string; + downloadUrl?: string; + magnetUrl?: string; + indexerId: number; + indexer: string; + categories: IndexerCategory[]; + size: number; + seeders?: number; + leechers?: number; + indexerFlags: string[]; + isGrabbing: boolean; + isGrabbed: boolean; + grabError?: string; + longDateFormat: string; + timeFormat: string; + rowHeight: number; + isSmallScreen: boolean; + onGrabPress(...args: unknown[]): void; +} + +function SearchIndexOverview(props: SearchIndexOverviewProps) { + const { + guid, + indexerId, + protocol, + categories, + age, + ageHours, + ageMinutes, + publishDate, + title, + infoUrl, + downloadUrl, + magnetUrl, + indexer, + size, + seeders, + leechers, + indexerFlags = [], + isGrabbing = false, + isGrabbed = false, + grabError, + longDateFormat, + timeFormat, + rowHeight, + isSmallScreen, + onGrabPress, + } = props; + + const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false); + + const { items: downloadClients } = useSelector( + createEnabledDownloadClientsSelector(protocol) + ); + + const onGrabPressWrapper = useCallback(() => { + onGrabPress({ + guid, + indexerId, + }); + }, [guid, indexerId, onGrabPress]); + + const onOverridePress = useCallback(() => { + setIsOverrideModalOpen(true); + }, [setIsOverrideModalOpen]); + + const onOverrideModalClose = useCallback(() => { + setIsOverrideModalOpen(false); + }, [setIsOverrideModalOpen]); + + const contentHeight = useMemo(() => { + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + + return rowHeight - padding; + }, [rowHeight, isSmallScreen]); + + return ( + <> +
+
+
+
+
+ + + +
+ +
+ + + {downloadClients.length > 1 ? ( + +
+ + + +
+ + ) : null} + + {downloadUrl || magnetUrl ? ( + + ) : null} +
+
+
{indexer}
+
+ + + {protocol === 'torrent' && ( + + )} + + + + + + + + {indexerFlags.length + ? indexerFlags + .sort((a, b) => + a.localeCompare(b, undefined, { numeric: true }) + ) + .map((flag, index) => { + return ( + + ); + }) + : null} +
+
+
+
+ + + + ); +} + +export default SearchIndexOverview; diff --git a/frontend/src/Search/NoSearchResults.css b/frontend/src/Search/NoSearchResults.css index eff6272f7..f17dd633e 100644 --- a/frontend/src/Search/NoSearchResults.css +++ b/frontend/src/Search/NoSearchResults.css @@ -1,4 +1,6 @@ .message { + composes: alert from '~Components/Alert.css'; + margin-top: 10px; margin-bottom: 30px; text-align: center; diff --git a/frontend/src/Search/NoSearchResults.tsx b/frontend/src/Search/NoSearchResults.tsx index 4ffd1d7fd..46fbc85e0 100644 --- a/frontend/src/Search/NoSearchResults.tsx +++ b/frontend/src/Search/NoSearchResults.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import Alert from 'Components/Alert'; +import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import styles from './NoSearchResults.css'; @@ -11,18 +13,16 @@ function NoSearchResults(props: NoSearchResultsProps) { if (totalItems > 0) { return ( -
-
- {translate('AllIndexersHiddenDueToFilter')} -
-
+ + {translate('AllSearchResultsHiddenByFilter')} + ); } return ( -
-
{translate('NoSearchResultsFound')}
-
+ + {translate('NoSearchResultsFound')} + ); } diff --git a/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx new file mode 100644 index 000000000..7d623decd --- /dev/null +++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModal.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { sizes } from 'Helpers/Props'; +import SelectDownloadClientModalContent from './SelectDownloadClientModalContent'; + +interface SelectDownloadClientModalProps { + isOpen: boolean; + protocol: DownloadProtocol; + modalTitle: string; + onDownloadClientSelect(downloadClientId: number): void; + onModalClose(): void; +} + +function SelectDownloadClientModal(props: SelectDownloadClientModalProps) { + const { isOpen, protocol, modalTitle, onDownloadClientSelect, onModalClose } = + props; + + return ( + + + + ); +} + +export default SelectDownloadClientModal; diff --git a/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx new file mode 100644 index 000000000..63e15808f --- /dev/null +++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientModalContent.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import Alert from 'Components/Alert'; +import Form from 'Components/Form/Form'; +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 DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { kinds } from 'Helpers/Props'; +import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector'; +import translate from 'Utilities/String/translate'; +import SelectDownloadClientRow from './SelectDownloadClientRow'; + +interface SelectDownloadClientModalContentProps { + protocol: DownloadProtocol; + modalTitle: string; + onDownloadClientSelect(downloadClientId: number): void; + onModalClose(): void; +} + +function SelectDownloadClientModalContent( + props: SelectDownloadClientModalContentProps +) { + const { modalTitle, protocol, onDownloadClientSelect, onModalClose } = props; + + const { isFetching, isPopulated, error, items } = useSelector( + createEnabledDownloadClientsSelector(protocol) + ); + + return ( + + + {translate('SelectDownloadClientModalTitle', { modalTitle })} + + + + {isFetching ? : null} + + {!isFetching && error ? ( + + {translate('DownloadClientsLoadError')} + + ) : null} + + {isPopulated && !error ? ( +
+ {items.map((downloadClient) => { + const { id, name, priority } = downloadClient; + + return ( + + ); + })} + + ) : null} +
+ + + + +
+ ); +} + +export default SelectDownloadClientModalContent; diff --git a/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css new file mode 100644 index 000000000..6525db977 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css @@ -0,0 +1,6 @@ +.downloadClient { + display: flex; + justify-content: space-between; + padding: 8px; + border-bottom: 1px solid var(--borderColor); +} diff --git a/frontend/src/History/Details/HistoryDetailsModal.css.d.ts b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts similarity index 83% rename from frontend/src/History/Details/HistoryDetailsModal.css.d.ts rename to frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts index a8cc499e2..10c2d3948 100644 --- a/frontend/src/History/Details/HistoryDetailsModal.css.d.ts +++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.css.d.ts @@ -1,7 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'markAsFailedButton': string; + 'downloadClient': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx new file mode 100644 index 000000000..6f98d60b4 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/DownloadClient/SelectDownloadClientRow.tsx @@ -0,0 +1,32 @@ +import React, { useCallback } from 'react'; +import Link from 'Components/Link/Link'; +import translate from 'Utilities/String/translate'; +import styles from './SelectDownloadClientRow.css'; + +interface SelectSeasonRowProps { + id: number; + name: string; + priority: number; + onDownloadClientSelect(downloadClientId: number): unknown; +} + +function SelectDownloadClientRow(props: SelectSeasonRowProps) { + const { id, name, priority, onDownloadClientSelect } = props; + + const onSeasonSelectWrapper = useCallback(() => { + onDownloadClientSelect(id); + }, [id, onDownloadClientSelect]); + + return ( + +
{name}
+
{translate('PrioritySettings', { priority })}
+ + ); +} + +export default SelectDownloadClientRow; diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchData.css b/frontend/src/Search/OverrideMatch/OverrideMatchData.css new file mode 100644 index 000000000..bd4d2f788 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchData.css @@ -0,0 +1,17 @@ +.link { + composes: link from '~Components/Link/Link.css'; + + width: 100%; +} + +.placeholder { + display: inline-block; + margin: -2px 0; + width: 100%; + outline: 2px dashed var(--dangerColor); + outline-offset: -2px; +} + +.optional { + outline: 2px dashed var(--gray); +} diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchData.css.d.ts b/frontend/src/Search/OverrideMatch/OverrideMatchData.css.d.ts new file mode 100644 index 000000000..dd3ac4575 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchData.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'link': string; + 'optional': string; + 'placeholder': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchData.tsx b/frontend/src/Search/OverrideMatch/OverrideMatchData.tsx new file mode 100644 index 000000000..82d6bd812 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchData.tsx @@ -0,0 +1,35 @@ +import classNames from 'classnames'; +import React from 'react'; +import Link from 'Components/Link/Link'; +import styles from './OverrideMatchData.css'; + +interface OverrideMatchDataProps { + value?: string | number | JSX.Element | JSX.Element[]; + isDisabled?: boolean; + isOptional?: boolean; + onPress: () => void; +} + +function OverrideMatchData(props: OverrideMatchDataProps) { + const { value, isDisabled = false, isOptional, onPress } = props; + + return ( + + {(value == null || (Array.isArray(value) && value.length === 0)) && + !isDisabled ? ( + +   + + ) : ( + value + )} + + ); +} + +export default OverrideMatchData; diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchModal.tsx b/frontend/src/Search/OverrideMatch/OverrideMatchModal.tsx new file mode 100644 index 000000000..16d62ea7c --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchModal.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { sizes } from 'Helpers/Props'; +import OverrideMatchModalContent from './OverrideMatchModalContent'; + +interface OverrideMatchModalProps { + isOpen: boolean; + title: string; + indexerId: number; + guid: string; + protocol: DownloadProtocol; + isGrabbing: boolean; + grabError?: string; + onModalClose(): void; +} + +function OverrideMatchModal(props: OverrideMatchModalProps) { + const { + isOpen, + title, + indexerId, + guid, + protocol, + isGrabbing, + grabError, + onModalClose, + } = props; + + return ( + + + + ); +} + +export default OverrideMatchModal; diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css new file mode 100644 index 000000000..a5b4b8d52 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css @@ -0,0 +1,49 @@ +.label { + composes: label from '~Components/Label.css'; + + cursor: pointer; +} + +.item { + display: block; + margin-bottom: 5px; + margin-left: 50px; +} + +.footer { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + display: flex; + justify-content: space-between; + overflow: hidden; +} + +.error { + margin-right: 20px; + color: var(--dangerColor); + word-break: break-word; +} + +.buttons { + display: flex; +} + +@media only screen and (max-width: $breakpointSmall) { + .item { + margin-left: 0; + } + + .footer { + display: block; + } + + .error { + margin-right: 0; + margin-bottom: 10px; + } + + .buttons { + justify-content: space-between; + flex-grow: 1; + } +} diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css.d.ts b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css.d.ts new file mode 100644 index 000000000..79c77d6b5 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'buttons': string; + 'error': string; + 'footer': string; + 'item': string; + 'label': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.tsx b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.tsx new file mode 100644 index 000000000..fbe0ec450 --- /dev/null +++ b/frontend/src/Search/OverrideMatch/OverrideMatchModalContent.tsx @@ -0,0 +1,150 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +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 DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { grabRelease } from 'Store/Actions/releaseActions'; +import { fetchDownloadClients } from 'Store/Actions/settingsActions'; +import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector'; +import translate from 'Utilities/String/translate'; +import SelectDownloadClientModal from './DownloadClient/SelectDownloadClientModal'; +import OverrideMatchData from './OverrideMatchData'; +import styles from './OverrideMatchModalContent.css'; + +type SelectType = 'select' | 'downloadClient'; + +interface OverrideMatchModalContentProps { + indexerId: number; + title: string; + guid: string; + protocol: DownloadProtocol; + isGrabbing: boolean; + grabError?: string; + onModalClose(): void; +} + +function OverrideMatchModalContent(props: OverrideMatchModalContentProps) { + const modalTitle = translate('ManualGrab'); + const { + indexerId, + title, + guid, + protocol, + isGrabbing, + grabError, + onModalClose, + } = props; + + const [downloadClientId, setDownloadClientId] = useState(null); + const [selectModalOpen, setSelectModalOpen] = useState( + null + ); + const previousIsGrabbing = usePrevious(isGrabbing); + + const dispatch = useDispatch(); + const { items: downloadClients } = useSelector( + createEnabledDownloadClientsSelector(protocol) + ); + + const onSelectModalClose = useCallback(() => { + setSelectModalOpen(null); + }, [setSelectModalOpen]); + + const onSelectDownloadClientPress = useCallback(() => { + setSelectModalOpen('downloadClient'); + }, [setSelectModalOpen]); + + const onDownloadClientSelect = useCallback( + (downloadClientId: number) => { + setDownloadClientId(downloadClientId); + setSelectModalOpen(null); + }, + [setDownloadClientId, setSelectModalOpen] + ); + + const onGrabPress = useCallback(() => { + dispatch( + grabRelease({ + indexerId, + guid, + downloadClientId, + }) + ); + }, [indexerId, guid, downloadClientId, dispatch]); + + useEffect(() => { + if (!isGrabbing && previousIsGrabbing) { + onModalClose(); + } + }, [isGrabbing, previousIsGrabbing, onModalClose]); + + useEffect( + () => { + dispatch(fetchDownloadClients()); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( + + + {translate('OverrideGrabModalTitle', { title })} + + + + + {downloadClients.length > 1 ? ( + downloadClient.id === downloadClientId + )?.name ?? translate('Default') + } + onPress={onSelectDownloadClientPress} + /> + } + /> + ) : null} + + + + +
{grabError}
+ +
+ + + + {translate('GrabRelease')} + +
+
+ + +
+ ); +} + +export default OverrideMatchModalContent; diff --git a/frontend/src/Search/SearchFooter.js b/frontend/src/Search/SearchFooter.js index 5e949fc6e..872328446 100644 --- a/frontend/src/Search/SearchFooter.js +++ b/frontend/src/Search/SearchFooter.js @@ -212,7 +212,11 @@ class SearchFooter extends Component { name="searchQuery" value={searchQuery} buttons={ - + @@ -275,6 +279,7 @@ class SearchFooter extends Component { } + @@ -314,7 +314,7 @@ class SearchIndex extends Component { selectedFilterKey={selectedFilterKey} filters={filters} customFilters={customFilters} - isDisabled={hasNoIndexer} + isDisabled={hasNoSearchResults} onFilterSelect={onFilterSelect} /> diff --git a/frontend/src/Search/SearchIndexConnector.js b/frontend/src/Search/SearchIndexConnector.js index e3302e73c..78a9866b2 100644 --- a/frontend/src/Search/SearchIndexConnector.js +++ b/frontend/src/Search/SearchIndexConnector.js @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import withScrollPosition from 'Components/withScrollPosition'; import { bulkGrabReleases, cancelFetchReleases, clearReleases, fetchReleases, setReleasesFilter, setReleasesSort, setReleasesTableOption } from 'Store/Actions/releaseActions'; +import { fetchDownloadClients } from 'Store/Actions/Settings/downloadClients'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createReleaseClientSideCollectionItemsSelector from 'Store/Selectors/createReleaseClientSideCollectionItemsSelector'; import SearchIndex from './SearchIndex'; @@ -55,12 +56,20 @@ function createMapDispatchToProps(dispatch, props) { dispatchClearReleases() { dispatch(clearReleases()); + }, + + dispatchFetchDownloadClients() { + dispatch(fetchDownloadClients()); } }; } class SearchIndexConnector extends Component { + componentDidMount() { + this.props.dispatchFetchDownloadClients(); + } + componentWillUnmount() { this.props.dispatchCancelFetchReleases(); this.props.dispatchClearReleases(); @@ -85,6 +94,7 @@ SearchIndexConnector.propTypes = { onBulkGrabPress: PropTypes.func.isRequired, dispatchCancelFetchReleases: PropTypes.func.isRequired, dispatchClearReleases: PropTypes.func.isRequired, + dispatchFetchDownloadClients: PropTypes.func.isRequired, items: PropTypes.arrayOf(PropTypes.object) }; diff --git a/frontend/src/Search/Table/CategoryLabel.js b/frontend/src/Search/Table/CategoryLabel.js deleted file mode 100644 index 5c076c521..000000000 --- a/frontend/src/Search/Table/CategoryLabel.js +++ /dev/null @@ -1,43 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Label from 'Components/Label'; -import Tooltip from 'Components/Tooltip/Tooltip'; -import { kinds, tooltipPositions } from 'Helpers/Props'; - -function CategoryLabel({ categories }) { - const sortedCategories = categories.filter((cat) => cat.name !== undefined).sort((c) => c.id); - - if (categories?.length === 0) { - return ( - Unknown} - tooltip="Please report this issue to the GitHub as this shouldn't be happening" - position={tooltipPositions.LEFT} - /> - ); - } - - return ( - - { - sortedCategories.map((category) => { - return ( - - ); - }) - } - - ); -} - -CategoryLabel.defaultProps = { - categories: [] -}; - -CategoryLabel.propTypes = { - categories: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -export default CategoryLabel; diff --git a/frontend/src/Search/Table/CategoryLabel.tsx b/frontend/src/Search/Table/CategoryLabel.tsx new file mode 100644 index 000000000..4cfdeb1b2 --- /dev/null +++ b/frontend/src/Search/Table/CategoryLabel.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import Label from 'Components/Label'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import { kinds, tooltipPositions } from 'Helpers/Props'; +import { IndexerCategory } from 'Indexer/Indexer'; +import translate from 'Utilities/String/translate'; + +interface CategoryLabelProps { + categories: IndexerCategory[]; +} + +function CategoryLabel({ categories = [] }: CategoryLabelProps) { + if (categories?.length === 0) { + return ( + {translate('Unknown')}} + tooltip="Please report this issue to the GitHub as this shouldn't be happening" + position={tooltipPositions.LEFT} + /> + ); + } + + const sortedCategories = categories + .filter((cat) => cat.name !== undefined) + .sort((a, b) => a.id - b.id); + + return ( + + {sortedCategories.map((category) => { + return ; + })} + + ); +} + +export default CategoryLabel; diff --git a/frontend/src/Search/Table/ReleaseLinks.css b/frontend/src/Search/Table/ReleaseLinks.css new file mode 100644 index 000000000..d37a082a1 --- /dev/null +++ b/frontend/src/Search/Table/ReleaseLinks.css @@ -0,0 +1,13 @@ +.links { + margin: 0; +} + +.link { + white-space: nowrap; +} + +.linkLabel { + composes: label from '~Components/Label.css'; + + cursor: pointer; +} diff --git a/frontend/src/Search/Table/ReleaseLinks.css.d.ts b/frontend/src/Search/Table/ReleaseLinks.css.d.ts new file mode 100644 index 000000000..9f91f93a4 --- /dev/null +++ b/frontend/src/Search/Table/ReleaseLinks.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'link': string; + 'linkLabel': string; + 'links': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Search/Table/ReleaseLinks.tsx b/frontend/src/Search/Table/ReleaseLinks.tsx new file mode 100644 index 000000000..38260bc21 --- /dev/null +++ b/frontend/src/Search/Table/ReleaseLinks.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import { kinds, sizes } from 'Helpers/Props'; +import { IndexerCategory } from 'Indexer/Indexer'; +import styles from './ReleaseLinks.css'; + +interface ReleaseLinksProps { + categories: IndexerCategory[]; + imdbId?: string; + tmdbId?: number; + tvdbId?: number; + tvMazeId?: number; +} + +function ReleaseLinks(props: ReleaseLinksProps) { + const { categories = [], imdbId, tmdbId, tvdbId, tvMazeId } = props; + + const categoryNames = categories + .filter((item) => item.id < 100000) + .map((c) => c.name); + + return ( +
+ {imdbId ? ( + + + + ) : null} + + {tmdbId ? ( + + + + ) : null} + + {tvdbId ? ( + + + + ) : null} + + {tvMazeId ? ( + + + + ) : null} +
+ ); +} + +export default ReleaseLinks; diff --git a/frontend/src/Search/Table/SearchIndexItemConnector.js b/frontend/src/Search/Table/SearchIndexItemConnector.js index 490214529..4cc7fb20c 100644 --- a/frontend/src/Search/Table/SearchIndexItemConnector.js +++ b/frontend/src/Search/Table/SearchIndexItemConnector.js @@ -2,7 +2,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { executeCommand } from 'Store/Actions/commandActions'; function createReleaseSelector() { return createSelector( @@ -37,10 +36,6 @@ function createMapStateToProps() { ); } -const mapDispatchToProps = { - dispatchExecuteCommand: executeCommand -}; - class SearchIndexItemConnector extends Component { // @@ -71,4 +66,4 @@ SearchIndexItemConnector.propTypes = { component: PropTypes.elementType.isRequired }; -export default connect(createMapStateToProps, mapDispatchToProps)(SearchIndexItemConnector); +export default connect(createMapStateToProps, null)(SearchIndexItemConnector); diff --git a/frontend/src/Search/Table/SearchIndexRow.css b/frontend/src/Search/Table/SearchIndexRow.css index 342092b81..b36ec4071 100644 --- a/frontend/src/Search/Table/SearchIndexRow.css +++ b/frontend/src/Search/Table/SearchIndexRow.css @@ -63,7 +63,37 @@ } .externalLinks { + composes: button from '~Components/Link/IconButton.css'; + + color: var(--textColor); +} + +.manualDownloadContent { + position: relative; + display: inline-block; margin: 0 2px; width: 22px; + height: 20.39px; + vertical-align: middle; + line-height: 20.39px; + + &:hover { + color: var(--iconButtonHoverColor); + } +} + +.interactiveIcon { + position: absolute; + top: 4px; + left: 0; + /* width: 100%; */ + text-align: center; +} + +.downloadIcon { + position: absolute; + top: 7px; + left: 8px; + /* width: 100%; */ text-align: center; } diff --git a/frontend/src/Search/Table/SearchIndexRow.css.d.ts b/frontend/src/Search/Table/SearchIndexRow.css.d.ts index 6d625f58a..7552b96f9 100644 --- a/frontend/src/Search/Table/SearchIndexRow.css.d.ts +++ b/frontend/src/Search/Table/SearchIndexRow.css.d.ts @@ -6,12 +6,15 @@ interface CssExports { 'category': string; 'cell': string; 'checkInput': string; + 'downloadIcon': string; 'downloadLink': string; 'externalLinks': string; 'files': string; 'grabs': string; 'indexer': string; 'indexerFlags': string; + 'interactiveIcon': string; + 'manualDownloadContent': string; 'peers': string; 'protocol': string; 'size': string; diff --git a/frontend/src/Search/Table/SearchIndexRow.js b/frontend/src/Search/Table/SearchIndexRow.js deleted file mode 100644 index 67c267696..000000000 --- a/frontend/src/Search/Table/SearchIndexRow.js +++ /dev/null @@ -1,396 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; -import Popover from 'Components/Tooltip/Popover'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; -import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import formatAge from 'Utilities/Number/formatAge'; -import formatBytes from 'Utilities/Number/formatBytes'; -import titleCase from 'Utilities/String/titleCase'; -import translate from 'Utilities/String/translate'; -import CategoryLabel from './CategoryLabel'; -import Peers from './Peers'; -import styles from './SearchIndexRow.css'; - -function getDownloadIcon(isGrabbing, isGrabbed, grabError) { - if (isGrabbing) { - return icons.SPINNER; - } else if (isGrabbed) { - return icons.DOWNLOADING; - } else if (grabError) { - return icons.DOWNLOADING; - } - - return icons.DOWNLOAD; -} - -function getDownloadKind(isGrabbed, grabError) { - if (isGrabbed) { - return kinds.SUCCESS; - } - - if (grabError) { - return kinds.DANGER; - } - - return kinds.DEFAULT; -} - -function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { - if (isGrabbing) { - return ''; - } else if (isGrabbed) { - return translate('AddedToDownloadClient'); - } else if (grabError) { - return grabError; - } - - return translate('AddToDownloadClient'); -} - -class SearchIndexRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isConfirmGrabModalOpen: false - }; - } - - // - // Listeners - - onGrabPress = () => { - const { - guid, - indexerId, - onGrabPress - } = this.props; - - onGrabPress({ - guid, - indexerId - }); - }; - - onSavePress = () => { - const { - downloadUrl, - fileName, - onSavePress - } = this.props; - - onSavePress({ - downloadUrl, - fileName - }); - }; - - // - // Render - - render() { - const { - guid, - protocol, - downloadUrl, - magnetUrl, - categories, - age, - ageHours, - ageMinutes, - publishDate, - title, - infoUrl, - indexer, - size, - files, - grabs, - seeders, - leechers, - indexerFlags, - columns, - isGrabbing, - isGrabbed, - grabError, - longDateFormat, - timeFormat, - isSelected, - onSelectedChange - } = this.props; - - return ( - <> - { - columns.map((column) => { - const { - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (column.name === 'select') { - return ( - - ); - } - - if (column.name === 'protocol') { - return ( - - - - ); - } - - if (column.name === 'age') { - return ( - - {formatAge(age, ageHours, ageMinutes)} - - ); - } - - if (column.name === 'sortTitle') { - return ( - - -
- {title} -
- -
- ); - } - - if (column.name === 'indexer') { - return ( - - {indexer} - - ); - } - - if (column.name === 'size') { - return ( - - {formatBytes(size)} - - ); - } - - if (column.name === 'files') { - return ( - - {files} - - ); - } - - if (column.name === 'grabs') { - return ( - - {grabs} - - ); - } - - if (column.name === 'peers') { - return ( - - { - protocol === 'torrent' && - - } - - ); - } - - if (column.name === 'category') { - return ( - - - - ); - } - - if (column.name === 'indexerFlags') { - return ( - - { - !!indexerFlags.length && - - } - title={translate('IndexerFlags')} - body={ -
    - { - indexerFlags.map((flag, index) => { - return ( -
  • - {titleCase(flag)} -
  • - ); - }) - } -
- } - position={tooltipPositions.LEFT} - /> - } -
- ); - } - - if (column.name === 'actions') { - return ( - - - - { - downloadUrl ? - : - null - } - - { - magnetUrl ? - : - null - } - - ); - } - - return null; - }) - } - - ); - } -} - -SearchIndexRow.propTypes = { - guid: PropTypes.string.isRequired, - categories: PropTypes.arrayOf(PropTypes.object).isRequired, - protocol: PropTypes.string.isRequired, - age: PropTypes.number.isRequired, - ageHours: PropTypes.number.isRequired, - ageMinutes: PropTypes.number.isRequired, - publishDate: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - fileName: PropTypes.string.isRequired, - infoUrl: PropTypes.string.isRequired, - downloadUrl: PropTypes.string, - magnetUrl: PropTypes.string, - indexerId: PropTypes.number.isRequired, - indexer: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - files: PropTypes.number, - grabs: PropTypes.number, - seeders: PropTypes.number, - leechers: PropTypes.number, - indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onGrabPress: PropTypes.func.isRequired, - onSavePress: PropTypes.func.isRequired, - isGrabbing: PropTypes.bool.isRequired, - isGrabbed: PropTypes.bool.isRequired, - grabError: PropTypes.string, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isSelected: PropTypes.bool, - onSelectedChange: PropTypes.func.isRequired -}; - -SearchIndexRow.defaultProps = { - isGrabbing: false, - isGrabbed: false -}; - -export default SearchIndexRow; diff --git a/frontend/src/Search/Table/SearchIndexRow.tsx b/frontend/src/Search/Table/SearchIndexRow.tsx new file mode 100644 index 000000000..1136a7f64 --- /dev/null +++ b/frontend/src/Search/Table/SearchIndexRow.tsx @@ -0,0 +1,395 @@ +import React, { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; +import Column from 'Components/Table/Column'; +import Popover from 'Components/Tooltip/Popover'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import ProtocolLabel from 'Indexer/Index/Table/ProtocolLabel'; +import { IndexerCategory } from 'Indexer/Indexer'; +import OverrideMatchModal from 'Search/OverrideMatch/OverrideMatchModal'; +import createEnabledDownloadClientsSelector from 'Store/Selectors/createEnabledDownloadClientsSelector'; +import { SelectStateInputProps } from 'typings/props'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatBytes from 'Utilities/Number/formatBytes'; +import titleCase from 'Utilities/String/titleCase'; +import translate from 'Utilities/String/translate'; +import CategoryLabel from './CategoryLabel'; +import Peers from './Peers'; +import ReleaseLinks from './ReleaseLinks'; +import styles from './SearchIndexRow.css'; + +function getDownloadIcon( + isGrabbing: boolean, + isGrabbed: boolean, + grabError?: string +) { + if (isGrabbing) { + return icons.SPINNER; + } else if (isGrabbed) { + return icons.DOWNLOADING; + } else if (grabError) { + return icons.DOWNLOADING; + } + + return icons.DOWNLOAD; +} + +function getDownloadKind(isGrabbed: boolean, grabError?: string) { + if (isGrabbed) { + return kinds.SUCCESS; + } + + if (grabError) { + return kinds.DANGER; + } + + return kinds.DEFAULT; +} + +function getDownloadTooltip( + isGrabbing: boolean, + isGrabbed: boolean, + grabError?: string +) { + if (isGrabbing) { + return ''; + } else if (isGrabbed) { + return translate('AddedToDownloadClient'); + } else if (grabError) { + return grabError; + } + + return translate('AddToDownloadClient'); +} + +interface SearchIndexRowProps { + guid: string; + protocol: DownloadProtocol; + age: number; + ageHours: number; + ageMinutes: number; + publishDate: string; + title: string; + fileName: string; + infoUrl: string; + downloadUrl?: string; + magnetUrl?: string; + indexerId: number; + indexer: string; + categories: IndexerCategory[]; + size: number; + files?: number; + grabs?: number; + seeders?: number; + leechers?: number; + imdbId?: string; + tmdbId?: number; + tvdbId?: number; + tvMazeId?: number; + indexerFlags: string[]; + isGrabbing: boolean; + isGrabbed: boolean; + grabError?: string; + longDateFormat: string; + timeFormat: string; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; + onGrabPress(...args: unknown[]): void; + onSavePress(...args: unknown[]): void; +} + +function SearchIndexRow(props: SearchIndexRowProps) { + const { + guid, + indexerId, + protocol, + categories, + age, + ageHours, + ageMinutes, + publishDate, + title, + fileName, + infoUrl, + downloadUrl, + magnetUrl, + indexer, + size, + files, + grabs, + seeders, + leechers, + imdbId, + tmdbId, + tvdbId, + tvMazeId, + indexerFlags = [], + isGrabbing = false, + isGrabbed = false, + grabError, + longDateFormat, + timeFormat, + columns, + isSelected, + onSelectedChange, + onGrabPress, + onSavePress, + } = props; + + const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false); + + const { items: downloadClients } = useSelector( + createEnabledDownloadClientsSelector(protocol) + ); + + const onGrabPressWrapper = useCallback(() => { + onGrabPress({ + guid, + indexerId, + }); + }, [guid, indexerId, onGrabPress]); + + const onSavePressWrapper = useCallback(() => { + onSavePress({ + downloadUrl, + fileName, + }); + }, [downloadUrl, fileName, onSavePress]); + + const onOverridePress = useCallback(() => { + setIsOverrideModalOpen(true); + }, [setIsOverrideModalOpen]); + + const onOverrideModalClose = useCallback(() => { + setIsOverrideModalOpen(false); + }, [setIsOverrideModalOpen]); + + return ( + <> + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'select') { + return ( + + ); + } + + if (name === 'protocol') { + return ( + + + + ); + } + + if (name === 'age') { + return ( + + {formatAge(age, ageHours, ageMinutes)} + + ); + } + + if (name === 'sortTitle') { + return ( + + +
{title}
+ +
+ ); + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'size') { + return ( + + {formatBytes(size)} + + ); + } + + if (name === 'files') { + return ( + + {files} + + ); + } + + if (name === 'grabs') { + return ( + + {grabs} + + ); + } + + if (name === 'peers') { + return ( + + {protocol === 'torrent' && ( + + )} + + ); + } + + if (name === 'category') { + return ( + + + + ); + } + + if (name === 'indexerFlags') { + return ( + + {!!indexerFlags.length && ( + } + title={translate('IndexerFlags')} + body={ +
    + {indexerFlags.map((flag, index) => { + return
  • {titleCase(flag)}
  • ; + })} +
+ } + position={tooltipPositions.LEFT} + /> + )} +
+ ); + } + + if (name === 'actions') { + return ( + + + + {downloadClients.length > 1 ? ( + +
+ + + +
+ + ) : null} + + {downloadUrl ? ( + + ) : null} + + {magnetUrl ? ( + + ) : null} + + {imdbId || tmdbId || tvdbId || tvMazeId ? ( + + } + title={translate('Links')} + body={ + + } + position={tooltipPositions.TOP} + /> + ) : null} +
+ ); + } + + return null; + })} + + + + ); +} + +export default SearchIndexRow; diff --git a/frontend/src/Settings/Applications/ApplicationSettings.tsx b/frontend/src/Settings/Applications/ApplicationSettings.tsx index c35d55e2d..7fc4b1d7b 100644 --- a/frontend/src/Settings/Applications/ApplicationSettings.tsx +++ b/frontend/src/Settings/Applications/ApplicationSettings.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, useCallback, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import AppState from 'App/State/AppState'; import { APP_INDEXER_SYNC } from 'Commands/commandNames'; @@ -56,7 +56,7 @@ function ApplicationSettings() { // @ts-ignore showSave={false} additionalButtons={ - + <> - + } /> diff --git a/frontend/src/Settings/Applications/Applications/Application.js b/frontend/src/Settings/Applications/Applications/Application.js index 610cc344d..086d39ee1 100644 --- a/frontend/src/Settings/Applications/Applications/Application.js +++ b/frontend/src/Settings/Applications/Applications/Application.js @@ -57,6 +57,7 @@ class Application extends Component { const { id, name, + enable, syncLevel, fields, tags, @@ -77,7 +78,7 @@ class Application extends Component {
{ - applicationUrl ? + enable && applicationUrl ?
diff --git a/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js b/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js index bbf8722c5..9f5e570c5 100644 --- a/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js +++ b/frontend/src/Settings/Applications/Applications/ApplicationsConnector.js @@ -5,12 +5,12 @@ import { createSelector } from 'reselect'; import { deleteApplication, fetchApplications } 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 Applications from './Applications'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.applications', sortByName), + createSortedSectionSelector('settings.applications', sortByProp('name')), createTagsSelector(), (applications, tagList) => { return { diff --git a/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js b/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js index 33cae830a..00e30cdb7 100644 --- a/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js +++ b/frontend/src/Settings/Applications/Applications/EditApplicationModalContent.js @@ -133,7 +133,8 @@ function EditApplicationModalContent(props) { diff --git a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx index 10e73b52a..57e88a4fe 100644 --- a/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx +++ b/frontend/src/Settings/Applications/Applications/Manage/Edit/ManageApplicationsEditModalContent.tsx @@ -30,7 +30,7 @@ const syncLevelOptions = [ get value() { return translate('NoChange'); }, - disabled: true, + isDisabled: true, }, { key: ApplicationSyncLevel.Disabled, diff --git a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx index 8c45cf8a5..bb81729f3 100644 --- a/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx +++ b/frontend/src/Settings/Applications/Applications/Manage/ManageApplicationsModalContent.tsx @@ -14,9 +14,11 @@ 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 { bulkDeleteApplications, bulkEditApplications, + setManageApplicationsSort, } from 'Store/Actions/settingsActions'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import { SelectStateInputProps } from 'typings/props'; @@ -62,6 +64,8 @@ const COLUMNS = [ interface ManageApplicationsModalContentProps { onModalClose(): void; + sortKey?: string; + sortDirection?: SortDirection; } function ManageApplicationsModalContent( @@ -76,6 +80,8 @@ function ManageApplicationsModalContent( isSaving, error, items, + sortKey, + sortDirection, }: ApplicationAppState = useSelector( createClientSideCollectionSelector('settings.applications') ); @@ -96,6 +102,13 @@ function ManageApplicationsModalContent( const selectedCount = selectedIds.length; + const onSortPress = useCallback( + (value: string) => { + dispatch(setManageApplicationsSort({ sortKey: value })); + }, + [dispatch] + ); + const onDeletePress = useCallback(() => { setIsDeleteModalOpen(true); }, [setIsDeleteModalOpen]); @@ -200,7 +213,10 @@ function ManageApplicationsModalContent( selectAll={true} allSelected={allSelected} allUnselected={allUnselected} + sortKey={sortKey} + sortDirection={sortDirection} onSelectAllChange={onSelectAllChange} + onSortPress={onSortPress} > {items.map((item) => { @@ -252,9 +268,9 @@ function ManageApplicationsModalContent( diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js index 640d56a89..51f390d4f 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClients.js @@ -1,10 +1,11 @@ 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 Icon from 'Components/Icon'; import PageSectionContent from 'Components/Page/PageSectionContent'; -import { icons } from 'Helpers/Props'; +import { icons, kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import AddDownloadClientModal from './AddDownloadClientModal'; import DownloadClient from './DownloadClient'; @@ -59,48 +60,59 @@ class DownloadClients extends Component { } = this.state; return ( -
- -
- { - items.map((item) => { - return ( - - ); - }) - } - - -
- -
-
+
+ +
+ {translate('ProwlarrDownloadClientsAlert')}
+
+ {translate('ProwlarrDownloadClientsInAppOnlyAlert')} +
+
- +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } - - -
+ +
+ +
+
+
+ + + + + +
+
); } } diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js index 9cba9c1cc..4f6833fcb 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/DownloadClientsConnector.js @@ -4,12 +4,12 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { deleteDownloadClient, fetchDownloadClients } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -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')), (downloadClients) => downloadClients ); } diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js index b4dd3c1e9..c57432710 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js +++ b/frontend/src/Settings/DownloadClients/DownloadClients/EditDownloadClientModalContent.js @@ -17,6 +17,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { icons, inputTypes, kinds } from 'Helpers/Props'; +import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton'; import translate from 'Utilities/String/translate'; import AddCategoryModalConnector from './Categories/AddCategoryModalConnector'; import Category from './Categories/Category'; @@ -61,6 +62,7 @@ class EditDownloadClientModalContent extends Component { onModalClose, onSavePress, onTestPress, + onAdvancedSettingsPress, onDeleteDownloadClientPress, onConfirmDeleteCategory, ...otherProps @@ -219,6 +221,12 @@ class EditDownloadClientModalContent extends Component { } + + { + this.props.toggleAdvancedSettings(); + }; + onConfirmDeleteCategory = (id) => { this.props.deleteDownloadClientCategory({ id }); }; @@ -81,6 +94,7 @@ class EditDownloadClientModalContentConnector extends Component { {...this.props} onSavePress={this.onSavePress} onTestPress={this.onTestPress} + onAdvancedSettingsPress={this.onAdvancedSettingsPress} onInputChange={this.onInputChange} onFieldChange={this.onFieldChange} onConfirmDeleteCategory={this.onConfirmDeleteCategory} @@ -102,6 +116,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 a2e0f89c6..d18e694c9 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx @@ -30,7 +30,7 @@ const enableOptions = [ get value() { return translate('NoChange'); }, - disabled: true, + isDisabled: true, }, { key: 'enabled', diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx index 8e257ae7a..fa82d61b9 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx @@ -14,9 +14,11 @@ 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, + setManageDownloadClientsSort, } from 'Store/Actions/settingsActions'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import { SelectStateInputProps } from 'typings/props'; @@ -61,6 +63,8 @@ const COLUMNS = [ interface ManageDownloadClientsModalContentProps { onModalClose(): void; + sortKey?: string; + sortDirection?: SortDirection; } function ManageDownloadClientsModalContent( @@ -75,6 +79,8 @@ function ManageDownloadClientsModalContent( isSaving, error, items, + sortKey, + sortDirection, }: DownloadClientAppState = useSelector( createClientSideCollectionSelector('settings.downloadClients') ); @@ -93,6 +99,13 @@ function ManageDownloadClientsModalContent( const selectedCount = selectedIds.length; + const onSortPress = useCallback( + (value: string) => { + dispatch(setManageDownloadClientsSort({ sortKey: value })); + }, + [dispatch] + ); + const onDeletePress = useCallback(() => { setIsDeleteModalOpen(true); }, [setIsDeleteModalOpen]); @@ -173,7 +186,10 @@ function ManageDownloadClientsModalContent( selectAll={true} allSelected={allSelected} allUnselected={allUnselected} + sortKey={sortKey} + sortDirection={sortDirection} onSelectAllChange={onSelectAllChange} + onSortPress={onSortPress} > {items.map((item) => { @@ -217,9 +233,9 @@ function ManageDownloadClientsModalContent( diff --git a/frontend/src/Settings/General/LoggingSettings.js b/frontend/src/Settings/General/LoggingSettings.js index 540e29b01..61a259258 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 ( @@ -37,11 +39,30 @@ function LoggingSettings(props) { {...logLevel} /> + + + {translate('LogSizeLimit')} + + + ); } LoggingSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, settings: PropTypes.object.isRequired, onInputChange: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js index 9a930b1d5..8e2597741 100644 --- a/frontend/src/Settings/General/SecuritySettings.js +++ b/frontend/src/Settings/General/SecuritySettings.js @@ -124,6 +124,7 @@ class SecuritySettings extends Component { authenticationRequired, username, password, + passwordConfirmation, apiKey, certificateValidation } = settings; @@ -139,8 +140,8 @@ class SecuritySettings extends Component { type={inputTypes.SELECT} name="authenticationMethod" values={authenticationMethodOptions} - helpText={translate('AuthenticationMethodHelpText', { appName: 'Prowlarr' })} - helpTextWarning={translate('AuthenticationRequiredWarning', { appName: 'Prowlarr' })} + helpText={translate('AuthenticationMethodHelpText')} + helpTextWarning={translate('AuthenticationRequiredWarning')} onChange={onInputChange} {...authenticationMethod} /> @@ -193,6 +194,21 @@ class SecuritySettings extends Component { null } + { + authenticationEnabled ? + + {translate('PasswordConfirmation')} + + + : + null + } + {translate('ApiKey')} diff --git a/frontend/src/Settings/General/UpdateSettings.js b/frontend/src/Settings/General/UpdateSettings.js index 3bf8d43b6..9cf1b7932 100644 --- a/frontend/src/Settings/General/UpdateSettings.js +++ b/frontend/src/Settings/General/UpdateSettings.js @@ -12,7 +12,6 @@ function UpdateSettings(props) { const { advancedSettings, settings, - isWindows, packageUpdateMechanism, onInputChange } = props; @@ -38,10 +37,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 (
@@ -62,61 +61,58 @@ 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/Indexers/IndexerProxies/EditIndexerProxyModalContent.js b/frontend/src/Settings/Indexers/IndexerProxies/EditIndexerProxyModalContent.js index 8579c2a2f..59e10422b 100644 --- a/frontend/src/Settings/Indexers/IndexerProxies/EditIndexerProxyModalContent.js +++ b/frontend/src/Settings/Indexers/IndexerProxies/EditIndexerProxyModalContent.js @@ -14,6 +14,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds } from 'Helpers/Props'; +import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton'; import translate from 'Utilities/String/translate'; import styles from './EditIndexerProxyModalContent.css'; @@ -31,6 +32,7 @@ function EditIndexerProxyModalContent(props) { onModalClose, onSavePress, onTestPress, + onAdvancedSettingsPress, onDeleteIndexerProxyPress, ...otherProps } = props; @@ -130,6 +132,12 @@ function EditIndexerProxyModalContent(props) { } + + { + this.props.toggleAdvancedSettings(); + }; + // // Render @@ -65,6 +76,7 @@ class EditIndexerProxyModalContentConnector extends Component { {...this.props} onSavePress={this.onSavePress} onTestPress={this.onTestPress} + onAdvancedSettingsPress={this.onAdvancedSettingsPress} onInputChange={this.onInputChange} onFieldChange={this.onFieldChange} /> @@ -82,6 +94,7 @@ EditIndexerProxyModalContentConnector.propTypes = { setIndexerProxyFieldValue: PropTypes.func.isRequired, saveIndexerProxy: PropTypes.func.isRequired, testIndexerProxy: PropTypes.func.isRequired, + toggleAdvancedSettings: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/Indexers/IndexerProxies/IndexerProxiesConnector.js b/frontend/src/Settings/Indexers/IndexerProxies/IndexerProxiesConnector.js index 9d2188a7c..0d2acae87 100644 --- a/frontend/src/Settings/Indexers/IndexerProxies/IndexerProxiesConnector.js +++ b/frontend/src/Settings/Indexers/IndexerProxies/IndexerProxiesConnector.js @@ -5,13 +5,13 @@ import { createSelector } from 'reselect'; import { deleteIndexerProxy, fetchIndexerProxies } 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 IndexerProxies from './IndexerProxies'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.indexerProxies', sortByName), - createSortedSectionSelector('indexers', sortByName), + createSortedSectionSelector('settings.indexerProxies', sortByProp('name')), + createSortedSectionSelector('indexers', sortByProp('name')), createTagsSelector(), (indexerProxies, indexers, tagList) => { return { diff --git a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js index 60e368617..ed00d96e6 100644 --- a/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js +++ b/frontend/src/Settings/Notifications/Notifications/EditNotificationModalContent.js @@ -14,6 +14,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes, kinds } from 'Helpers/Props'; +import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton'; import translate from 'Utilities/String/translate'; import NotificationEventItems from './NotificationEventItems'; import styles from './EditNotificationModalContent.css'; @@ -32,6 +33,7 @@ function EditNotificationModalContent(props) { onModalClose, onSavePress, onTestPress, + onAdvancedSettingsPress, onDeleteNotificationPress, ...otherProps } = props; @@ -136,6 +138,12 @@ function EditNotificationModalContent(props) { } + + { + this.props.toggleAdvancedSettings(); + }; + // // Render @@ -65,6 +76,7 @@ class EditNotificationModalContentConnector extends Component { {...this.props} onSavePress={this.onSavePress} onTestPress={this.onTestPress} + onAdvancedSettingsPress={this.onAdvancedSettingsPress} onInputChange={this.onInputChange} onFieldChange={this.onFieldChange} /> @@ -82,6 +94,7 @@ EditNotificationModalContentConnector.propTypes = { setNotificationFieldValue: PropTypes.func.isRequired, saveNotification: PropTypes.func.isRequired, testNotification: PropTypes.func.isRequired, + toggleAdvancedSettings: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js index b306f742a..6351c6f8a 100644 --- a/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js +++ b/frontend/src/Settings/Notifications/Notifications/NotificationsConnector.js @@ -5,12 +5,12 @@ import { createSelector } from 'reselect'; import { deleteNotification, fetchNotifications } 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 Notifications from './Notifications'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.notifications', sortByName), + createSortedSectionSelector('settings.notifications', sortByProp('name')), createTagsSelector(), (notifications, tagList) => { return { diff --git a/frontend/src/Settings/PendingChangesModal.js b/frontend/src/Settings/PendingChangesModal.js index 4cb83e8f6..213445c65 100644 --- a/frontend/src/Settings/PendingChangesModal.js +++ b/frontend/src/Settings/PendingChangesModal.js @@ -15,12 +15,17 @@ function PendingChangesModal(props) { isOpen, onConfirm, onCancel, - bindShortcut + bindShortcut, + unbindShortcut } = props; useEffect(() => { - bindShortcut('enter', onConfirm); - }, [bindShortcut, onConfirm]); + if (isOpen) { + bindShortcut('enter', onConfirm); + + return () => unbindShortcut('enter', onConfirm); + } + }, [bindShortcut, unbindShortcut, isOpen, onConfirm]); return ( - {translate('RSS')} + {translate('Rss')} } diff --git a/frontend/src/Settings/Profiles/App/AppProfilesConnector.js b/frontend/src/Settings/Profiles/App/AppProfilesConnector.js index a150655a6..02bf845df 100644 --- a/frontend/src/Settings/Profiles/App/AppProfilesConnector.js +++ b/frontend/src/Settings/Profiles/App/AppProfilesConnector.js @@ -4,12 +4,12 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { cloneAppProfile, deleteAppProfile, fetchAppProfiles } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; -import sortByName from 'Utilities/Array/sortByName'; +import sortByProp from 'Utilities/Array/sortByProp'; import AppProfiles from './AppProfiles'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.appProfiles', sortByName), + createSortedSectionSelector('settings.appProfiles', sortByProp('name')), (appProfiles) => appProfiles ); } diff --git a/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js b/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js index aace8e039..ac67c77f2 100644 --- a/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js +++ b/frontend/src/Settings/Profiles/App/EditAppProfileModalContent.js @@ -97,20 +97,6 @@ class EditAppProfileModalContent extends Component { />
- - - {translate('EnableInteractiveSearch')} - - - - - {translate('EnableAutomaticSearch')} @@ -125,6 +111,20 @@ class EditAppProfileModalContent extends Component { /> + + + {translate('EnableInteractiveSearch')} + + + + + {translate('MinimumSeeders')} diff --git a/frontend/src/Settings/Tags/TagsConnector.js b/frontend/src/Settings/Tags/TagsConnector.js index 4f311e984..1f3de2034 100644 --- a/frontend/src/Settings/Tags/TagsConnector.js +++ b/frontend/src/Settings/Tags/TagsConnector.js @@ -3,12 +3,14 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { fetchApplications, fetchIndexerProxies, fetchNotifications } from 'Store/Actions/settingsActions'; -import { fetchTagDetails } from 'Store/Actions/tagActions'; +import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import sortByProp from 'Utilities/Array/sortByProp'; import Tags from './Tags'; function createMapStateToProps() { return createSelector( - (state) => state.tags, + createSortedSectionSelector('tags', sortByProp('label')), (tags) => { const isFetching = tags.isFetching || tags.details.isFetching; const error = tags.error || tags.details.error; @@ -25,6 +27,7 @@ function createMapStateToProps() { } const mapDispatchToProps = { + dispatchFetchTags: fetchTags, dispatchFetchTagDetails: fetchTagDetails, dispatchFetchNotifications: fetchNotifications, dispatchFetchIndexerProxies: fetchIndexerProxies, @@ -38,12 +41,14 @@ class MetadatasConnector extends Component { componentDidMount() { const { + dispatchFetchTags, dispatchFetchTagDetails, dispatchFetchNotifications, dispatchFetchIndexerProxies, dispatchFetchApplications } = this.props; + dispatchFetchTags(); dispatchFetchTagDetails(); dispatchFetchNotifications(); dispatchFetchIndexerProxies(); @@ -63,6 +68,7 @@ class MetadatasConnector extends Component { } MetadatasConnector.propTypes = { + dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchTagDetails: PropTypes.func.isRequired, dispatchFetchNotifications: PropTypes.func.isRequired, dispatchFetchIndexerProxies: PropTypes.func.isRequired, diff --git a/frontend/src/Settings/UI/UISettings.js b/frontend/src/Settings/UI/UISettings.js index cfb7c04f6..d156f4ff3 100644 --- a/frontend/src/Settings/UI/UISettings.js +++ b/frontend/src/Settings/UI/UISettings.js @@ -21,19 +21,19 @@ export const firstDayOfWeekOptions = [ ]; export const weekColumnOptions = [ - { key: 'ddd M/D', value: 'Tue 3/25' }, - { key: 'ddd MM/DD', value: 'Tue 03/25' }, - { key: 'ddd D/M', value: 'Tue 25/3' }, - { key: 'ddd DD/MM', value: 'Tue 25/03' } + { key: 'ddd M/D', value: 'Tue 3/25', hint: 'ddd M/D' }, + { key: 'ddd MM/DD', value: 'Tue 03/25', hint: 'ddd MM/DD' }, + { key: 'ddd D/M', value: 'Tue 25/3', hint: 'ddd D/M' }, + { key: 'ddd DD/MM', value: 'Tue 25/03', hint: 'ddd DD/MM' } ]; const shortDateFormatOptions = [ - { key: 'MMM D YYYY', value: 'Mar 25 2014' }, - { key: 'DD MMM YYYY', value: '25 Mar 2014' }, - { key: 'MM/D/YYYY', value: '03/25/2014' }, - { key: 'MM/DD/YYYY', value: '03/25/2014' }, - { key: 'DD/MM/YYYY', value: '25/03/2014' }, - { key: 'YYYY-MM-DD', value: '2014-03-25' } + { key: 'MMM D YYYY', value: 'Mar 25 2014', hint: 'MMM D YYYY' }, + { key: 'DD MMM YYYY', value: '25 Mar 2014', hint: 'DD MMM YYYY' }, + { key: 'MM/D/YYYY', value: '03/25/2014', hint: 'MM/D/YYYY' }, + { key: 'MM/DD/YYYY', value: '03/25/2014', hint: 'MM/DD/YYYY' }, + { key: 'DD/MM/YYYY', value: '25/03/2014', hint: 'DD/MM/YYYY' }, + { key: 'YYYY-MM-DD', value: '2014-03-25', hint: 'YYYY-MM-DD' } ]; const longDateFormatOptions = [ @@ -176,6 +176,13 @@ class UISettings extends Component { helpTextWarning={translate('UILanguageHelpTextWarning')} onChange={onInputChange} {...settings.uiLanguage} + errors={ + languages.some((language) => language.key === settings.uiLanguage.value) ? + settings.uiLanguage.errors : + [ + ...settings.uiLanguage.errors, + { message: translate('InvalidUILanguage') } + ]} /> diff --git a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js index a80ee1e45..f5ef10a4d 100644 --- a/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js +++ b/frontend/src/Store/Actions/Creators/createFetchServerSideCollectionHandler.js @@ -6,6 +6,8 @@ import getSectionState from 'Utilities/State/getSectionState'; import { set, updateServerSideCollection } from '../baseActions'; function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter) { + const [baseSection] = section.split('.'); + return function(getState, payload, dispatch) { dispatch(set({ section, isFetching: true })); @@ -25,10 +27,13 @@ function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter const { selectedFilterKey, - filters, - customFilters + filters } = sectionState; + const customFilters = getState().customFilters.items.filter((customFilter) => { + return customFilter.type === section || customFilter.type === baseSection; + }); + const selectedFilters = findSelectedFilters(selectedFilterKey, filters, customFilters); selectedFilters.forEach((filter) => { @@ -37,7 +42,8 @@ function createFetchServerSideCollectionHandler(section, url, fetchDataAugmenter const promise = createAjaxRequest({ url, - data + data, + traditional: true }).request; promise.done((response) => { diff --git a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js index ca26883fb..e35157dbd 100644 --- a/frontend/src/Store/Actions/Creators/createTestProviderHandler.js +++ b/frontend/src/Store/Actions/Creators/createTestProviderHandler.js @@ -1,8 +1,11 @@ +import $ from 'jquery'; +import _ from 'lodash'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import getProviderState from 'Utilities/State/getProviderState'; import { set } from '../baseActions'; const abortCurrentRequests = {}; +let lastTestData = null; export function createCancelTestProviderHandler(section) { return function(getState, payload, dispatch) { @@ -17,10 +20,25 @@ function createTestProviderHandler(section, url) { return function(getState, payload, dispatch) { dispatch(set({ section, isTesting: true })); - const testData = getProviderState(payload, getState, section); + const { + queryParams = {}, + ...otherPayload + } = payload; + + const testData = getProviderState({ ...otherPayload }, getState, section); + const params = { ...queryParams }; + + // If the user is re-testing the same provider without changes + // force it to be tested. + + if (_.isEqual(testData, lastTestData)) { + params.forceTest = true; + } + + lastTestData = testData; const ajaxOptions = { - url: `${url}/test`, + url: `${url}/test?${$.param(params, true)}`, method: 'POST', contentType: 'application/json', dataType: 'json', @@ -32,6 +50,8 @@ function createTestProviderHandler(section, url) { abortCurrentRequests[section] = abortRequest; request.done((data) => { + lastTestData = null; + dispatch(set({ section, isTesting: false, diff --git a/frontend/src/Store/Actions/Settings/appProfiles.js b/frontend/src/Store/Actions/Settings/appProfiles.js index 357588b75..92a48e0b8 100644 --- a/frontend/src/Store/Actions/Settings/appProfiles.js +++ b/frontend/src/Store/Actions/Settings/appProfiles.js @@ -7,6 +7,7 @@ import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/create import { createThunk } from 'Store/thunks'; import getSectionState from 'Utilities/State/getSectionState'; import updateSectionState from 'Utilities/State/updateSectionState'; +import translate from 'Utilities/String/translate'; // // Variables @@ -87,7 +88,7 @@ export default { const pendingChanges = { ...item, id: 0 }; delete pendingChanges.id; - pendingChanges.name = `${pendingChanges.name} - Copy`; + pendingChanges.name = translate('DefaultNameCopiedProfile', { name: pendingChanges.name }); newState.pendingChanges = pendingChanges; return updateSectionState(state, section, newState); diff --git a/frontend/src/Store/Actions/Settings/applications.js b/frontend/src/Store/Actions/Settings/applications.js index 3db520525..53a008b0c 100644 --- a/frontend/src/Store/Actions/Settings/applications.js +++ b/frontend/src/Store/Actions/Settings/applications.js @@ -1,4 +1,5 @@ import { createAction } from 'redux-actions'; +import { sortDirections } from 'Helpers/Props'; import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; @@ -7,6 +8,7 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import { createThunk } from 'Store/thunks'; @@ -30,9 +32,10 @@ export const CANCEL_SAVE_APPLICATION = 'settings/applications/cancelSaveApplicat export const DELETE_APPLICATION = 'settings/applications/deleteApplication'; export const TEST_APPLICATION = 'settings/applications/testApplication'; export const CANCEL_TEST_APPLICATION = 'settings/applications/cancelTestApplication'; -export const TEST_ALL_APPLICATIONS = 'indexers/testAllApplications'; +export const TEST_ALL_APPLICATIONS = 'settings/applications/testAllApplications'; export const BULK_EDIT_APPLICATIONS = 'settings/applications/bulkEditApplications'; export const BULK_DELETE_APPLICATIONS = 'settings/applications/bulkDeleteApplications'; +export const SET_MANAGE_APPLICATIONS_SORT = 'settings/applications/setManageApplicationsSort'; // // Action Creators @@ -49,6 +52,7 @@ export const cancelTestApplication = createThunk(CANCEL_TEST_APPLICATION); export const testAllApplications = createThunk(TEST_ALL_APPLICATIONS); export const bulkEditApplications = createThunk(BULK_EDIT_APPLICATIONS); export const bulkDeleteApplications = createThunk(BULK_DELETE_APPLICATIONS); +export const setManageApplicationsSort = createAction(SET_MANAGE_APPLICATIONS_SORT); export const setApplicationValue = createAction(SET_APPLICATION_VALUE, (payload) => { return { @@ -88,7 +92,14 @@ export default { isTesting: false, isTestingAll: false, items: [], - pendingChanges: {} + pendingChanges: {}, + sortKey: 'name', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + name: function(item) { + return item.name.toLowerCase(); + } + } }, // @@ -121,7 +132,10 @@ export default { return selectedSchema; }); - } + }, + + [SET_MANAGE_APPLICATIONS_SORT]: createSetClientSideCollectionSortReducer(section) + } }; diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js index 990b7008e..56784d5d0 100644 --- a/frontend/src/Store/Actions/Settings/downloadClients.js +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -1,4 +1,5 @@ import { createAction } from 'redux-actions'; +import { sortDirections } from 'Helpers/Props'; import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; @@ -7,6 +8,7 @@ import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHand import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createSetClientSideCollectionSortReducer from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import { createThunk } from 'Store/thunks'; @@ -34,6 +36,7 @@ export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestD export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients'; export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients'; export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients'; +export const SET_MANAGE_DOWNLOAD_CLIENTS_SORT = 'settings/downloadClients/setManageDownloadClientsSort'; // // Action Creators @@ -50,6 +53,7 @@ export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT) export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS); export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS); export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS); +export const setManageDownloadClientsSort = createAction(SET_MANAGE_DOWNLOAD_CLIENTS_SORT); export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => { return { @@ -89,7 +93,14 @@ export default { isTesting: false, isTestingAll: false, items: [], - pendingChanges: {} + pendingChanges: {}, + sortKey: 'name', + sortDirection: sortDirections.ASCENDING, + sortPredicates: { + name: function(item) { + return item.name.toLowerCase(); + } + } }, // @@ -147,7 +158,10 @@ export default { return selectedSchema; }); - } + }, + + [SET_MANAGE_DOWNLOAD_CLIENTS_SORT]: createSetClientSideCollectionSortReducer(section) + } }; diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js index 2e133f61e..c324fe227 100644 --- a/frontend/src/Store/Actions/historyActions.js +++ b/frontend/src/Store/Actions/historyActions.js @@ -1,5 +1,5 @@ import { createAction } from 'redux-actions'; -import { filterTypes, sortDirections } from 'Helpers/Props'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers'; @@ -82,6 +82,12 @@ export const defaultState = { isSortable: false, isVisible: false }, + { + name: 'host', + label: () => translate('Host'), + isSortable: false, + isVisible: false + }, { name: 'elapsedTime', label: () => translate('ElapsedTime'), @@ -159,6 +165,27 @@ export const defaultState = { } ] } + ], + + filterBuilderProps: [ + { + name: 'eventType', + label: () => translate('EventType'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.HISTORY_EVENT_TYPE + }, + { + name: 'indexerIds', + label: () => translate('Indexer'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.INDEXER + }, + { + name: 'successful', + label: () => translate('Successful'), + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.BOOL + } ] }; diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 98db37faf..a25144d5a 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -4,6 +4,7 @@ import * as commands from './commandActions'; import * as customFilters from './customFilterActions'; import * as history from './historyActions'; import * as indexers from './indexerActions'; +import * as indexerHistory from './indexerHistoryActions'; import * as indexerIndex from './indexerIndexActions'; import * as indexerStats from './indexerStatsActions'; import * as indexerStatus from './indexerStatusActions'; @@ -28,6 +29,7 @@ export default [ releases, localization, indexers, + indexerHistory, indexerIndex, indexerStats, indexerStatus, diff --git a/frontend/src/Store/Actions/indexerActions.js b/frontend/src/Store/Actions/indexerActions.js index 0325891be..e11051c2f 100644 --- a/frontend/src/Store/Actions/indexerActions.js +++ b/frontend/src/Store/Actions/indexerActions.js @@ -1,11 +1,15 @@ import _ from 'lodash'; import { createAction } from 'redux-actions'; -import { sortDirections } from 'Helpers/Props'; +import { filterTypePredicates, sortDirections } from 'Helpers/Props'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; -import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createSaveProviderHandler, { + createCancelSaveProviderHandler +} from 'Store/Actions/Creators/createSaveProviderHandler'; import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; -import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createTestProviderHandler, { + createCancelTestProviderHandler +} from 'Store/Actions/Creators/createTestProviderHandler'; import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import { createThunk, handleThunks } from 'Store/thunks'; @@ -16,6 +20,7 @@ import translate from 'Utilities/String/translate'; import createBulkEditItemHandler from './Creators/createBulkEditItemHandler'; import createBulkRemoveItemHandler from './Creators/createBulkRemoveItemHandler'; import createHandleActions from './Creators/createHandleActions'; +import createClearReducer from './Creators/Reducers/createClearReducer'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; // @@ -69,15 +74,68 @@ export const filterPredicates = { item.fields.find((field) => field.name === 'vipExpiration')?.value ?? null; return dateFilterPredicate(vipExpiration, filterValue, type); + }, + + categories: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + + const { categories = [] } = item.capabilities || {}; + + const categoryList = categories + .filter((category) => category.id < 100000) + .reduce((acc, element) => { + acc.push(element.id); + + if (element.subCategories && element.subCategories.length > 0) { + element.subCategories.forEach((subCat) => { + acc.push(subCat.id); + }); + } + + return acc; + }, []); + + return predicate(categoryList, filterValue); } }; export const sortPredicates = { - vipExpiration: function(item) { - const vipExpiration = - item.fields.find((field) => field.name === 'vipExpiration')?.value ?? ''; + status: function({ enable, redirect }) { + let result = 0; - return vipExpiration; + if (redirect) { + result++; + } + + if (enable) { + result += 2; + } + + return result; + }, + + vipExpiration: function({ fields = [] }) { + return fields.find((field) => field.name === 'vipExpiration')?.value ?? ''; + }, + + minimumSeeders: function({ fields = [] }) { + return fields.find((field) => field.name === 'torrentBaseSettings.appMinimumSeeders')?.value ?? undefined; + }, + + seedRatio: function({ fields = [] }) { + return fields.find((field) => field.name === 'torrentBaseSettings.seedRatio')?.value ?? undefined; + }, + + seedTime: function({ fields = [] }) { + return fields.find((field) => field.name === 'torrentBaseSettings.seedTime')?.value ?? undefined; + }, + + packSeedTime: function({ fields = [] }) { + return fields.find((field) => field.name === 'torrentBaseSettings.packSeedTime')?.value ?? undefined; + }, + + preferMagnetUrl: function({ fields = [] }) { + return fields.find((field) => field.name === 'torrentBaseSettings.preferMagnetUrl')?.value ?? undefined; } }; @@ -88,6 +146,7 @@ export const FETCH_INDEXERS = 'indexers/fetchIndexers'; export const FETCH_INDEXER_SCHEMA = 'indexers/fetchIndexerSchema'; export const SELECT_INDEXER_SCHEMA = 'indexers/selectIndexerSchema'; export const SET_INDEXER_SCHEMA_SORT = 'indexers/setIndexerSchemaSort'; +export const CLEAR_INDEXER_SCHEMA = 'indexers/clearIndexerSchema'; export const CLONE_INDEXER = 'indexers/cloneIndexer'; export const SET_INDEXER_VALUE = 'indexers/setIndexerValue'; export const SET_INDEXER_FIELD_VALUE = 'indexers/setIndexerFieldValue'; @@ -107,6 +166,7 @@ export const fetchIndexers = createThunk(FETCH_INDEXERS); export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA); export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA); export const setIndexerSchemaSort = createAction(SET_INDEXER_SCHEMA_SORT); +export const clearIndexerSchema = createAction(CLEAR_INDEXER_SCHEMA); export const cloneIndexer = createAction(CLONE_INDEXER); export const saveIndexer = createThunk(SAVE_INDEXER); @@ -192,6 +252,8 @@ export const reducers = createHandleActions({ }); }, + [CLEAR_INDEXER_SCHEMA]: createClearReducer(schemaSection, defaultState), + [CLONE_INDEXER]: function(state, { payload }) { const id = payload.id; const newState = getSectionState(state, section); @@ -203,14 +265,20 @@ export const reducers = createHandleActions({ delete selectedSchema.name; selectedSchema.fields = selectedSchema.fields.map((field) => { - return { ...field }; + const newField = { ...field }; + + if (newField.privacy === 'apiKey' || newField.privacy === 'password') { + newField.value = ''; + } + + return newField; }); newState.selectedSchema = selectedSchema; // Set the name in pendingChanges newState.pendingChanges = { - name: `${item.name} - Copy` + name: translate('DefaultNameCopiedProfile', { name: item.name }) }; return updateSectionState(state, section, newState); diff --git a/frontend/src/Store/Actions/indexerHistoryActions.js b/frontend/src/Store/Actions/indexerHistoryActions.js new file mode 100644 index 000000000..2cec678e1 --- /dev/null +++ b/frontend/src/Store/Actions/indexerHistoryActions.js @@ -0,0 +1,81 @@ +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, update } from './baseActions'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'indexerHistory'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_INDEXER_HISTORY = 'indexerHistory/fetchIndexerHistory'; +export const CLEAR_INDEXER_HISTORY = 'indexerHistory/clearIndexerHistory'; + +// +// Action Creators + +export const fetchIndexerHistory = createThunk(FETCH_INDEXER_HISTORY); +export const clearIndexerHistory = createAction(CLEAR_INDEXER_HISTORY); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_INDEXER_HISTORY]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const promise = createAjaxRequest({ + url: '/history/indexer', + data: payload + }).request; + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_INDEXER_HISTORY]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/indexerIndexActions.js b/frontend/src/Store/Actions/indexerIndexActions.js index c55e46031..a002d9b41 100644 --- a/frontend/src/Store/Actions/indexerIndexActions.js +++ b/frontend/src/Store/Actions/indexerIndexActions.js @@ -37,12 +37,18 @@ export const defaultState = { isVisible: true, isModifiable: false }, + { + name: 'id', + columnLabel: () => translate('IndexerId'), + label: () => translate('Id'), + isSortable: true, + isVisible: false + }, { name: 'sortName', label: () => translate('IndexerName'), isSortable: true, - isVisible: true, - isModifiable: false + isVisible: true }, { name: 'protocol', @@ -110,6 +116,12 @@ export const defaultState = { isSortable: true, isVisible: false }, + { + name: 'preferMagnetUrl', + label: () => translate('PreferMagnetUrl'), + isSortable: true, + isVisible: false + }, { name: 'tags', label: () => translate('Tags'), @@ -180,6 +192,12 @@ export const defaultState = { type: filterBuilderTypes.EXACT, valueType: filterBuilderValueTypes.APP_PROFILE }, + { + name: 'categories', + label: () => translate('Categories'), + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.CATEGORY + }, { name: 'tags', label: () => translate('Tags'), diff --git a/frontend/src/Store/Actions/indexerStatsActions.js b/frontend/src/Store/Actions/indexerStatsActions.js index 9171ee340..06c9586b5 100644 --- a/frontend/src/Store/Actions/indexerStatsActions.js +++ b/frontend/src/Store/Actions/indexerStatsActions.js @@ -3,6 +3,7 @@ import { batchActions } from 'redux-batched-actions'; import { filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; import translate from 'Utilities/String/translate'; import { set, update } from './baseActions'; import createHandleActions from './Creators/createHandleActions'; @@ -55,19 +56,27 @@ export const defaultState = { filterBuilderProps: [ { - name: 'startDate', - label: 'Start Date', - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.DATE + name: 'indexers', + label: () => translate('Indexers'), + type: filterBuilderTypes.CONTAINS, + valueType: filterBuilderValueTypes.INDEXER }, { - name: 'endDate', - label: 'End Date', + name: 'protocols', + label: () => translate('Protocols'), type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.DATE + valueType: filterBuilderValueTypes.PROTOCOL + }, + { + name: 'tags', + label: () => translate('Tags'), + type: filterBuilderTypes.CONTAINS, + valueType: filterBuilderValueTypes.TAG } ], + selectedFilterKey: 'all' + }; export const persistState = [ @@ -81,6 +90,10 @@ export const persistState = [ export const FETCH_INDEXER_STATS = 'indexerStats/fetchIndexerStats'; export const SET_INDEXER_STATS_FILTER = 'indexerStats/setIndexerStatsFilter'; +function getCustomFilters(state, type) { + return state.customFilters.items.filter((customFilter) => customFilter.type === type); +} + // // Action Creators @@ -94,23 +107,39 @@ export const actionHandlers = handleThunks({ [FETCH_INDEXER_STATS]: function(getState, payload, dispatch) { const state = getState(); const indexerStats = state.indexerStats; + const customFilters = getCustomFilters(state, section); + const selectedFilters = findSelectedFilters(indexerStats.selectedFilterKey, indexerStats.filters, customFilters); const requestParams = { endDate: moment().toISOString() }; + selectedFilters.forEach((selectedFilter) => { + if (selectedFilter.key === 'indexers') { + requestParams.indexers = selectedFilter.value.join(','); + } + + if (selectedFilter.key === 'protocols') { + requestParams.protocols = selectedFilter.value.join(','); + } + + if (selectedFilter.key === 'tags') { + requestParams.tags = selectedFilter.value.join(','); + } + }); + if (indexerStats.selectedFilterKey !== 'all') { - let dayCount = 7; + if (indexerStats.selectedFilterKey === 'lastSeven') { + requestParams.startDate = moment().add(-7, 'days').endOf('day').toISOString(); + } if (indexerStats.selectedFilterKey === 'lastThirty') { - dayCount = 30; + requestParams.startDate = moment().add(-30, 'days').endOf('day').toISOString(); } if (indexerStats.selectedFilterKey === 'lastNinety') { - dayCount = 90; + requestParams.startDate = moment().add(-90, 'days').endOf('day').toISOString(); } - - requestParams.startDate = moment().add(-dayCount, 'days').endOf('day').toISOString(); } const basesAttrs = { diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js index d8618a15a..fd2fe441b 100644 --- a/frontend/src/Store/Actions/releaseActions.js +++ b/frontend/src/Store/Actions/releaseActions.js @@ -1,7 +1,9 @@ import $ from 'jquery'; +import React from 'react'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; -import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypePredicates, icons, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import getSectionState from 'Utilities/State/getSectionState'; @@ -110,7 +112,11 @@ export const defaultState = { }, { name: 'indexerFlags', - columnLabel: 'Indexer Flags', + columnLabel: () => translate('IndexerFlags'), + label: React.createElement(Icon, { + name: icons.FLAG, + title: () => translate('IndexerFlags') + }), isSortable: true, isVisible: true }, @@ -163,6 +169,18 @@ export const defaultState = { } ], + filterPredicates: { + peers: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + + const seeders = item.seeders || 0; + const leechers = item.leechers || 0; + const peers = seeders + leechers; + + return predicate(peers, filterValue); + } + }, + filterBuilderProps: [ { name: 'title', @@ -351,8 +369,9 @@ export const actionHandlers = handleThunks({ promise.done((data) => { dispatch(batchActions([ - ...data.map((release) => { + ...data.map(({ guid }) => { return updateRelease({ + guid, isGrabbing: false, isGrabbed: true, grabError: null @@ -382,7 +401,16 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ [CLEAR_RELEASES]: (state) => { - return Object.assign({}, state, defaultState); + const { + sortKey, + sortDirection, + customFilters, + selectedFilterKey, + columns, + ...otherDefaultState + } = defaultState; + + return Object.assign({}, state, otherDefaultState); }, [UPDATE_RELEASE]: (state, { payload }) => { diff --git a/frontend/src/Store/Actions/systemActions.js b/frontend/src/Store/Actions/systemActions.js index 92360b589..75d2595cf 100644 --- a/frontend/src/Store/Actions/systemActions.js +++ b/frontend/src/Store/Actions/systemActions.js @@ -110,7 +110,6 @@ export const defaultState = { { name: 'actions', columnLabel: () => translate('Actions'), - isSortable: true, isVisible: true, isModifiable: false } diff --git a/frontend/src/Store/Selectors/createDimensionsSelector.js b/frontend/src/Store/Selectors/createDimensionsSelector.ts similarity index 69% rename from frontend/src/Store/Selectors/createDimensionsSelector.js rename to frontend/src/Store/Selectors/createDimensionsSelector.ts index ce26b2e2c..b9602cb02 100644 --- a/frontend/src/Store/Selectors/createDimensionsSelector.js +++ b/frontend/src/Store/Selectors/createDimensionsSelector.ts @@ -1,8 +1,9 @@ import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; function createDimensionsSelector() { return createSelector( - (state) => state.app.dimensions, + (state: AppState) => state.app.dimensions, (dimensions) => { return dimensions; } diff --git a/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts b/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts new file mode 100644 index 000000000..3a581587b --- /dev/null +++ b/frontend/src/Store/Selectors/createEnabledDownloadClientsSelector.ts @@ -0,0 +1,26 @@ +import { createSelector } from 'reselect'; +import { DownloadClientAppState } from 'App/State/SettingsAppState'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import DownloadClient from 'typings/DownloadClient'; +import sortByProp from 'Utilities/Array/sortByProp'; + +export default function createEnabledDownloadClientsSelector( + protocol: DownloadProtocol +) { + return createSelector( + createSortedSectionSelector( + 'settings.downloadClients', + sortByProp('name') + ), + (downloadClients: DownloadClientAppState) => { + const { isFetching, isPopulated, error, items } = downloadClients; + + const clients = items.filter( + (item) => item.protocol === protocol && item.enable + ); + + return { isFetching, isPopulated, error, items: clients }; + } + ); +} diff --git a/frontend/src/Store/Selectors/createSortedSectionSelector.js b/frontend/src/Store/Selectors/createSortedSectionSelector.ts similarity index 68% rename from frontend/src/Store/Selectors/createSortedSectionSelector.js rename to frontend/src/Store/Selectors/createSortedSectionSelector.ts index 331d890c9..abee01f75 100644 --- a/frontend/src/Store/Selectors/createSortedSectionSelector.js +++ b/frontend/src/Store/Selectors/createSortedSectionSelector.ts @@ -1,14 +1,18 @@ import { createSelector } from 'reselect'; import getSectionState from 'Utilities/State/getSectionState'; -function createSortedSectionSelector(section, comparer) { +function createSortedSectionSelector( + section: string, + comparer: (a: T, b: T) => number +) { return createSelector( (state) => state, (state) => { const sectionState = getSectionState(state, section, true); + return { ...sectionState, - items: [...sectionState.items].sort(comparer) + items: [...sectionState.items].sort(comparer), }; } ); diff --git a/frontend/src/Styles/Themes/dark.js b/frontend/src/Styles/Themes/dark.js index 06c0304ea..a7cbb6de0 100644 --- a/frontend/src/Styles/Themes/dark.js +++ b/frontend/src/Styles/Themes/dark.js @@ -187,7 +187,8 @@ module.exports = { // // Charts - failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'], - chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'], - chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'] + chartBackgroundColor: '#262626', + failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'].join(','), + chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'].join(','), + chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'].join(',') }; diff --git a/frontend/src/Styles/Themes/index.js b/frontend/src/Styles/Themes/index.js index d93c5dd8c..4dec39164 100644 --- a/frontend/src/Styles/Themes/index.js +++ b/frontend/src/Styles/Themes/index.js @@ -2,7 +2,7 @@ import * as dark from './dark'; import * as light from './light'; const defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches; -const auto = defaultDark ? { ...dark } : { ...light }; +const auto = defaultDark ? dark : light; export default { auto, diff --git a/frontend/src/Styles/Themes/light.js b/frontend/src/Styles/Themes/light.js index 5ff84460c..f88070a0f 100644 --- a/frontend/src/Styles/Themes/light.js +++ b/frontend/src/Styles/Themes/light.js @@ -187,7 +187,8 @@ module.exports = { // // Charts - failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'], - chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'], - chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'] + chartBackgroundColor: '#fff', + failedColors: ['#ffbeb2', '#feb4a6', '#fdab9b', '#fca290', '#fb9984', '#fa8f79', '#f9856e', '#f77b66', '#f5715d', '#f36754', '#f05c4d', '#ec5049', '#e74545', '#e13b42', '#da323f', '#d3293d', '#ca223c', '#c11a3b', '#b8163a', '#ae123a'].join(','), + chartColorsDiversified: ['#90caf9', '#f4d166', '#ff8a65', '#ce93d8', '#80cba9', '#ffab91', '#8097ea', '#bcaaa4', '#a57583', '#e4e498', '#9e96af', '#c6ab81', '#6972c6', '#619fc6', '#81ad81', '#f48fb1', '#82afca', '#b5b071', '#8b959b', '#7ec0b4'].join(','), + chartColors: ['#f4d166', '#f6c760', '#f8bc58', '#f8b252', '#f7a84a', '#f69e41', '#f49538', '#f38b2f', '#f28026', '#f0751e', '#eb6c1c', '#e4641e', '#de5d1f', '#d75521', '#cf4f22', '#c64a22', '#bc4623', '#b24223', '#a83e24', '#9e3a26'].join(',') }; diff --git a/frontend/src/Styles/Variables/fonts.js b/frontend/src/Styles/Variables/fonts.js index 3b0077c5a..def48f28e 100644 --- a/frontend/src/Styles/Variables/fonts.js +++ b/frontend/src/Styles/Variables/fonts.js @@ -2,7 +2,6 @@ module.exports = { // Families defaultFontFamily: 'Roboto, "open sans", "Helvetica Neue", Helvetica, Arial, sans-serif', monoSpaceFontFamily: '"Ubuntu Mono", Menlo, Monaco, Consolas, "Courier New", monospace;', - passwordFamily: 'text-security-disc', // Sizes extraSmallFontSize: '11px', diff --git a/frontend/src/System/Backup/BackupRow.js b/frontend/src/System/Backup/BackupRow.js index 82fd3b50f..39f7f1123 100644 --- a/frontend/src/System/Backup/BackupRow.js +++ b/frontend/src/System/Backup/BackupRow.js @@ -4,7 +4,7 @@ import Icon from 'Components/Icon'; import IconButton from 'Components/Link/IconButton'; import Link from 'Components/Link/Link'; import ConfirmModal from 'Components/Modal/ConfirmModal'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; import { icons, kinds } from 'Helpers/Props'; @@ -110,12 +110,13 @@ class BackupRow extends Component { {formatBytes(size)} - @@ -138,7 +139,9 @@ class BackupRow extends Component { isOpen={isConfirmDeleteModalOpen} kind={kinds.DANGER} title={translate('DeleteBackup')} - message={translate('DeleteBackupMessageText', { name })} + message={translate('DeleteBackupMessageText', { + name + })} confirmLabel={translate('Delete')} onConfirm={this.onConfirmDeletePress} onCancel={this.onConfirmDeleteModalClose} diff --git a/frontend/src/System/Backup/Backups.js b/frontend/src/System/Backup/Backups.js index 8f7a5b0a5..ede2f97f6 100644 --- a/frontend/src/System/Backup/Backups.js +++ b/frontend/src/System/Backup/Backups.js @@ -109,7 +109,7 @@ class Backups extends Component { { !isFetching && !!error && - {translate('UnableToLoadBackups')} + {translate('BackupsLoadError')} } diff --git a/frontend/src/System/Backup/RestoreBackupModalContent.js b/frontend/src/System/Backup/RestoreBackupModalContent.js index 150c46ad6..9b5daa9f4 100644 --- a/frontend/src/System/Backup/RestoreBackupModalContent.js +++ b/frontend/src/System/Backup/RestoreBackupModalContent.js @@ -14,7 +14,7 @@ import styles from './RestoreBackupModalContent.css'; function getErrorMessage(error) { if (!error || !error.responseJSON || !error.responseJSON.message) { - return 'Error restoring backup'; + return translate('ErrorRestoringBackup'); } return error.responseJSON.message; @@ -146,7 +146,9 @@ class RestoreBackupModalContent extends Component { { - !!id && `Would you like to restore the backup '${name}'?` + !!id && translate('WouldYouLikeToRestoreBackup', { + name + }) } { @@ -203,7 +205,7 @@ class RestoreBackupModalContent extends Component {
- Note: Prowlarr will automatically restart and reload the UI during the restore process. + {translate('RestartReloadNote')}