diff --git a/.devcontainer/Sonarr.code-workspace b/.devcontainer/Sonarr.code-workspace new file mode 100644 index 00000000000..a46158e4494 --- /dev/null +++ b/.devcontainer/Sonarr.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 00000000000..629a2aa2121 --- /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": "Sonarr", + "image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "nodeGypDependencies": true, + "version": "16", + "nvmVersion": "latest" + } + }, + "forwardPorts": [8989], + "customizations": { + "vscode": { + "extensions": ["esbenp.prettier-vscode"] + } + } +} diff --git a/.editorconfig b/.editorconfig index 7c003bbdb5c..6d6c3a13f85 100644 --- a/.editorconfig +++ b/.editorconfig @@ -40,9 +40,18 @@ dotnet_naming_style.instance_field_style.capitalization = camel_case dotnet_naming_style.instance_field_style.required_prefix = _ # Prefer "var" everywhere -csharp_style_var_for_built_in_types = true:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true +csharp_style_var_when_type_is_apparent = true +csharp_style_var_elsewhere = true +# Prefer "out" variables to be declared inline +csharp_style_inlined_variable_declaration = true + +# Using directive is unnecessary. +dotnet_diagnostic.IDE0005.severity = error +# Use var instead of explicit type +dotnet_diagnostic.IDE0007.severity = error +# Inline variable declaration +dotnet_diagnostic.IDE0018.severity = error # Stylecop Rules dotnet_diagnostic.SA0001.severity = none @@ -167,6 +176,7 @@ dotnet_diagnostic.CA1309.severity = suggestion dotnet_diagnostic.CA1310.severity = suggestion dotnet_diagnostic.CA1401.severity = suggestion dotnet_diagnostic.CA1416.severity = suggestion +dotnet_diagnostic.CA1419.severity = suggestion dotnet_diagnostic.CA1507.severity = suggestion dotnet_diagnostic.CA1508.severity = suggestion dotnet_diagnostic.CA1707.severity = suggestion @@ -182,9 +192,6 @@ dotnet_diagnostic.CA1720.severity = suggestion dotnet_diagnostic.CA1721.severity = suggestion dotnet_diagnostic.CA1724.severity = suggestion dotnet_diagnostic.CA1725.severity = suggestion -dotnet_diagnostic.CA1801.severity = suggestion -dotnet_diagnostic.CA1802.severity = suggestion -dotnet_diagnostic.CA1805.severity = suggestion dotnet_diagnostic.CA1806.severity = suggestion dotnet_diagnostic.CA1810.severity = suggestion dotnet_diagnostic.CA1812.severity = suggestion @@ -196,13 +203,11 @@ dotnet_diagnostic.CA1819.severity = suggestion dotnet_diagnostic.CA1822.severity = suggestion dotnet_diagnostic.CA1823.severity = suggestion dotnet_diagnostic.CA1824.severity = suggestion +dotnet_diagnostic.CA1825.severity = suggestion dotnet_diagnostic.CA2000.severity = suggestion dotnet_diagnostic.CA2002.severity = suggestion dotnet_diagnostic.CA2007.severity = suggestion dotnet_diagnostic.CA2008.severity = suggestion -dotnet_diagnostic.CA2009.severity = suggestion -dotnet_diagnostic.CA2010.severity = suggestion -dotnet_diagnostic.CA2011.severity = suggestion dotnet_diagnostic.CA2012.severity = suggestion dotnet_diagnostic.CA2013.severity = suggestion dotnet_diagnostic.CA2100.severity = suggestion @@ -233,6 +238,8 @@ dotnet_diagnostic.CA2243.severity = suggestion dotnet_diagnostic.CA2244.severity = suggestion dotnet_diagnostic.CA2245.severity = suggestion dotnet_diagnostic.CA2246.severity = suggestion +dotnet_diagnostic.CA2249.severity = suggestion +dotnet_diagnostic.CA2251.severity = suggestion dotnet_diagnostic.CA3061.severity = suggestion dotnet_diagnostic.CA3075.severity = suggestion dotnet_diagnostic.CA3076.severity = suggestion @@ -262,7 +269,7 @@ dotnet_diagnostic.CA5397.severity = suggestion dotnet_diagnostic.SYSLIB0006.severity = none -[*.{js,html,js,hbs,less,css}] +[*.{js,html,hbs,less,css,ts,tsx}] charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true diff --git a/.esprintrc b/.esprintrc deleted file mode 100644 index 9330e00d1f3..00000000000 --- a/.esprintrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "paths": [ - "frontend/src/**/*.js" - ], - "ignored": [ - "**/node_modules/**/*" - ], - "port": 5004 -} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c2979607b9e..4e4eedb6644 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug Report -description: 'Support Requests will be closed immediately, if you are not 100% certain this is a bug please go to our Reddit, Discord, Forums, or IRC first. Sonarr v2 is EOL & unsupported.' +description: 'Only bug reports for v4 will be accepted, older versions are no longer receiving bug fixes and support issues will be closed immediately.' labels: ['needs-triage'] body: - type: checkboxes @@ -37,17 +37,19 @@ body: label: Environment description: | examples: - - **OS**: Ubuntu 20.04 - - **Sonarr**: Sonarr 3.0.6.1265 + - **OS**: Ubuntu 22.04 + - **Sonarr**: Sonarr 4.0.0.766 - **Docker Install**: Yes - **Using Reverse Proxy**: No - **Browser**: Firefox 90 (If UI related) + - **Database**: Sqlite 3.41.2 value: | - OS: - - Sonarr: - - Docker Install: - - Using Reverse Proxy: - - Browser: + - Sonarr: + - Docker Install: + - Using Reverse Proxy: + - Browser: + - Database: render: markdown validations: required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d8f748ad9dc..25b1761f91f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,14 +1,15 @@ -#### Database Migration -YES | NO - #### Description A few sentences describing the overall goals of the pull request's commits. -#### Todos -- [ ] Tests -- [ ] Wiki Updates + + +#### Screenshots for UI Changes + + +#### Database Migration +YES - ### #### Issues Fixed or Closed by this PR +* Closes # -* diff --git a/.github/actions/archive/action.yml b/.github/actions/archive/action.yml new file mode 100644 index 00000000000..83e8a8ea9eb --- /dev/null +++ b/.github/actions/archive/action.yml @@ -0,0 +1,29 @@ +name: Archive +description: Archive binaries for deployment + +inputs: + os: + description: 'OS that the packaging is running on' + required: true + artifact: + description: 'Binary artifact' + required: true + archive_type: + description: 'File type to use for the final package' + required: true + branch: + description: 'Git branch used for this build' + required: true + major_version: + description: 'Sonarr major version' + required: true + version: + description: 'Sonarr version' + required: true + +runs: + using: 'composite' + steps: + - name: Archive Artifact + uses: thedoctor0/zip-release@0.7.5 + diff --git a/.github/actions/package/action.yml b/.github/actions/package/action.yml new file mode 100644 index 00000000000..99c4d4ff1ac --- /dev/null +++ b/.github/actions/package/action.yml @@ -0,0 +1,78 @@ +name: Package +description: Packages binaries for deployment + +inputs: + platform: + description: 'Binary platform' + required: true + framework: + description: '.net framework' + required: true + artifact: + description: 'Binary artifact' + required: true + branch: + description: 'Git branch used for this build' + required: true + major_version: + description: 'Sonarr major version' + required: true + version: + description: 'Sonarr version' + required: true + +runs: + using: 'composite' + steps: + - name: Download Artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact }} + path: _output + + - name: Download UI Artifact + uses: actions/download-artifact@v4 + with: + name: build_ui + path: _output/UI + + - name: Configure Environment Variables + shell: bash + run: | + echo "FRAMEWORK=${{ inputs.framework }}" >> "$GITHUB_ENV" + echo "BRANCH=${{ inputs.branch }}" >> "$GITHUB_ENV" + echo "SONARR_MAJOR_VERSION=${{ inputs.major_version }}" >> "$GITHUB_ENV" + echo "SONARR_VERSION=${{ inputs.version }}" >> "$GITHUB_ENV" + + - name: Create Packages + shell: bash + run: $GITHUB_ACTION_PATH/package.sh + + - name: Create Windows Installer (x64) + if: ${{ inputs.platform == 'windows' }} + working-directory: distribution/windows/setup + shell: cmd + run: | + SET RUNTIME=win-x64 + + build.bat + + - name: Create Windows Installer (x86) + if: ${{ inputs.platform == 'windows' }} + working-directory: distribution/windows/setup + shell: cmd + run: | + SET RUNTIME=win-x86 + + build.bat + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: release_${{ inputs.platform }} + compression-level: 0 + if-no-files-found: error + path: | + _artifacts/*.exe + _artifacts/*.tar.gz + _artifacts/*.zip diff --git a/.github/actions/package/package.sh b/.github/actions/package/package.sh new file mode 100755 index 00000000000..8dce6058533 --- /dev/null +++ b/.github/actions/package/package.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +outputFolder=_output +artifactsFolder=_artifacts +uiFolder="$outputFolder/UI" +framework="${FRAMEWORK:=net6.0}" + +rm -rf $artifactsFolder +mkdir $artifactsFolder + +for runtime in _output/* +do + name="${runtime##*/}" + folderName="$runtime/$framework" + sonarrFolder="$folderName/Sonarr" + archiveName="Sonarr.$BRANCH.$SONARR_VERSION.$name" + + if [[ "$name" == 'UI' ]]; then + continue + fi + + echo "Creating package for $name" + + echo "Copying UI" + cp -r $uiFolder $sonarrFolder + + echo "Setting permissions" + find $sonarrFolder -name "ffprobe" -exec chmod a+x {} \; + find $sonarrFolder -name "Sonarr" -exec chmod a+x {} \; + find $sonarrFolder -name "Sonarr.Update" -exec chmod a+x {} \; + + if [[ "$name" == *"osx"* ]]; then + echo "Creating macOS package" + + packageName="$name-app" + packageFolder="$outputFolder/$packageName" + + rm -rf $packageFolder + mkdir $packageFolder + + cp -r distribution/macOS/Sonarr.app $packageFolder + mkdir -p $packageFolder/Sonarr.app/Contents/MacOS + + echo "Copying Binaries" + cp -r $sonarrFolder/* $packageFolder/Sonarr.app/Contents/MacOS + + echo "Removing Update Folder" + rm -r $packageFolder/Sonarr.app/Contents/MacOS/Sonarr.Update + + echo "Packaging macOS app Artifact" + (cd $packageFolder; zip -rq "../../$artifactsFolder/$archiveName-app.zip" ./Sonarr.app) + fi + + echo "Packaging Artifact" + if [[ "$name" == *"linux"* ]] || [[ "$name" == *"osx"* ]] || [[ "$name" == *"freebsd"* ]]; then + tar -zcf "./$artifactsFolder/$archiveName.tar.gz" -C $folderName Sonarr + fi + + if [[ "$name" == *"win"* ]]; then + if [ "$RUNNER_OS" = "Windows" ] + then + (cd $folderName; 7z a -tzip "../../../$artifactsFolder/$archiveName.zip" ./Sonarr) + else + (cd $folderName; zip -rq "../../../$artifactsFolder/$archiveName.zip" ./Sonarr) + fi + fi +done diff --git a/.github/actions/publish-test-artifact/action.yml b/.github/actions/publish-test-artifact/action.yml new file mode 100644 index 00000000000..af364204301 --- /dev/null +++ b/.github/actions/publish-test-artifact/action.yml @@ -0,0 +1,18 @@ +name: Publish Test Artifact +description: Publishes a test artifact + +inputs: + framework: + description: '.net framework' + required: true + runtime: + description: '.net runtime' + required: true + +runs: + using: 'composite' + steps: + - uses: actions/upload-artifact@v4 + with: + name: tests-${{ inputs.runtime }} + path: _tests/${{ inputs.framework }}/${{ inputs.runtime }}/publish/**/* diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml new file mode 100644 index 00000000000..bd62f483054 --- /dev/null +++ b/.github/actions/test/action.yml @@ -0,0 +1,87 @@ +name: Test +description: Runs unit/integration tests + +inputs: + use_postgres: + description: 'Whether postgres should be used for the database' + os: + description: 'OS that the tests are running on' + required: true + artifact: + description: 'Test binary artifact' + required: true + pattern: + description: 'Pattern for DLLs' + required: true + filter: + description: 'Filter for tests' + required: true + integration_tests: + description: 'True if running integration tests' + binary_artifact: + description: 'Binary artifact for integration tests' + binary_path: + description: 'Path witin binary artifact for integration tests' + +runs: + using: 'composite' + steps: + - name: Setup .NET + uses: actions/setup-dotnet@v4 + + - name: Setup Postgres + if: ${{ inputs.use_postgres }} + uses: ikalnytskyi/action-setup-postgres@v4 + + - name: Setup Test Variables + shell: bash + run: | + echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}" >> "$GITHUB_ENV" + + - name: Setup Postgres Environment Variables + if: ${{ inputs.use_postgres }} + shell: bash + run: | + echo "Sonarr__Postgres__Host=localhost" >> "$GITHUB_ENV" + echo "Sonarr__Postgres__Port=5432" >> "$GITHUB_ENV" + echo "Sonarr__Postgres__User=postgres" >> "$GITHUB_ENV" + echo "Sonarr__Postgres__Password=postgres" >> "$GITHUB_ENV" + + - name: Download Artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact }} + path: _tests + + - name: Download Binary Artifact + if: ${{ inputs.integration_tests }} + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.binary_artifact }} + path: _output + + - name: Set up binary artifact + if: ${{ inputs.binary_path != '' }} + shell: bash + run: mv ./_output/${{inputs.binary_path}} _tests/bin + + - name: Make executable + if: startsWith(inputs.os, 'windows') != true + shell: bash + run: chmod +x ./_tests/Sonarr.Test.Dummy && chmod +x ./_tests/ffprobe + + - name: Make Sonarr binary executable + if: ${{ inputs.integration_tests && !startsWith(inputs.os, 'windows') }} + shell: bash + run: chmod +x ./_tests/bin/Sonarr + + - name: Run tests + shell: bash + run: dotnet test ./_tests/Sonarr.*.Test.dll --filter "${{ inputs.filter }}" --logger "trx;LogFileName=${{ env.RESULTS_NAME }}.trx" --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true" + + - name: Upload Test Results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: results-${{ env.RESULTS_NAME }} + path: TestResults/*.trx diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000000..fdd66d11acb --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,23 @@ +'connection': + - changed-files: + - any-glob-to-any-file: src/NzbDrone.Core/Notifications/**/* + +'db-migration': + - changed-files: + - any-glob-to-any-file: src/NzbDrone.Core/Datastore/Migration/* + +'download-client': + - changed-files: + - any-glob-to-any-file: src/NzbDrone.Core/Download/Clients/**/* + +'indexer': + - changed-files: + - any-glob-to-any-file: src/NzbDrone.Core/Indexers/**/* + +'parsing': + - changed-files: + - any-glob-to-any-file: src/NzbDrone.Core/Parser/**/* + +'ui-only': + - changed-files: + - any-glob-to-all-files: frontend/**/* diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000000..87eb8f2fe0c --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,9 @@ + changelog: + exclude: + authors: + - Weblate + - SonarrBot + categories: + - title: Changes + labels: + - '*' diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml deleted file mode 100644 index ef9d6a808aa..00000000000 --- a/.github/workflows/lock.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: 'Lock threads' - -on: - workflow_dispatch: - schedule: - - cron: '0 0 * * *' - -jobs: - lock: - runs-on: ubuntu-latest - steps: - - uses: dessant/lock-threads@v3 - with: - github-token: ${{ github.token }} - issue-lock-inactive-days: '90' - issue-exclude-created-before: '' - issue-exclude-labels: 'one-day-maybe' - issue-lock-labels: '' - issue-lock-comment: '' - issue-lock-reason: 'resolved' - process-only: '' diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000000..7a36fefe199 --- /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 00000000000..6ea80f4185c --- /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 Sonarr", + "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/Sonarr", + "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/settings.json b/.vscode/settings.json new file mode 100644 index 00000000000..44aeb4060d9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules\\typescript\\lib" +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000000..cfd41d42f12 --- /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/Sonarr.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/Sonarr.sln", + "-property:GenerateFullPaths=true", + "-consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/Sonarr.sln" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/Logo/Jetbrains/dottrace.svg b/Logo/Jetbrains/dottrace.svg deleted file mode 100644 index b879517cd0c..00000000000 --- a/Logo/Jetbrains/dottrace.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/Jetbrains/jetbrains.svg b/Logo/Jetbrains/jetbrains.svg deleted file mode 100644 index 75d4d217718..00000000000 --- a/Logo/Jetbrains/jetbrains.svg +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/Jetbrains/resharper.svg b/Logo/Jetbrains/resharper.svg deleted file mode 100644 index 24c987a7801..00000000000 --- a/Logo/Jetbrains/resharper.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Logo/Jetbrains/teamcity.svg b/Logo/Jetbrains/teamcity.svg deleted file mode 100644 index ca14b3dc1d4..00000000000 --- a/Logo/Jetbrains/teamcity.svg +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/build.sh b/build.sh index 337e08b997c..ee59d2f6176 100755 --- a/build.sh +++ b/build.sh @@ -4,19 +4,18 @@ set -e outputFolder='_output' testPackageFolder='_tests' artifactsFolder="_artifacts"; +framework="${FRAMEWORK:=net6.0}" ProgressStart() { - echo "##teamcity[blockOpened name='$1']" - echo "##teamcity[progressStart '$1']" + echo "::group::$1" echo "Start '$1'" } ProgressEnd() { echo "Finish '$1'" - echo "##teamcity[progressFinish '$1']" - echo "##teamcity[blockClosed name='$1']" + echo "::endgroup::" } UpdateVersionNumber() @@ -140,7 +139,7 @@ PackageLinux() echo "Adding Sonarr.Mono to UpdatePackage" cp $folder/Sonarr.Mono.* $folder/Sonarr.Update - if [ "$framework" = "net6.0" ]; then + if [ "$framework" = "$framework" ]; then cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update fi @@ -168,7 +167,7 @@ PackageMacOS() echo "Adding Sonarr.Mono to UpdatePackage" cp $folder/Sonarr.Mono.* $folder/Sonarr.Update - if [ "$framework" = "net6.0" ]; then + if [ "$framework" = "$framework" ]; then cp $folder/Mono.Posix.NETStandard.* $folder/Sonarr.Update cp $folder/libMonoPosixHelper.* $folder/Sonarr.Update fi @@ -400,20 +399,20 @@ then if [[ -z "$RID" || -z "$FRAMEWORK" ]]; then - PackageTests "net6.0" "win-x64" - PackageTests "net6.0" "win-x86" - PackageTests "net6.0" "linux-x64" - PackageTests "net6.0" "linux-musl-x64" - PackageTests "net6.0" "osx-x64" + PackageTests "$framework" "win-x64" + PackageTests "$framework" "win-x86" + PackageTests "$framework" "linux-x64" + PackageTests "$framework" "linux-musl-x64" + PackageTests "$framework" "osx-x64" if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ]; then - PackageTests "net6.0" "freebsd-x64" + PackageTests "$framework" "freebsd-x64" fi else PackageTests "$FRAMEWORK" "$RID" fi - UploadTestArtifacts "net6.0" + UploadTestArtifacts "$framework" fi if [ "$FRONTEND" = "YES" ]; @@ -435,22 +434,22 @@ then if [[ -z "$RID" || -z "$FRAMEWORK" ]]; then - Package "net6.0" "win-x64" - Package "net6.0" "win-x86" - Package "net6.0" "linux-x64" - Package "net6.0" "linux-musl-x64" - Package "net6.0" "linux-arm64" - Package "net6.0" "linux-musl-arm64" - Package "net6.0" "linux-arm" - Package "net6.0" "osx-x64" - Package "net6.0" "osx-arm64" + Package "$framework" "win-x64" + Package "$framework" "win-x86" + Package "$framework" "linux-x64" + Package "$framework" "linux-musl-x64" + Package "$framework" "linux-arm64" + Package "$framework" "linux-musl-arm64" + Package "$framework" "linux-arm" + Package "$framework" "osx-x64" + Package "$framework" "osx-arm64" if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ]; then - Package "net6.0" "freebsd-x64" + Package "$framework" "freebsd-x64" fi else Package "$FRAMEWORK" "$RID" fi - UploadArtifacts "net6.0" + UploadArtifacts "$framework" fi diff --git a/distribution/debian.sh b/distribution/debian.sh deleted file mode 100644 index 55ed038d6e2..00000000000 --- a/distribution/debian.sh +++ /dev/null @@ -1,64 +0,0 @@ -fromdos ./debian/* -chmod ugo-x ./debian/* -cp -r ./debian ./debian_backup - -BuildVersion=${dependent_build_number:-4.10.0.999} -BuildBranch=${dependent_build_branch:-main} -BootstrapVersion=`echo "$BuildVersion" | cut -d. -f1,2,3` -BootstrapUpdater="BuiltIn" -PackageUpdater="apt" - -echo Version: "$BuildVersion" Branch: "$BuildBranch" - -rm -r ./sonarr_bin/Sonarr.Update -chmod -R ugo-x,ugo+rwX,go-w ./sonarr_bin/* - -echo Updating changelog for $BuildVersion -sed -i "s:{version}:$BuildVersion:g; s:{branch}:$BuildBranch:g;" debian/changelog -sed -i "s:{version}:$BuildVersion:g; s:{updater}:$PackageUpdater:g" debian/preinst debian/postinst debian/postrm -sed -i '/#BEGIN BUILTIN UPDATER/,/#END BUILTIN UPDATER/d' debian/preinst debian/postinst debian/postrm -echo "# Do Not Edit\nPackageVersion=$BuildVersion\nPackageAuthor=[Team Sonarr](https://sonarr.tv)\nReleaseVersion=$BuildVersion\nUpdateMethod=$PackageUpdater\nBranch=$BuildBranch" > package_info - -echo Running debuild for $BuildVersion -if [ -z "${TEST_OUTPUT}" ]; then - debuild -b -else - debuild -us -uc -b -fi - -# Restore debian directory to the original files -rm -rf ./debian -mv ./debian_backup ./debian - -echo Updating changelog for $BootstrapVersion -sed -i "s:{version}:$BootstrapVersion:g; s:{branch}:$BuildBranch:g;" debian/changelog -sed -i "s:{version}:$BuildVersion:g; s:{updater}:$BootstrapUpdater:g" debian/preinst debian/postinst debian/postrm -sed -i '/#BEGIN BUILTIN UPDATER/d; /#END BUILTIN UPDATER/d' debian/preinst debian/postinst debian/postrm -echo "# Do Not Edit\nPackageVersion=$BootstrapVersion\nPackageAuthor=[Team Sonarr](https://sonarr.tv)\nReleaseVersion=$BuildVersion\nUpdateMethod=$BootstrapUpdater\nBranch=$BuildBranch" > package_info - -echo Running debuild for $BootstrapVersion -if [ -z "${TEST_OUTPUT}" ]; then - debuild -b -else - debuild -us -uc -b -fi - -echo Moving stuff around -mv ../sonarr_*.deb ./ -mv ../sonarr_*.changes ./ -rm ../sonarr_*.build - -if [ -z "${TEST_OUTPUT}" ]; then - echo Signing Package - dpkg-sig -k 884589CE --sign builder "sonarr_${BuildVersion}_all.deb" - dpkg-sig -k 884589CE --sign builder "sonarr_${BootstrapVersion}_all.deb" - - echo running alien - alien -r -v ./*.deb -else - echo "Exporting packages to ${TEST_OUTPUT}" - dpkg -e "sonarr_${BuildVersion}_all.deb" ${TEST_OUTPUT}/sonarr-build - dpkg -e "sonarr_${BootstrapVersion}_all.deb" ${TEST_OUTPUT}/sonarr-release - - cp *.deb ${TEST_OUTPUT}/ -fi diff --git a/distribution/debian/.editorconfig b/distribution/debian/.editorconfig deleted file mode 100644 index ed7f9d30982..00000000000 --- a/distribution/debian/.editorconfig +++ /dev/null @@ -1,6 +0,0 @@ -[*] -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true -indent_style = space -indent_size = 2 diff --git a/distribution/debian/changelog b/distribution/debian/changelog deleted file mode 100644 index e5c3c5745af..00000000000 --- a/distribution/debian/changelog +++ /dev/null @@ -1,5 +0,0 @@ -sonarr ({version}) {branch}; urgency=low - - * Automatic Release. - - -- Sonarr Sun, 28 Jan 2018 00:00:00 -0700 diff --git a/distribution/debian/compat b/distribution/debian/compat deleted file mode 100644 index f599e28b8ab..00000000000 --- a/distribution/debian/compat +++ /dev/null @@ -1 +0,0 @@ -10 diff --git a/distribution/debian/config b/distribution/debian/config deleted file mode 100644 index fb68205fff5..00000000000 --- a/distribution/debian/config +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh -e - -. /usr/share/debconf/confmodule - -db_beginblock -db_input high sonarr/owning_user || true -db_input high sonarr/owning_group || true -db_endblock -db_go - -db_beginblock -db_input low sonarr/owning_umask || true -db_input low sonarr/config_directory || true -db_endblock -db_go - -exit 0 diff --git a/distribution/debian/control b/distribution/debian/control deleted file mode 100644 index 7a07311c7af..00000000000 --- a/distribution/debian/control +++ /dev/null @@ -1,18 +0,0 @@ -Section: web -Priority: optional -Maintainer: Sonarr -Source: sonarr -Homepage: https://sonarr.tv -Vcs-Git: git@github.com:Sonarr/Sonarr.git -Vcs-Browser: https://github.com/Sonarr/Sonarr -Build-Depends: debhelper (>= 9), - dh-systemd (>= 1.5) - -Package: sonarr -Architecture: all -Provides: nzbdrone -Conflicts: nzbdrone -Replaces: nzbdrone -Depends: adduser, libsqlite3-0 (>= 3.7), ${cli:Depends}, ${misc:Depends} -Suggests: sqlite3 (>= 3.7) -Description: Internet PVR diff --git a/distribution/debian/copyright b/distribution/debian/copyright deleted file mode 100644 index 8d2f3f1d3a0..00000000000 --- a/distribution/debian/copyright +++ /dev/null @@ -1,24 +0,0 @@ -Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: sonarr -Source: https://github.com/Sonarr/Sonarr - -Files: * -Copyright: 2010-2016 Sonarr - -License: GPL-3.0+ - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - . - This package is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - . - You should have received a copy of the GNU General Public License - along with this program. If not, see . - . - On Debian systems, the complete text of the GNU General - Public License version 3 can be found in "/usr/share/common-licenses/GPL-3". diff --git a/distribution/debian/files b/distribution/debian/files deleted file mode 100644 index 01702ddbbd0..00000000000 --- a/distribution/debian/files +++ /dev/null @@ -1 +0,0 @@ -sonarr_3.0.0.0_all.deb web optional diff --git a/distribution/debian/install b/distribution/debian/install deleted file mode 100644 index b3cf8bedb12..00000000000 --- a/distribution/debian/install +++ /dev/null @@ -1,2 +0,0 @@ -sonarr_bin/* usr/lib/sonarr/bin -package_info usr/lib/sonarr diff --git a/distribution/debian/install.sh b/distribution/debian/install.sh new file mode 100644 index 00000000000..803d7cf5178 --- /dev/null +++ b/distribution/debian/install.sh @@ -0,0 +1,182 @@ +#!/bin/bash +### Description: Sonarr .NET Debian install +### Originally written for Radarr by: DoctorArr - doctorarr@the-rowlands.co.uk on 2021-10-01 v1.0 +### Updates for servarr suite made by Bakerboy448, DoctorArr, brightghost, aeramor and VP-EN +### Version v1.0.0 2023-12-29 - StevieTV - adapted from servarr script for Sonarr installs +### Version V1.0.1 2024-01-02 - StevieTV - remove UTF8-BOM +### Version V1.0.2 2024-01-03 - markus101 - Get user input from /dev/tty +### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory + +### Boilerplate Warning +#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +#EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +#MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +#NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +#LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +#OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +#WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +scriptversion="1.0.3" +scriptdate="2024-01-06" + +set -euo pipefail + +echo "Running Sonarr Install Script - Version [$scriptversion] as of [$scriptdate]" + +# Am I root?, need root! + +if [ "$EUID" -ne 0 ]; then + echo "Please run as root." + exit +fi + +app="sonarr" +app_port="8989" +app_prereq="curl sqlite3 wget" +app_umask="0002" +branch="main" + +# Constants +### Update these variables as required for your specific instance +installdir="/opt" # {Update me if needed} Install Location +bindir="${installdir}/${app^}" # Full Path to Install Location +datadir="/var/lib/$app/" # {Update me if needed} AppData directory to use +app_bin=${app^} # Binary Name of the app + +# This script should not be ran from installdir, otherwise later in the script the extracted files will be removed before they can be moved to installdir. +if [ "$installdir" == "$(dirname -- "$( readlink -f -- "$0"; )")" ] || [ "$bindir" == "$(dirname -- "$( readlink -f -- "$0"; )")" ]; then + echo "You should not run this script from the intended install directory. The script will exit. Please re-run it from another directory" + exit +fi + +# Prompt User +read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty +app_uid=$(echo "$app_uid" | tr -d ' ') +app_uid=${app_uid:-$app} +# Prompt Group +read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty +app_guid=$(echo "$app_guid" | tr -d ' ') +app_guid=${app_guid:-media} + +echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory" +echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories" +read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty + +# Create User / Group as needed +if [ "$app_guid" != "$app_uid" ]; then + if ! getent group "$app_guid" >/dev/null; then + groupadd "$app_guid" + fi +fi +if ! getent passwd "$app_uid" >/dev/null; then + adduser --system --no-create-home --ingroup "$app_guid" "$app_uid" + echo "Created and added User [$app_uid] to Group [$app_guid]" +fi +if ! getent group "$app_guid" | grep -qw "$app_uid"; then + echo "User [$app_uid] did not exist in Group [$app_guid]" + usermod -a -G "$app_guid" "$app_uid" + echo "Added User [$app_uid] to Group [$app_guid]" +fi + +# Stop the App if running +if service --status-all | grep -Fq "$app"; then + systemctl stop "$app" + systemctl disable "$app".service + echo "Stopped existing $app" +fi + +# Create Appdata Directory + +# AppData +mkdir -p "$datadir" +chown -R "$app_uid":"$app_guid" "$datadir" +chmod 775 "$datadir" +echo "Directories created" +# Download and install the App + +# prerequisite packages +echo "" +echo "Installing pre-requisite Packages" +# shellcheck disable=SC2086 +apt update && apt install -y $app_prereq +echo "" +ARCH=$(dpkg --print-architecture) +# get arch +dlbase="https://services.sonarr.tv/v1/download/$branch/latest?version=4&os=linux" +case "$ARCH" in +"amd64") DLURL="${dlbase}&arch=x64" ;; +"armhf") DLURL="${dlbase}&arch=arm" ;; +"arm64") DLURL="${dlbase}&arch=arm64" ;; +*) + echo "Arch not supported" + exit 1 + ;; +esac +echo "" +echo "Removing previous tarballs" +# -f to Force so we fail if it doesn't exist +rm -f "${app^}".*.tar.gz +echo "" +echo "Downloading..." +wget --content-disposition "$DLURL" +tar -xvzf "${app^}".*.tar.gz +echo "" +echo "Installation files downloaded and extracted" + +# remove existing installs +echo "Removing existing installation" +rm -rf "$bindir" +echo "Installing..." +mv "${app^}" $installdir +chown "$app_uid":"$app_guid" -R "$bindir" +chmod 775 "$bindir" +rm -rf "${app^}.*.tar.gz" +# Ensure we check for an update in case user installs older version or different branch +touch "$datadir"/update_required +chown "$app_uid":"$app_guid" "$datadir"/update_required +echo "App Installed" +# Configure Autostart + +# Remove any previous app .service +echo "Removing old service file" +rm -rf /etc/systemd/system/"$app".service + +# Create app .service with correct user startup +echo "Creating service file" +cat </dev/null +[Unit] +Description=${app^} Daemon +After=syslog.target network.target +[Service] +User=$app_uid +Group=$app_guid +UMask=$app_umask +Type=simple +ExecStart=$bindir/$app_bin -nobrowser -data=$datadir +TimeoutStopSec=20 +KillMode=process +Restart=on-failure +[Install] +WantedBy=multi-user.target +EOF + +# Start the App +echo "Service file created. Attempting to start the app" +systemctl -q daemon-reload +systemctl enable --now -q "$app" + +# Finish Update/Installation +host=$(hostname -I) +ip_local=$(grep -oP '^\S*' <<<"$host") +echo "" +echo "Install complete" +sleep 10 +STATUS="$(systemctl is-active "$app")" +if [ "${STATUS}" = "active" ]; then + echo "Browse to http://$ip_local:$app_port for the ${app^} GUI" +else + echo "${app^} failed to start" +fi + +# Exit +exit 0 diff --git a/distribution/debian/postinst b/distribution/debian/postinst deleted file mode 100644 index df3c8fd42d8..00000000000 --- a/distribution/debian/postinst +++ /dev/null @@ -1,111 +0,0 @@ -#!/bin/sh -set -e - -BUILD_VERSION={version} -UPDATER={updater} - -. /usr/share/debconf/confmodule -db_get sonarr/owning_user -USER="$RET" -db_get sonarr/owning_group -GROUP="$RET" -db_get sonarr/owning_umask -UMASK="$RET" -db_get sonarr/config_directory -CONFDIR="$RET" - -# Add User and Group -if ! getent group "$GROUP" >/dev/null; then - groupadd "$GROUP" -fi -if ! getent passwd "$USER" >/dev/null; then - adduser --system --no-create-home --ingroup "$GROUP" "$USER" -fi - -if [ $1 = "configure" ]; then - # Migrate old Sonarr v3 alpha data dir from /var/opt/sonarr or user home - if [ -d "/var/opt/sonarr" ] && [ "$CONFDIR" != "/var/opt/sonarr" ] && [ ! -d "$CONFDIR" ]; then - varoptRoot="/var/opt/sonarr" - varoptAppData="$varoptRoot/.config/Sonarr" - sonarrUserHome=`getent passwd $USER | cut -d ':' -f 6` - sonarrAppData="$sonarrUserHome/.config/Sonarr" - if [ -f "$varoptRoot/sonarr.db" ]; then - # Handle /var/opt/sonarr/sonarr.db - mv "$varoptRoot" "$CONFDIR" - elif [ -f "$varoptAppData/sonarr.db" ]; then - # Handle /var/opt/sonarr/.config/Sonarr/sonarr.db - mv "$varoptAppData" "$CONFDIR" - rm -rf "$varoptRoot" - elif [ -f "$sonarrAppData/sonarr.db" ]; then - # Handle ~/.config/Sonarr/sonarr.db - mv "$sonarrAppData" "$CONFDIR" - rm -rf "$sonarrAppData" - else - mv "$varoptRoot" "$CONFDIR" - fi - chown -R $USER:$GROUP "$CONFDIR" - chmod -R 775 "$CONFDIR" - fi - - # Migrate old NzbDrone data dir - if [ -d "/usr/lib/sonarr/nzbdrone-appdata" ] && [ ! -d "$CONFDIR" ]; then - NZBDRONE_DATA=`readlink /usr/lib/sonarr/nzbdrone-appdata` - if [ -f "$NZBDRONE_DATA/config.xml" ] && [ -f "$NZBDRONE_DATA/nzbdrone.db" ]; then - echo "Found NzbDrone database in $NZBDRONE_DATA, copying to $CONFDIR." - mkdir -p "$CONFDIR" - cp $NZBDRONE_DATA/config.xml $NZBDRONE_DATA/nzbdrone.db* "$CONFDIR/" - chown -R $USER:$GROUP "$CONFDIR" - chmod -R 775 "$CONFDIR" - else - echo "Missing NzbDrone database in $NZBDRONE_DATA, skipping migration." - fi - rm /usr/lib/sonarr/nzbdrone-appdata - fi -fi - -# Create data directory -if [ ! -d "$CONFDIR" ]; then - mkdir -p "$CONFDIR" -fi - -# Set permissions on data directory (always do this instead only on creation in case user was changed via dpkg-reconfigure) -chown -R $USER:$GROUP "$CONFDIR" - -#BEGIN BUILTIN UPDATER -# Apply patch if present -if [ "$UPDATER" = "BuiltIn" ] && [ -f /usr/lib/sonarr/bin_patch/release_info ]; then - # It shouldn't be possible to get a wrong bin_patch, but let's check anyway and throw it away if it's wrong - currentVersion=`cat /usr/lib/sonarr/bin_patch/release_info | grep 'ReleaseVersion=' | cut -d= -f 2` - currentRelease=`echo "$currentVersion" | cut -d. -f1,2,3` - currentBuild=`echo "$currentVersion" | cut -d. -f4` - targetVersion=$BUILD_VERSION - targetRelease=`echo "$targetVersion" | cut -d. -f1,2,3` - targetBuild=`echo "$targetVersion" | cut -d. -f4` - - if [ "$currentRelease" = "$targetRelease" ] && [ "$currentBuild" -gt "$targetBuild" ]; then - echo "Applying $currentVersion from BuiltIn updater instead of downgrading to $targetVersion" - rm -rf /usr/lib/sonarr/bin - mv /usr/lib/sonarr/bin_patch /usr/lib/sonarr/bin - else - rm -rf /usr/lib/sonarr/bin_patch - fi -fi -#END BUILTIN UPDATER - -# Set permissions on /usr/lib/sonarr -chown -R $USER:$GROUP /usr/lib/sonarr - -# Update sonarr.service file -sed -i "s:User=\w*:User=$USER:g; s:Group=\w*:Group=$GROUP:g; s:UMask=[0-9]*:UMask=$UMASK:g; s:-data=.*$:-data=$CONFDIR:g" /lib/systemd/system/sonarr.service - -#BEGIN BUILTIN UPDATER -if [ "$UPDATER" = "BuiltIn" ]; then - # If we upgraded, signal Sonarr to do an update check on startup instead of scheduled. - touch $CONFDIR/update_required - chown $USER:$GROUP $CONFDIR/update_required -fi -#END BUILTIN UPDATER - -#DEBHELPER# - -exit 0 diff --git a/distribution/debian/postrm b/distribution/debian/postrm deleted file mode 100644 index d13374776d7..00000000000 --- a/distribution/debian/postrm +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/sh -set -e - -BUILD_VERSION={version} -UPDATER={updater} - -if [ $1 = "abort-install" ]; then - # preinst was aborted, possibly due to NzbDrone still running. - # Nothing to do here - : -fi - -# The bin directory is expected to be empty, unless the BuiltIn updater added files. -if [ $1 = "remove" ] && [ -d /usr/lib/sonarr/bin ]; then - rm -rf /usr/lib/sonarr/bin -fi - -#BEGIN BUILTIN UPDATER -# Remove any existing patch if still present -if [ $1 = "remove" ] && [ -d /usr/lib/sonarr/bin_patch ]; then - rm -rf /usr/lib/sonarr/bin_patch -fi -#END BUILTIN UPDATER - -# Purge the entire sonarr configuration directory. -# TODO: Maybe move a minimal backup to tmp? -if [ $1 = "purge" ] && [ -e /usr/share/debconf/confmodule ]; then - . /usr/share/debconf/confmodule - db_get sonarr/config_directory - CONFDIR="$RET" - if [ -d "$CONFDIR" ]; then - rm -rf "$CONFDIR" - fi -fi - -#DEBHELPER# diff --git a/distribution/debian/preinst b/distribution/debian/preinst deleted file mode 100644 index a07a6310c0b..00000000000 --- a/distribution/debian/preinst +++ /dev/null @@ -1,91 +0,0 @@ -#!/bin/sh -set -e - -BUILD_VERSION={version} -UPDATER={updater} - -# Deal with existing nzbdrone installs -# -# Existing nzbdrone packages do not have startup scripts and the process might still be running. -# If the user manually installed nzbdrone then the process might still be running too. -if [ $1 = "install" ]; then - psNzbDrone=`ps ax -o'user:20,pid,ppid,unit,args' | grep mono.*NzbDrone\\\\.exe || true` - if [ ! -z "$psNzbDrone" ]; then - # Get the user and optional systemd unit - psNzbDroneUser=`echo "$psNzbDrone" | tr -s ' ' | cut -d ' ' -f 1` - psNzbDroneUnit=`echo "$psNzbDrone" | tr -s ' ' | cut -d ' ' -f 4` - # Get the appdata from the cmdline or get it from the user dir - droneAppData=`echo "$psNzbDrone" | tr ' ' '\n' | grep -- "-data=" | cut -d= -f 2` - if [ "$droneAppData" = "" ]; then - droneUserHome=`getent passwd $psNzbDroneUser | cut -d ':' -f 6` - droneAppData="$droneUserHome/.config/NzbDrone" - fi - - if [ "$psNzbDroneUnit" != "-" ] && [ -d /run/systemd/system ]; then - if [ "$psNzbDroneUnit" = "sonarr.service" ]; then - # Conflicts with our new sonarr.service so we have to remove it - echo "NzbDrone systemd startup detected at $psNzbDroneUnit, stopping and removing..." - deb-systemd-invoke stop $psNzbDroneUnit >/dev/null - if [ -f "/etc/systemd/system/$psNzbDroneUnit" ]; then - rm /etc/systemd/system/$psNzbDroneUnit - fi - if [ -f "/usr/lib/systemd/system/$psNzbDroneUnit" ]; then - rm /usr/lib/systemd/system/$psNzbDroneUnit - fi - deb-systemd-helper purge $psNzbDroneUnit >/dev/null - deb-systemd-helper unmask $psNzbDroneUnit >/dev/null - systemctl --system daemon-reload >/dev/null || true - else - # Just disable it, so the user can revisit the settings later - echo "NzbDrone systemd startup detected at $psNzbDroneUnit, stopping and disabling..." - deb-systemd-invoke stop $psNzbDroneUnit >/dev/null - deb-systemd-invoke mask $psNzbDroneUnit >/dev/null - fi - else - # We don't support auto migration for other startup methods, so bail. - # This leaves the sonarr package in an incomplete state. - echo "ps: $psNzbDrone" - echo "Error: An existing Sonarr v2 (NzbDrone) process is running. Remove the NzbDrone auto-startup prior to installing sonarr." - exit 1 - fi - - # We don't have the debconf configuration yet so we can't migrate the data. - # Instead we symlink so postinst knows where it's at. - if [ -f "/usr/lib/sonarr/nzbdrone-appdata" ]; then - rm "/usr/lib/sonarr/nzbdrone-appdata" - else - mkdir -p "/usr/lib/sonarr" - fi - ln -s $droneAppData /usr/lib/sonarr/nzbdrone-appdata - fi -fi - -#BEGIN BUILTIN UPDATER -# Check for supported upgrade paths -if [ $1 = "upgrade" ] && [ "$UPDATER" = "BuiltIn" ] && [ -f /usr/lib/sonarr/bin/release_info ]; then - # If we allow the Built-In updater to upgrade from 3.0.1.123 to 3.0.2.500 and now apt is catching up to 3.0.2.425 - # then we need to deal with that 500->425 'downgrade'. - # We do that by preserving the binaries and using those instead for postinst. - - currentVersion=`cat /usr/lib/sonarr/bin/release_info | grep 'ReleaseVersion=' | cut -d= -f 2` - currentRelease=`echo "$currentVersion" | cut -d. -f1,2,3` - currentBuild=`echo "$currentVersion" | cut -d. -f4` - targetVersion=$BUILD_VERSION - targetRelease=`echo "$targetVersion" | cut -d. -f1,2,3` - targetBuild=`echo "$targetVersion" | cut -d. -f4` - - if [ -d /usr/lib/sonarr/bin_patch ]; then - rm -rf /usr/lib/sonarr/bin_patch - fi - - # Check if the existing version is already an upgrade for the included build - if [ "$currentRelease" = "$targetRelease" ] && [ "$currentBuild" -gt "$targetBuild" ]; then - echo "Preserving $currentVersion from BuiltIn updater instead of downgrading to $targetVersion" - cp -r /usr/lib/sonarr/bin /usr/lib/sonarr/bin_patch - fi -fi -#END BUILTIN UPDATER - -#DEBHELPER# - -exit 0 diff --git a/distribution/debian/rules b/distribution/debian/rules deleted file mode 100644 index 88fda20d452..00000000000 --- a/distribution/debian/rules +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/make -f - -# Uncomment this to turn on verbose mode. -#export DH_VERBOSE=1 - -EXCLUDE_MODULEREFS = crypt32 httpapi __Internal ole32.dll - -%: - dh $@ --with=systemd --with=cli - -# No init script, only systemd -override_dh_installinit: - true - -# Sonarr likes debug symbols for logging -override_dh_clistrip: - -override_dh_makeclilibs: - -override_dh_clideps: - dh_clideps -d -r $(patsubst %,--exclude-moduleref=%,$(EXCLUDE_MODULEREFS)) diff --git a/distribution/debian/sonarr.clideps-override b/distribution/debian/sonarr.clideps-override deleted file mode 100644 index 629bfd09725..00000000000 --- a/distribution/debian/sonarr.clideps-override +++ /dev/null @@ -1,2 +0,0 @@ -ignores msbuild -ignores libc6 diff --git a/distribution/debian/sonarr.service b/distribution/debian/sonarr.service index f249ca82cd9..cd106de3d6d 100644 --- a/distribution/debian/sonarr.service +++ b/distribution/debian/sonarr.service @@ -11,7 +11,7 @@ Group=sonarr UMask=002 Type=simple -ExecStart=/usr/lib/sonarr/bin/Sonarr -nobrowser -data=/var/lib/sonarr +ExecStart=/opt/Sonarr/Sonarr -nobrowser -data=/var/lib/sonarr TimeoutStopSec=20 KillMode=process Restart=on-failure diff --git a/distribution/debian/templates b/distribution/debian/templates deleted file mode 100644 index 40c7746f105..00000000000 --- a/distribution/debian/templates +++ /dev/null @@ -1,27 +0,0 @@ -Template: sonarr/owning_user -Type: string -Default: sonarr -Description: Sonarr user: - Specify the user that is used to run Sonarr. The user will be created if it does not already exist. - The default 'sonarr' should work fine for most users. You can specify the user group next. - -Template: sonarr/owning_group -Type: string -Default: sonarr -Description: Sonarr group: - Specify the group that is used to run Sonarr. The group will be created if it does not already exist. - If the user doesn't already exist then this group will be used as the user's primary group. - Any media files created by Sonarr will be writeable by this group. - It's advisable to keep the group the same between download client, Sonarr and media centers. - -Template: sonarr/owning_umask -Type: string -Default: 0002 -Description: Sonarr umask: - Specifies the umask of the files created by Sonarr. 0002 means the files will be created with 664 as permissions. - -Template: sonarr/config_directory -Type: string -Default: /var/lib/sonarr -Description: Config directory: - Specify the directory where Sonarr stores the internal database and metadata. Media content will be stored elsewhere. diff --git a/distribution/macOS/Sonarr.app/Contents/Info.plist b/distribution/macOS/Sonarr.app/Contents/Info.plist index 91fe06105ae..4a6973050be 100644 --- a/distribution/macOS/Sonarr.app/Contents/Info.plist +++ b/distribution/macOS/Sonarr.app/Contents/Info.plist @@ -23,11 +23,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 3.0 + 4.0 CFBundleSignature xmmd CFBundleVersion - 3.0 + 4.0 NSAppleScriptEnabled YES diff --git a/distribution/windows/setup/build.bat b/distribution/windows/setup/build.bat index 1b28e233197..6c205089ee5 100644 --- a/distribution/windows/setup/build.bat +++ b/distribution/windows/setup/build.bat @@ -1,7 +1,7 @@ -REM SET SONARR_VERSION=1 -REM SET BRANCH=develop -echo ##teamcity[progressStart 'Building setup file'] -inno\ISCC.exe sonarr.iss -echo ##teamcity[progressFinish 'Building setup file'] +@REM SET SONARR_MAJOR_VERSION=4 +@REM SET SONARR_VERSION=4.0.0.5 +@REM SET BRANCH=develop +@REM SET FRAMEWORK=net6.0 +@REM SET RUNTIME=win-x64 -echo ##teamcity[publishArtifacts 'distribution\windows\setup\output\*%RUNTIME%*.exe'] +inno\ISCC.exe sonarr.iss diff --git a/distribution/windows/setup/sonarr.iss b/distribution/windows/setup/sonarr.iss index 09d8d6c38c8..8401bdea903 100644 --- a/distribution/windows/setup/sonarr.iss +++ b/distribution/windows/setup/sonarr.iss @@ -6,8 +6,9 @@ #define AppURL "https://sonarr.tv/" #define ForumsURL "https://forums.sonarr.tv/" #define AppExeName "Sonarr.exe" -#define BuildNumber "3.0" +#define BuildNumber "4.0" #define BuildNumber GetEnv('SONARR_VERSION') +#define MajorVersion GetEnv('SONARR_MAJOR_VERSION') #define BranchName GetEnv('BRANCH') #define Framework GetEnv('FRAMEWORK') #define Runtime GetEnv('RUNTIME') @@ -18,7 +19,7 @@ ; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) AppId={{56C1065D-3523-4025-B76D-6F73F67F7F71} AppName={#AppName} -AppVersion={#BuildNumber} +AppVersion={#MajorVersion} AppPublisher={#AppPublisher} AppPublisherURL={#AppURL} AppSupportURL={#ForumsURL} @@ -37,9 +38,10 @@ DisableReadyPage=True CompressionThreads=2 Compression=lzma2/normal AppContact={#ForumsURL} -VersionInfoVersion={#BuildNumber} +VersionInfoVersion={#MajorVersion} SetupLogging=yes -OutputDir=output +OutputDir="..\..\..\_artifacts" +AppverName={#AppName} [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" @@ -51,8 +53,8 @@ Name: "startupShortcut"; Description: "Create shortcut in Startup folder (Starts Name: "none"; Description: "Do not start automatically"; GroupDescription: "Start automatically"; Flags: exclusive unchecked [Files] -Source: "..\..\..\_artifacts\{#Runtime}\{#Framework}\Sonarr\Sonarr.exe"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\..\..\_artifacts\{#Runtime}\{#Framework}\Sonarr\*"; Excludes: "Sonarr.Update"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "..\..\..\_output\{#Runtime}\{#Framework}\Sonarr\Sonarr.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\..\..\_output\{#Runtime}\{#Framework}\Sonarr\*"; Excludes: "Sonarr.Update"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs ; NOTE: Don't use "Flags: ignoreversion" on any shared system files [Icons] diff --git a/docs.sh b/docs.sh new file mode 100755 index 00000000000..386f5df68de --- /dev/null +++ b/docs.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -e + +FRAMEWORK="net6.0" +PLATFORM=$1 + +if [ "$PLATFORM" = "Windows" ]; then + RUNTIME="win-x64" +elif [ "$PLATFORM" = "Linux" ]; then + RUNTIME="linux-x64" +elif [ "$PLATFORM" = "Mac" ]; then + RUNTIME="osx-x64" +else + echo "Platform must be provided as first argument: Windows, Linux or Mac" + exit 1 +fi + +outputFolder='_output' +testPackageFolder='_tests' + +rm -rf $outputFolder +rm -rf $testPackageFolder + +slnFile=src/Sonarr.sln + +platform=Posix + +if [ "$PLATFORM" = "Windows" ]; then + application=Sonarr.Console.dll +else + application=Sonarr.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.6.2 Swashbuckle.AspNetCore.Cli + +dotnet tool run swagger tofile --output ./src/Sonarr.Api.V3/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v3 & + +sleep 45 + +kill %1 + +exit 0 diff --git a/frontend/.csscomb.json b/frontend/.csscomb.json deleted file mode 100644 index a82e49732d9..00000000000 --- a/frontend/.csscomb.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "remove-empty-rulesets": true, - "always-semicolon": true, - "color-case": "lower", - "block-indent": " ", - "color-shorthand": false, - "element-case": "lower", - "eof-newline": true, - "leading-zero": true, - "quotes": "double", - "sort-order-fallback": "abc", - "space-before-colon": "", - "space-after-colon": " ", - "space-before-combinator": " ", - "space-after-combinator": " ", - "space-between-declarations": "\n", - "space-before-opening-brace": " ", - "space-after-opening-brace": "\n", - "space-after-selector-delimiter": " ", - "space-before-selector-delimiter": "", - "space-before-closing-brace": "\n", - "strip-spaces": true, - "tab-size": true, - "unitless-zero": false -} diff --git a/frontend/.esformatter b/frontend/.esformatter deleted file mode 100644 index 600bb0751e0..00000000000 --- a/frontend/.esformatter +++ /dev/null @@ -1,335 +0,0 @@ -{ - "indent": { - "value": " ", - "FunctionExpression": 1, - "ArrayExpression": 1, - "ObjectExpression": 1 - }, - "lineBreak": { - "value": "\n", - - "before": { - "ArrayPatternClosing": 0, - "ArrayPatternComma": 0, - "ArrayPatternOpening": 0, - "ArrowFunctionExpressionArrow": 0, - "ArrowFunctionExpressionClosingBrace": ">=1", - "ArrowFunctionExpressionOpeningBrace": 0, - "AssignmentExpression": ">=1", - "AssignmentOperator": 0, - "BlockStatement": 0, - "BreakKeyword": ">=1", - "CallExpression": -1, - "CallExpressionClosingParentheses": -1, - "CallExpressionOpeningParentheses": 0, - "CatchClosingBrace": ">=1", - "CatchKeyword": 0, - "CatchOpeningBrace": 0, - "ClassDeclaration": ">=1", - "ClassDeclarationClosingBrace": ">=1", - "ClassDeclarationOpeningBrace": 0, - "ConditionalExpression": ">=1", - "DeleteOperator": ">=1", - "DoWhileStatement": ">=1", - "DoWhileStatementClosingBrace": ">=1", - "DoWhileStatementOpeningBrace": 0, - "ElseIfStatement": 0, - "ElseIfStatementClosingBrace": ">=1", - "ElseIfStatementOpeningBrace": 0, - "ElseStatement": 0, - "ElseStatementClosingBrace": ">=1", - "ElseStatementOpeningBrace": 0, - "EmptyStatement": -1, - "EndOfFile": -1, - "FinallyClosingBrace": ">=1", - "FinallyKeyword": -1, - "FinallyOpeningBrace": 0, - "ForInStatement": ">=1", - "ForInStatementClosingBrace": ">=1", - "ForInStatementExpressionClosing": 0, - "ForInStatementExpressionOpening": 0, - "ForInStatementOpeningBrace": 0, - "ForStatement": ">=1", - "ForStatementClosingBrace": ">=1", - "ForStatementExpressionClosing": "<2", - "ForStatementExpressionOpening": 0, - "ForStatementOpeningBrace": 0, - "FunctionDeclaration": ">=1", - "FunctionDeclarationClosingBrace": ">=1", - "FunctionDeclarationOpeningBrace": 0, - "FunctionExpression": 0, - "FunctionExpressionClosingBrace": 1, - "FunctionExpressionOpeningBrace":0, - "IIFEClosingParentheses": 0, - "IfStatement": ">=1", - "IfStatementClosingBrace": ">=1", - "IfStatementOpeningBrace": 0, - "LogicalExpression": -1, - "MemberExpressionClosing": 0, - "MemberExpressionOpening": 0, - "MemberExpressionPeriod": -1, - "MethodDefinition": ">=1", - "ObjectExpressionClosingBrace": "<=1", - "ObjectPatternClosingBrace": 0, - "ObjectPatternComma": 0, - "ObjectPatternOpeningBrace": 0, - "ParameterDefault": 0, - "Property": "<=2", - "PropertyValue": 0, - "ReturnStatement": -1, - "SwitchClosingBrace": ">=1", - "SwitchOpeningBrace": 0, - "ThisExpression": -1, - "ThrowStatement": ">=1", - "TryClosingBrace": ">=1", - "TryKeyword": -1, - "TryOpeningBrace": 0, - "VariableDeclaration": ">=1", - "VariableDeclarationSemiColon": 0, - "VariableDeclarationWithoutInit": ">=1", - "VariableName": ">=1", - "VariableValue": 0, - "WhileStatement": ">=1", - "WhileStatementClosingBrace": ">=1", - "WhileStatementOpeningBrace": 0 - }, - - "after": { - "ArrayPatternClosing": 0, - "ArrayPatternComma": 0, - "ArrayPatternOpening": 0, - "ArrowFunctionExpressionArrow": 0, - "ArrowFunctionExpressionClosingBrace": -1, - "ArrowFunctionExpressionOpeningBrace": ">=1", - "AssignmentExpression": ">=1", - "AssignmentOperator": 0, - "BlockStatement": 0, - "BreakKeyword": -1, - "CallExpression": -1, - "CallExpressionClosingParentheses": -1, - "CallExpressionOpeningParentheses": -1, - "CatchClosingBrace": ">=0", - "CatchKeyword": 0, - "CatchOpeningBrace": ">=1", - "ClassDeclaration": ">=1", - "ClassDeclarationClosingBrace": ">=1", - "ClassDeclarationOpeningBrace": ">=1", - "ConditionalExpression": ">=1", - "DeleteOperator": ">=1", - "DoWhileStatement": ">=1", - "DoWhileStatementClosingBrace": 0, - "DoWhileStatementOpeningBrace": ">=1", - "ElseIfStatement": ">=1", - "ElseIfStatementClosingBrace": ">=1", - "ElseIfStatementOpeningBrace": ">=1", - "ElseStatement": ">=1", - "ElseStatementClosingBrace": ">=1", - "ElseStatementOpeningBrace": ">=1", - "EmptyStatement": -1, - "FinallyClosingBrace": ">=1", - "FinallyKeyword": -1, - "FinallyOpeningBrace": ">=1", - "ForInStatement": ">=1", - "ForInStatementClosingBrace": ">=1", - "ForInStatementExpressionClosing": -1, - "ForInStatementExpressionOpening": "<2", - "ForInStatementOpeningBrace": ">=1", - "ForStatement": ">=1", - "ForStatementClosingBrace": ">=1", - "ForStatementExpressionClosing": -1, - "ForStatementExpressionOpening": "<2", - "ForStatementOpeningBrace": ">=1", - "FunctionDeclaration": ">=1", - "FunctionDeclarationClosingBrace": ">=1", - "FunctionDeclarationOpeningBrace": ">=1", - "FunctionExpression": 0, - "FunctionExpressionClosingBrace": -1, - "FunctionExpressionOpeningBrace": 1, - "IIFEOpeningParentheses": 0, - "IfStatement": ">=1", - "IfStatementClosingBrace": ">=1", - "IfStatementOpeningBrace": ">=1", - "LogicalExpression": -1, - "MemberExpressionClosing": 0, - "MemberExpressionOpening": 0, - "MemberExpressionPeriod": 0, - "MethodDefinition": ">=1", - "ObjectExpressionOpeningBrace": "<=1", - "ObjectPatternClosingBrace": 0, - "ObjectPatternComma": 0, - "ObjectPatternOpeningBrace": 0, - "ParameterDefault": 0, - "Property": -1, - "PropertyName": 0, - "ReturnStatement": -1, - "SwitchCaseColon": ">=1", - "SwitchClosingBrace": ">=1", - "SwitchOpeningBrace": ">=1", - "ThisExpression": 0, - "ThrowStatement": ">=1", - "TryClosingBrace": 0, - "TryKeyword": -1, - "TryOpeningBrace": ">=1", - "VariableDeclaration": ">=1", - "VariableDeclarationSemiColon": ">=1", - "VariableValue": -1, - "WhileStatement": ">=1", - "WhileStatementClosingBrace": ">=1", - "WhileStatementOpeningBrace": ">=1" - } - }, - "whiteSpace": { - "value": " ", - "removeTrailing": 1, - "before": { - "ArgumentComma": 0, - "ArgumentList": 0, - "ArgumentListArrayExpression": 0, - "ArgumentListFunctionExpression": 1, - "ArgumentListObjectExpression": 0, - "ArrayExpressionClosing": 0, - "ArrayExpressionComma": 0, - "ArrayExpressionOpening": 1, - "AssignmentOperator": 1, - "BinaryExpression": 0, - "BinaryExpressionOperator": 1, - "BlockComment": 1, - "CallExpression": 1, - "CatchClosingBrace": 1, - "CatchKeyword": 1, - "CatchOpeningBrace": 1, - "CatchParameterList": 0, - "CommaOperator": 0, - "ConditionalExpressionAlternate": 1, - "ConditionalExpressionConsequent": 1, - "DoWhileStatementClosingBrace": 1, - "DoWhileStatementConditional": 1, - "DoWhileStatementOpeningBrace": 1, - "ElseIfStatementClosingBrace": 1, - "ElseIfStatementOpeningBrace": 1, - "ElseStatementClosingBrace": 1, - "ElseStatementOpeningBrace": 1, - "EmptyStatement": 0, - "ExpressionClosingParentheses": 0, - "FinallyClosingBrace": 1, - "FinallyKeyword": -1, - "FinallyOpeningBrace": 1, - "ForInStatement": 1, - "ForInStatementClosingBrace": 1, - "ForInStatementExpressionClosing": 0, - "ForInStatementExpressionOpening": 1, - "ForInStatementOpeningBrace": 1, - "ForStatement": 1, - "ForStatementClosingBrace": 1, - "ForStatementExpressionClosing": 0, - "ForStatementExpressionOpening": 1, - "ForStatementOpeningBrace": 1, - "ForStatementSemicolon": 0, - "FunctionDeclarationClosingBrace": 1, - "FunctionDeclarationOpeningBrace": 1, - "FunctionExpressionClosingBrace": 1, - "FunctionExpressionOpeningBrace": 1, - "IfStatementClosingBrace": 1, - "IfStatementConditionalClosing": 0, - "IfStatementConditionalOpening": 1, - "IfStatementOpeningBrace": 1, - "LineComment": 1, - "LogicalExpressionOperator": 1, - "MemberExpressionClosing": 0, - "ObjectExpressionClosingBrace": 1, - "ParameterComma": 0, - "ParameterList": 0, - "Property": 1, - "PropertyName": 1, - "PropertyValue": 1, - "SwitchDiscriminantClosing": 0, - "SwitchDiscriminantOpening": 1, - "ThrowKeyword": 1, - "TryClosingBrace": 1, - "TryKeyword": -1, - "TryOpeningBrace": 1, - "UnaryExpressionOperator": 0, - "VariableName": 1, - "VariableValue": 1, - "WhileStatementClosingBrace": 1, - "WhileStatementConditionalClosing": 0, - "WhileStatementConditionalOpening": 1, - "WhileStatementOpeningBrace": 1 - }, - "after": { - "ArgumentComma": 1, - "ArgumentList": 0, - "ArgumentListArrayExpression": 1, - "ArgumentListFunctionExpression": 1, - "ArgumentListObjectExpression": 0, - "ArrayExpressionClosing": 0, - "ArrayExpressionComma": 1, - "ArrayExpressionOpening": 0, - "AssignmentOperator": 1, - "BinaryExpression": 0, - "BinaryExpressionOperator": 1, - "BlockComment": 1, - "CallExpression": 0, - "CatchClosingBrace": 1, - "CatchKeyword": 1, - "CatchOpeningBrace": 1, - "CatchParameterList": 0, - "CommaOperator": 1, - "ConditionalExpressionConsequent": 1, - "ConditionalExpressionTest": 1, - "DoWhileStatementBody": 1, - "DoWhileStatementClosingBrace": 1, - "DoWhileStatementOpeningBrace": 1, - "ElseIfStatementClosingBrace": 1, - "ElseIfStatementOpeningBrace": 1, - "ElseStatementClosingBrace": 1, - "ElseStatementOpeningBrace": 1, - "EmptyStatement": 0, - "ExpressionOpeningParentheses": 0, - "FinallyClosingBrace": 1, - "FinallyKeyword": -1, - "FinallyOpeningBrace": 1, - "ForInStatement": 1, - "ForInStatementClosingBrace": 1, - "ForInStatementExpressionClosing": 1, - "ForInStatementExpressionOpening": 0, - "ForInStatementOpeningBrace": 1, - "ForStatement": 1, - "ForStatementClosingBrace": 1, - "ForStatementExpressionClosing": 1, - "ForStatementExpressionOpening": 0, - "ForStatementOpeningBrace": 1, - "ForStatementSemicolon": 1, - "FunctionDeclarationClosingBrace": 0, - "FunctionDeclarationOpeningBrace": 0, - "FunctionExpressionClosingBrace": 0, - "FunctionExpressionOpeningBrace": 0, - "FunctionName": 0, - "FunctionReservedWord": 0, - "IfStatementClosingBrace": 1, - "IfStatementConditionalClosing": 0, - "IfStatementConditionalOpening": 0, - "IfStatementOpeningBrace": 1, - "LogicalExpressionOperator": 1, - "MemberExpressionOpening": 0, - "ObjectExpressionClosingBrace": 0, - "ObjectExpressionOpeningBrace": 1, - "ParameterComma": 1, - "ParameterList": 0, - "PropertyName": 0, - "PropertyValue": 0, - "SwitchDiscriminantClosing": 1, - "SwitchDiscriminantOpening": 0, - "ThrowKeyword": 1, - "TryClosingBrace": 1, - "TryKeyword": -1, - "TryOpeningBrace": 1, - "UnaryExpressionOperator": 0, - "VariableName": 1, - "WhileStatementClosingBrace": 1, - "WhileStatementConditionalClosing": 1, - "WhileStatementConditionalOpening": 0, - "WhileStatementOpeningBrace": 1 - } - } -} diff --git a/frontend/.eslintignore b/frontend/.eslintignore index d4b43f83601..e6d49ec4d77 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -1 +1,2 @@ **/JsLibraries/** +**/*.css.d.ts diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 06695aac222..e14b9125d9c 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -1,14 +1,21 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires const fs = require('fs'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const path = require('path'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const typescriptEslintRecommended = require('@typescript-eslint/eslint-plugin').configs.recommended; + +const frontendFolder = __dirname; const dirs = fs - .readdirSync('frontend/src', { withFileTypes: true }) + .readdirSync(path.join(frontendFolder, 'src'), { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) .map((dirent) => dirent.name) .join('|'); -const frontendFolder = __dirname; - module.exports = { + root: true, + parser: '@babel/eslint-parser', env: { @@ -21,7 +28,8 @@ module.exports = { globals: { expect: false, chai: false, - sinon: false + sinon: false, + JSX: true }, parserOptions: { @@ -41,7 +49,9 @@ module.exports = { 'react', 'react-hooks', 'simple-import-sort', - 'import' + 'import', + '@typescript-eslint', + 'prettier' ], settings: { @@ -200,7 +210,6 @@ module.exports = { 'no-undef-init': 'off', 'no-undefined': 'off', 'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }], - 'no-use-before-define': 'error', // Node.js and CommonJS @@ -224,7 +233,7 @@ module.exports = { 'consistent-this': ['error', 'self'], 'eol-last': 'error', 'func-names': 'off', - 'func-style': ['error', 'declaration'], + 'func-style': ['error', 'declaration', { allowArrowFunctions: true }], indent: ['error', 2, { SwitchCase: 1 }], 'key-spacing': ['error', { beforeColon: false, afterColon: true }], 'keyword-spacing': ['error', { before: true, after: true }], @@ -315,7 +324,9 @@ module.exports = { }, overrides: [ { - files: ['*.js'], + files: [ + '*.js' + ], rules: { 'simple-import-sort/imports': [ 'error', @@ -330,6 +341,95 @@ module.exports = { } ] } + }, + { + files: [ + '*.ts', + '*.tsx' + ], + + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.json' + }, + + extends: [ + 'prettier' + ], + + rules: Object.assign(typescriptEslintRecommended.rules, { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'after-used', + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true + + } + ], + '@typescript-eslint/explicit-function-return-type': 'off', + 'no-shadow': 'off', + 'prettier/prettier': 'error', + 'simple-import-sort/imports': [ + 'error', + { + groups: [ + // Packages + // Absolute Paths + // Relative Paths + // Css + ['^@?\\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' + }) + }, + { + files: [ + '*.css.d.ts' + ], + rules: { + 'filenames/match-exported': 'off', + 'init-declarations': 'off', + 'prettier/prettier': 'off' + } } ] }; diff --git a/frontend/.jsbeautifyrc b/frontend/.jsbeautifyrc deleted file mode 100644 index 50aa6aa2907..00000000000 --- a/frontend/.jsbeautifyrc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "js": { - "indent_size": 2, - "indent_char": " ", - "indent_level": 2, - "indent_with_tabs": false, - "preserve_newlines": true, - "brace_style": "collapse", - "max_preserve_newlines": 2, - "jslint_happy": true - } -} \ No newline at end of file diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 00000000000..3e6367c54d1 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,10 @@ +# Ignore everything recursively +* + +# But not the .ts files +!*.ts* + +*css.d.ts + +# Check subdirectories too +!*/ diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 00000000000..2f91ee6918e --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "arrowParens": "always", + "endOfLine": "auto", + "singleQuote": true, + "trailingComma": "es5" +} diff --git a/frontend/.stylelintrc b/frontend/.stylelintrc index 92a583d89be..f19357a4c16 100644 --- a/frontend/.stylelintrc +++ b/frontend/.stylelintrc @@ -1,12 +1,12 @@ { -"plugins": [ - "stylelint-order" -], -"ignoreFiles": [ - "frontend/src/Styles/scaffolding.css", - "**/*.js" -], -"rules": { + "plugins": [ + "stylelint-order" + ], + "ignoreFiles": [ + "frontend/src/Styles/scaffolding.css", + "**/*.js" + ], + "rules": { "at-rule-empty-line-before": [ "always", { @@ -15,96 +15,46 @@ ] } ], - "at-rule-name-case": "lower", - "at-rule-name-newline-after": "always-multi-line", - "at-rule-name-space-after": "always", "at-rule-no-unknown": [ true, { "ignoreAtRules": [ "/^add\\-mixin$/", "/^define\\-mixin$/" - ] + ] } ], "at-rule-no-vendor-prefix": true, - "at-rule-semicolon-newline-after": "always", - "at-rule-semicolon-space-before": "never", - "block-closing-brace-empty-line-before": "never", - "block-closing-brace-newline-after": "always", - "block-closing-brace-newline-before": "always", - "block-closing-brace-space-after": "always-single-line", - "block-closing-brace-space-before": "always-single-line", "block-no-empty": true, - "block-opening-brace-newline-after": "always", - "block-opening-brace-newline-before": "never-single-line", - "block-opening-brace-space-after": "always-single-line", - "block-opening-brace-space-before": "always", - "color-hex-case": "lower", "color-hex-length": "short", "color-named": "never", "color-no-invalid-hex": true, "comment-whitespace-inside": "always", - "declaration-bang-space-after": "never", - "declaration-bang-space-before": "always", "declaration-block-no-duplicate-properties": [ true, { "ignoreProperties": [ - "composes" + "composes" ] } ], "declaration-block-no-redundant-longhand-properties": true, "declaration-block-no-shorthand-property-overrides": true, - "declaration-block-semicolon-newline-after": "always", - "declaration-block-semicolon-newline-before": "never-multi-line", - "declaration-block-semicolon-space-before": "never", "declaration-block-single-line-max-declarations": 1, - "declaration-block-trailing-semicolon": "always", - "declaration-colon-space-after": "always", - "declaration-colon-space-before": "never", "font-family-name-quotes": "always-unless-keyword", "function-calc-no-unspaced-operator": true, - "function-comma-newline-after": "never-multi-line", - "function-comma-newline-before": "never-multi-line", - "function-comma-space-after": "always", - "function-comma-space-before": "never", "function-linear-gradient-no-nonstandard-direction": true, "function-name-case": "lower", - "function-parentheses-newline-inside": "never-multi-line", - "function-parentheses-space-inside": "never", "function-url-quotes": "always", "function-url-scheme-disallowed-list": [ "data" ], - "function-whitespace-after": "always", - "indentation": 2, "keyframe-declaration-no-important": true, "length-zero-no-unit": true, - "max-empty-lines": 1, - "max-line-length": [ - 100, - { - "ignore": [ - "non-comments" - ] - } - ], "max-nesting-depth": 2, - "media-feature-colon-space-after": "always", - "media-feature-colon-space-before": "never", - "media-feature-name-case": "lower", "media-feature-name-no-vendor-prefix": true, - "media-feature-range-operator-space-after": "always", - "media-feature-range-operator-space-before": "always", "no-empty-source": true, - "no-eol-whitespace": true, - "no-extra-semicolons": true, "no-invalid-double-slash-comments": true, - "no-missing-end-of-source-newline": true, - "number-leading-zero": "always", - "number-no-trailing-zeros": true, "order/order": [ "custom-properties", "dollar-variables", @@ -132,6 +82,7 @@ "right", "bottom", "left", + "inset", "z-index", "display", "visibility", @@ -343,54 +294,33 @@ ] } ], - "property-case": "lower", "property-no-vendor-prefix": true, "rule-empty-line-before": [ "always", { "except": [ - "first-nested" + "first-nested" ], "ignore": [ - "after-comment" + "after-comment" ] } ], - "selector-attribute-brackets-space-inside": "never", - "selector-attribute-operator-space-after": "never", - "selector-attribute-operator-space-before": "never", "selector-attribute-quotes": "never", "selector-class-pattern": "^[A-Za-z0-9]+$", - "selector-combinator-space-after": "always", - "selector-combinator-space-before": "always", - "selector-descendant-combinator-no-non-space": true, - "selector-list-comma-newline-after": "always", - "selector-list-comma-newline-before": "never-multi-line", - "selector-list-comma-space-before": "never", "selector-max-attribute": 0, "selector-max-class": 3, "selector-max-compound-selectors": 3, - "selector-max-empty-lines": 0, "selector-max-id": 0, "selector-max-universal": 0, - "selector-pseudo-class-case": "lower", - "selector-pseudo-class-parentheses-space-inside": "never", - "selector-pseudo-element-case": "lower", "selector-pseudo-element-colon-notation": "double", "selector-pseudo-element-no-unknown": true, "selector-type-case": "lower", "selector-type-no-unknown": true, "shorthand-property-no-redundant-values": true, "string-no-newline": true, - "string-quotes": "single", "time-min-milliseconds": 100, - "unit-case": "lower", "unit-no-unknown": true, - "value-list-comma-newline-after": "never-multi-line", - "value-list-comma-newline-before": "never-multi-line", - "value-list-comma-space-after": "always", - "value-list-comma-space-before": "never", - "value-list-max-empty-lines": 0, "value-no-vendor-prefix": true } -} +} \ No newline at end of file diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 00000000000..0e005a3cd85 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "stylelint.vscode-stylelint", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" + ] +} \ No newline at end of file diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 00000000000..8da95337f69 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,23 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "files.insertFinalNewline": true, + + "files.exclude": { + "**/node_modules": true, + "**/*.d.css": true + }, + + "editor.formatOnSave": false, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit" + }, + + "typescript.preferences.quoteStyle": "single", + + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact" + ], +} diff --git a/frontend/babel.config.js b/frontend/babel.config.js index fe855af6318..ade9f24a21a 100644 --- a/frontend/babel.config.js +++ b/frontend/babel.config.js @@ -2,22 +2,25 @@ 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: { development: { presets: [ - ['@babel/preset-react', { development: true }] + ['@babel/preset-react', { development: true }], + '@babel/preset-typescript' ], plugins: [ 'babel-plugin-inline-classnames' @@ -25,7 +28,8 @@ module.exports = { }, production: { presets: [ - '@babel/preset-react' + '@babel/preset-react', + '@babel/preset-typescript' ], plugins: [ 'babel-plugin-transform-react-remove-prop-types' diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js index 1c86e39d42f..0d0364950dc 100644 --- a/frontend/build/webpack.config.js +++ b/frontend/build/webpack.config.js @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ const path = require('path'); const webpack = require('webpack'); const FileManagerPlugin = require('filemanager-webpack-plugin'); @@ -5,6 +6,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const LiveReloadPlugin = require('webpack-livereload-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const TerserPlugin = require('terser-webpack-plugin'); +const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); module.exports = (env) => { const uiFolder = 'UI'; @@ -24,6 +26,7 @@ module.exports = (env) => { const config = { mode: isProduction ? 'production' : 'development', devtool: isProduction ? 'source-map' : 'eval-source-map', + target: 'web', stats: { children: false @@ -34,18 +37,22 @@ module.exports = (env) => { }, entry: { - index: 'index.js' + index: 'index.ts' }, resolve: { + extensions: [ + '.ts', + '.tsx', + '.js' + ], modules: [ srcFolder, path.join(srcFolder, 'Shims'), 'node_modules' ], alias: { - jquery: 'jquery/src/jquery', - 'react-middle-truncate': 'react-middle-truncate/lib/react-middle-truncate' + jquery: 'jquery/dist/jquery.min' }, fallback: { buffer: false, @@ -60,23 +67,23 @@ module.exports = (env) => { output: { path: distFolder, publicPath: '/', - filename: '[name].js', + filename: isProduction ? '[name]-[contenthash].js' : '[name].js', sourceMapFilename: '[file].map' }, optimization: { moduleIds: 'deterministic', - chunkIds: 'named', - splitChunks: { - chunks: 'initial', - name: 'vendors' - } + chunkIds: isProduction ? 'deterministic' : 'named' }, performance: { hints: false }, + experiments: { + topLevelAwait: true + }, + plugins: [ new webpack.DefinePlugin({ __DEV__: !isProduction, @@ -84,13 +91,15 @@ module.exports = (env) => { }), new MiniCssExtractPlugin({ - filename: 'Content/styles.css' + filename: 'Content/styles.css', + chunkFilename: isProduction ? 'Content/[id]-[chunkhash].css' : 'Content/[id].css' }), new HtmlWebpackPlugin({ template: 'frontend/src/index.ejs', filename: 'index.html', - publicPath: '/' + publicPath: '/', + inject: false }), new FileManagerPlugin({ @@ -125,12 +134,20 @@ module.exports = (env) => { { source: 'frontend/src/Content/robots.txt', destination: path.join(distFolder, 'Content/robots.txt') + }, + + // manifest.json and browserconfig.xml + { + source: 'frontend/src/Content/*.(json|xml)', + destination: path.join(distFolder, 'Content') } ] } } }), + new ForkTsCheckerWebpackPlugin(), + new LiveReloadPlugin() ], @@ -154,7 +171,7 @@ module.exports = (env) => { } }, { - test: /\.js?$/, + test: [/\.jsx?$/, /\.tsx?$/], exclude: /(node_modules|JsLibraries)/, use: [ { @@ -170,7 +187,7 @@ module.exports = (env) => { loose: true, debug: false, useBuiltIns: 'entry', - corejs: 3 + corejs: '3.39' } ] ] @@ -185,12 +202,13 @@ module.exports = (env) => { exclude: /(node_modules|globals.css)/, use: [ { loader: MiniCssExtractPlugin.loader }, + { loader: 'css-modules-typescript-loader' }, { loader: 'css-loader', options: { importLoaders: 1, modules: { - localIdentName: '[name]/[local]/[hash:base64:5]' + localIdentName: isProduction ? '[name]/[local]/[hash:base64:5]' : '[name]/[local]' } } }, @@ -253,18 +271,19 @@ module.exports = (env) => { config.resolve.alias['react-dom$'] = 'react-dom/profiling'; config.resolve.alias['scheduler/tracing'] = 'scheduler/tracing-profiling'; - config.optimization.minimizer = [ - new TerserPlugin({ - cache: true, - parallel: true, - sourceMap: true, // Must be set to true if using source-maps in production - terserOptions: { - mangle: false, - keep_classnames: true, - keep_fnames: true - } - }) - ]; + config.optimization = { + minimize: true, + minimizer: [ + new TerserPlugin({ + terserOptions: { + sourceMap: true, // Must be set to true if using source-maps in production + mangle: false, + keep_classnames: true, + keep_fnames: true + } + }) + ] + }; } return config; diff --git a/frontend/build/webpack/css-variables-loader.js b/frontend/build/webpack/css-variables-loader.js index 5683c98bef2..717d7d323f5 100644 --- a/frontend/build/webpack/css-variables-loader.js +++ b/frontend/build/webpack/css-variables-loader.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line filenames/match-exported const loaderUtils = require('loader-utils'); module.exports = function cssVariablesLoader(source) { diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json deleted file mode 100644 index b12d0a2d4e9..00000000000 --- a/frontend/jsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "es6", - "checkJs": false, - "baseUrl": "src", - "jsx": "react", - "module": "commonjs", - "moduleResolution": "node", - "paths": { - "*": [ - "*" - ] - } - }, - "include": [ - "./src/**/*" - ], - "exclude": [ - ] -} \ No newline at end of file diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index f657adf289b..89db00f8c84 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/.vscode/settings.json b/frontend/src/.vscode/settings.json deleted file mode 100644 index 0fb2bf460ae..00000000000 --- a/frontend/src/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -// Place your settings in this file to overwrite default and user settings. -{ - "files.insertFinalNewline": true -} \ No newline at end of file diff --git a/frontend/src/Activity/Blocklist/Blocklist.js b/frontend/src/Activity/Blocklist/Blocklist.js deleted file mode 100644 index 4bc7ca385b0..00000000000 --- a/frontend/src/Activity/Blocklist/Blocklist.js +++ /dev/null @@ -1,232 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import ConfirmModal from 'Components/Modal/ConfirmModal'; -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 Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import TablePager from 'Components/Table/TablePager'; -import { align, icons, kinds } from 'Helpers/Props'; -import getRemovedItems from 'Utilities/Object/getRemovedItems'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; -import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import BlocklistRowConnector from './BlocklistRowConnector'; - -class Blocklist extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - allSelected: false, - allUnselected: false, - lastToggled: null, - selectedState: {}, - isConfirmRemoveModalOpen: false, - items: props.items - }; - } - - componentDidUpdate(prevProps) { - const { - items - } = this.props; - - if (hasDifferentItems(prevProps.items, items)) { - this.setState((state) => { - return { - ...removeOldSelectedState(state, getRemovedItems(prevProps.items, items)), - items - }; - }); - - return; - } - } - - // - // Control - - getSelectedIds = () => { - return getSelectedIds(this.state.selectedState); - }; - - // - // Listeners - - onSelectAllChange = ({ value }) => { - this.setState(selectAll(this.state.selectedState, value)); - }; - - onSelectedChange = ({ id, value, shiftKey = false }) => { - this.setState((state) => { - return toggleSelected(state, this.props.items, id, value, shiftKey); - }); - }; - - onRemoveSelectedPress = () => { - this.setState({ isConfirmRemoveModalOpen: true }); - }; - - onRemoveSelectedConfirmed = () => { - this.props.onRemoveSelected(this.getSelectedIds()); - this.setState({ isConfirmRemoveModalOpen: false }); - }; - - onConfirmRemoveModalClose = () => { - this.setState({ isConfirmRemoveModalOpen: false }); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - items, - columns, - totalRecords, - isRemoving, - isClearingBlocklistExecuting, - onClearBlocklistPress, - ...otherProps - } = this.props; - - const { - allSelected, - allUnselected, - selectedState, - isConfirmRemoveModalOpen - } = this.state; - - const selectedIds = this.getSelectedIds(); - - return ( - - - - - - - - - - - - - - - - - { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && -
Unable to load blocklist
- } - - { - isPopulated && !error && !items.length && -
- No history blocklist -
- } - - { - isPopulated && !error && !!items.length && -
- - - { - items.map((item) => { - return ( - - ); - }) - } - -
- - -
- } -
- - -
- ); - } -} - -Blocklist.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - totalRecords: PropTypes.number, - isRemoving: PropTypes.bool.isRequired, - isClearingBlocklistExecuting: PropTypes.bool.isRequired, - onRemoveSelected: PropTypes.func.isRequired, - onClearBlocklistPress: PropTypes.func.isRequired -}; - -export default Blocklist; diff --git a/frontend/src/Activity/Blocklist/Blocklist.tsx b/frontend/src/Activity/Blocklist/Blocklist.tsx new file mode 100644 index 00000000000..4163bc9ca4b --- /dev/null +++ b/frontend/src/Activity/Blocklist/Blocklist.tsx @@ -0,0 +1,329 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { SelectProvider } from 'App/SelectContext'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +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 Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import usePaging from 'Components/Table/usePaging'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { align, icons, kinds } from 'Helpers/Props'; +import { + clearBlocklist, + fetchBlocklist, + gotoBlocklistPage, + removeBlocklistItems, + setBlocklistFilter, + setBlocklistSort, + setBlocklistTableOption, +} from 'Store/Actions/blocklistActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { CheckInputChanged } from 'typings/inputs'; +import { SelectStateInputProps } from 'typings/props'; +import { TableOptionsChangePayload } from 'typings/Table'; +import { + registerPagePopulator, + unregisterPagePopulator, +} from 'Utilities/pagePopulator'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import BlocklistFilterModal from './BlocklistFilterModal'; +import BlocklistRow from './BlocklistRow'; + +function Blocklist() { + const requestCurrentPage = useCurrentPage(); + + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + sortKey, + sortDirection, + page, + pageSize, + totalPages, + totalRecords, + isRemoving, + } = useSelector((state: AppState) => state.blocklist); + + const customFilters = useSelector(createCustomFiltersSelector('blocklist')); + const isClearingBlocklistExecuting = useSelector( + createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST) + ); + const dispatch = useDispatch(); + + const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] = + useState(false); + const [isConfirmClearModalOpen, setIsConfirmClearModalOpen] = useState(false); + + const [selectState, setSelectState] = useSelectState(); + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const wasClearingBlocklistExecuting = usePrevious( + isClearingBlocklistExecuting + ); + + const handleSelectAllChange = useCallback( + ({ value }: CheckInputChanged) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const handleSelectedChange = useCallback( + ({ id, value, shiftKey = false }: SelectStateInputProps) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const handleRemoveSelectedPress = useCallback(() => { + setIsConfirmRemoveModalOpen(true); + }, [setIsConfirmRemoveModalOpen]); + + const handleRemoveSelectedConfirmed = useCallback(() => { + dispatch(removeBlocklistItems({ ids: selectedIds })); + setIsConfirmRemoveModalOpen(false); + }, [selectedIds, setIsConfirmRemoveModalOpen, dispatch]); + + const handleConfirmRemoveModalClose = useCallback(() => { + setIsConfirmRemoveModalOpen(false); + }, [setIsConfirmRemoveModalOpen]); + + const handleClearBlocklistPress = useCallback(() => { + setIsConfirmClearModalOpen(true); + }, [setIsConfirmClearModalOpen]); + + const handleClearBlocklistConfirmed = useCallback(() => { + dispatch(executeCommand({ name: commandNames.CLEAR_BLOCKLIST })); + setIsConfirmClearModalOpen(false); + }, [setIsConfirmClearModalOpen, dispatch]); + + const handleConfirmClearModalClose = useCallback(() => { + setIsConfirmClearModalOpen(false); + }, [setIsConfirmClearModalOpen]); + + const { + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + } = usePaging({ + page, + totalPages, + gotoPage: gotoBlocklistPage, + }); + + const handleFilterSelect = useCallback( + (selectedFilterKey: string) => { + dispatch(setBlocklistFilter({ selectedFilterKey })); + }, + [dispatch] + ); + + const handleSortPress = useCallback( + (sortKey: string) => { + dispatch(setBlocklistSort({ sortKey })); + }, + [dispatch] + ); + + const handleTableOptionChange = useCallback( + (payload: TableOptionsChangePayload) => { + dispatch(setBlocklistTableOption(payload)); + + if (payload.pageSize) { + dispatch(gotoBlocklistPage({ page: 1 })); + } + }, + [dispatch] + ); + + useEffect(() => { + if (requestCurrentPage) { + dispatch(fetchBlocklist()); + } else { + dispatch(gotoBlocklistPage({ page: 1 })); + } + + return () => { + dispatch(clearBlocklist()); + }; + }, [requestCurrentPage, dispatch]); + + useEffect(() => { + const repopulate = () => { + dispatch(fetchBlocklist()); + }; + + registerPagePopulator(repopulate); + + return () => { + unregisterPagePopulator(repopulate); + }; + }, [dispatch]); + + useEffect(() => { + if (wasClearingBlocklistExecuting && !isClearingBlocklistExecuting) { + dispatch(gotoBlocklistPage({ page: 1 })); + } + }, [isClearingBlocklistExecuting, wasClearingBlocklistExecuting, dispatch]); + + return ( + + + + + + + + + + + + + + + + + + + + {isFetching && !isPopulated ? : null} + + {!isFetching && !!error ? ( + {translate('BlocklistLoadError')} + ) : null} + + {isPopulated && !error && !items.length ? ( + + {selectedFilterKey === 'all' + ? translate('NoBlocklistItems') + : translate('BlocklistFilterHasNoItems')} + + ) : null} + + {isPopulated && !error && !!items.length ? ( +
+ + + {items.map((item) => { + return ( + + ); + })} + +
+ +
+ ) : null} +
+ + + + +
+
+ ); +} + +export default Blocklist; diff --git a/frontend/src/Activity/Blocklist/BlocklistConnector.js b/frontend/src/Activity/Blocklist/BlocklistConnector.js deleted file mode 100644 index 454fa13a94f..00000000000 --- a/frontend/src/Activity/Blocklist/BlocklistConnector.js +++ /dev/null @@ -1,152 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import withCurrentPage from 'Components/withCurrentPage'; -import * as blocklistActions from 'Store/Actions/blocklistActions'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import Blocklist from './Blocklist'; - -function createMapStateToProps() { - return createSelector( - (state) => state.blocklist, - createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST), - (blocklist, isClearingBlocklistExecuting) => { - return { - isClearingBlocklistExecuting, - ...blocklist - }; - } - ); -} - -const mapDispatchToProps = { - ...blocklistActions, - executeCommand -}; - -class BlocklistConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - useCurrentPage, - fetchBlocklist, - gotoBlocklistFirstPage - } = this.props; - - registerPagePopulator(this.repopulate); - - if (useCurrentPage) { - fetchBlocklist(); - } else { - gotoBlocklistFirstPage(); - } - } - - componentDidUpdate(prevProps) { - if (prevProps.isClearingBlocklistExecuting && !this.props.isClearingBlocklistExecuting) { - this.props.gotoBlocklistFirstPage(); - } - } - - componentWillUnmount() { - this.props.clearBlocklist(); - unregisterPagePopulator(this.repopulate); - } - - // - // Control - - repopulate = () => { - this.props.fetchBlocklist(); - }; - // - // Listeners - - onFirstPagePress = () => { - this.props.gotoBlocklistFirstPage(); - }; - - onPreviousPagePress = () => { - this.props.gotoBlocklistPreviousPage(); - }; - - onNextPagePress = () => { - this.props.gotoBlocklistNextPage(); - }; - - onLastPagePress = () => { - this.props.gotoBlocklistLastPage(); - }; - - onPageSelect = (page) => { - this.props.gotoBlocklistPage({ page }); - }; - - onRemoveSelected = (ids) => { - this.props.removeBlocklistItems({ ids }); - }; - - onSortPress = (sortKey) => { - this.props.setBlocklistSort({ sortKey }); - }; - - onClearBlocklistPress = () => { - this.props.executeCommand({ name: commandNames.CLEAR_BLOCKLIST }); - }; - - onTableOptionChange = (payload) => { - this.props.setBlocklistTableOption(payload); - - if (payload.pageSize) { - this.props.gotoBlocklistFirstPage(); - } - }; - - // - // Render - - render() { - return ( - - ); - } -} - -BlocklistConnector.propTypes = { - useCurrentPage: PropTypes.bool.isRequired, - isClearingBlocklistExecuting: PropTypes.bool.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchBlocklist: PropTypes.func.isRequired, - gotoBlocklistFirstPage: PropTypes.func.isRequired, - gotoBlocklistPreviousPage: PropTypes.func.isRequired, - gotoBlocklistNextPage: PropTypes.func.isRequired, - gotoBlocklistLastPage: PropTypes.func.isRequired, - gotoBlocklistPage: PropTypes.func.isRequired, - removeBlocklistItems: PropTypes.func.isRequired, - setBlocklistSort: PropTypes.func.isRequired, - setBlocklistTableOption: PropTypes.func.isRequired, - clearBlocklist: PropTypes.func.isRequired, - executeCommand: PropTypes.func.isRequired -}; - -export default withCurrentPage( - connect(createMapStateToProps, mapDispatchToProps)(BlocklistConnector) -); diff --git a/frontend/src/Activity/Blocklist/BlocklistDetailsModal.js b/frontend/src/Activity/Blocklist/BlocklistDetailsModal.js deleted file mode 100644 index 4e2197d5b77..00000000000 --- a/frontend/src/Activity/Blocklist/BlocklistDetailsModal.js +++ /dev/null @@ -1,89 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -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'; - -class BlocklistDetailsModal extends Component { - - // - // Render - - render() { - const { - isOpen, - sourceTitle, - protocol, - indexer, - message, - onModalClose - } = this.props; - - return ( - - - - Details - - - - - - - - - { - !!message && - - } - - { - !!message && - - } - - - - - - - - - ); - } -} - -BlocklistDetailsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - sourceTitle: PropTypes.string.isRequired, - protocol: PropTypes.string.isRequired, - indexer: PropTypes.string, - message: PropTypes.string, - onModalClose: PropTypes.func.isRequired -}; - -export default BlocklistDetailsModal; diff --git a/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx b/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx new file mode 100644 index 00000000000..ec026ae92e9 --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistDetailsModal.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +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 DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import translate from 'Utilities/String/translate'; + +interface BlocklistDetailsModalProps { + isOpen: boolean; + sourceTitle: string; + protocol: DownloadProtocol; + indexer?: string; + message?: string; + onModalClose: () => void; +} + +function BlocklistDetailsModal(props: BlocklistDetailsModalProps) { + const { isOpen, sourceTitle, protocol, indexer, message, onModalClose } = + props; + + return ( + + + Details + + + + + + + + {message ? ( + + ) : null} + + {message ? ( + + ) : null} + + + + + + + + + ); +} + +export default BlocklistDetailsModal; diff --git a/frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx b/frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx new file mode 100644 index 00000000000..ea80458f191 --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistFilterModal.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setBlocklistFilter } from 'Store/Actions/blocklistActions'; + +function createBlocklistSelector() { + return createSelector( + (state: AppState) => state.blocklist.items, + (blocklistItems) => { + return blocklistItems; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.blocklist.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface BlocklistFilterModalProps { + isOpen: boolean; +} + +export default function BlocklistFilterModal(props: BlocklistFilterModalProps) { + const sectionItems = useSelector(createBlocklistSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'blocklist'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setBlocklistFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Activity/Blocklist/BlocklistRow.css.d.ts b/frontend/src/Activity/Blocklist/BlocklistRow.css.d.ts new file mode 100644 index 00000000000..cc16b7e9eda --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistRow.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'actions': string; + 'indexer': string; + 'languages': string; + 'quality': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/Blocklist/BlocklistRow.js b/frontend/src/Activity/Blocklist/BlocklistRow.js deleted file mode 100644 index 30bb368445c..00000000000 --- a/frontend/src/Activity/Blocklist/BlocklistRow.js +++ /dev/null @@ -1,211 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import IconButton from 'Components/Link/IconButton'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; -import TableRow from 'Components/Table/TableRow'; -import EpisodeFormats from 'Episode/EpisodeFormats'; -import EpisodeLanguages from 'Episode/EpisodeLanguages'; -import EpisodeQuality from 'Episode/EpisodeQuality'; -import { icons, kinds } from 'Helpers/Props'; -import SeriesTitleLink from 'Series/SeriesTitleLink'; -import BlocklistDetailsModal from './BlocklistDetailsModal'; -import styles from './BlocklistRow.css'; - -class BlocklistRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - // - // Listeners - - onDetailsPress = () => { - this.setState({ isDetailsModalOpen: true }); - }; - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - series, - sourceTitle, - languages, - quality, - customFormats, - date, - protocol, - indexer, - message, - isSelected, - columns, - onSelectedChange, - onRemovePress - } = this.props; - - return ( - - - - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'series.sortTitle') { - return ( - - - - ); - } - - if (name === 'sourceTitle') { - return ( - - {sourceTitle} - - ); - } - - if (name === 'languages') { - return ( - - - - ); - } - - if (name === 'quality') { - return ( - - - - ); - } - - if (name === 'customFormats') { - return ( - - - - ); - } - - if (name === 'date') { - return ( - - ); - } - - if (name === 'indexer') { - return ( - - {indexer} - - ); - } - - if (name === 'actions') { - return ( - - - - - - ); - } - - return null; - }) - } - - - - ); - } - -} - -BlocklistRow.propTypes = { - id: PropTypes.number.isRequired, - series: PropTypes.object.isRequired, - sourceTitle: PropTypes.string.isRequired, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - quality: PropTypes.object.isRequired, - customFormats: PropTypes.arrayOf(PropTypes.object).isRequired, - date: PropTypes.string.isRequired, - protocol: PropTypes.string.isRequired, - indexer: PropTypes.string, - message: PropTypes.string, - isSelected: PropTypes.bool.isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onSelectedChange: PropTypes.func.isRequired, - onRemovePress: PropTypes.func.isRequired -}; - -export default BlocklistRow; diff --git a/frontend/src/Activity/Blocklist/BlocklistRow.tsx b/frontend/src/Activity/Blocklist/BlocklistRow.tsx new file mode 100644 index 00000000000..c7410320d3b --- /dev/null +++ b/frontend/src/Activity/Blocklist/BlocklistRow.tsx @@ -0,0 +1,163 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import EpisodeFormats from 'Episode/EpisodeFormats'; +import EpisodeLanguages from 'Episode/EpisodeLanguages'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import { icons, kinds } from 'Helpers/Props'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import useSeries from 'Series/useSeries'; +import { removeBlocklistItem } from 'Store/Actions/blocklistActions'; +import Blocklist from 'typings/Blocklist'; +import { SelectStateInputProps } from 'typings/props'; +import translate from 'Utilities/String/translate'; +import BlocklistDetailsModal from './BlocklistDetailsModal'; +import styles from './BlocklistRow.css'; + +interface BlocklistRowProps extends Blocklist { + isSelected: boolean; + columns: Column[]; + onSelectedChange: (options: SelectStateInputProps) => void; +} + +function BlocklistRow(props: BlocklistRowProps) { + const { + id, + seriesId, + sourceTitle, + languages, + quality, + customFormats, + date, + protocol, + indexer, + message, + isSelected, + columns, + onSelectedChange, + } = props; + + const series = useSeries(seriesId); + const dispatch = useDispatch(); + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const handleDetailsPress = useCallback(() => { + setIsDetailsModalOpen(true); + }, [setIsDetailsModalOpen]); + + const handleDetailsModalClose = useCallback(() => { + setIsDetailsModalOpen(false); + }, [setIsDetailsModalOpen]); + + const handleRemovePress = useCallback(() => { + dispatch(removeBlocklistItem({ id })); + }, [id, dispatch]); + + if (!series) { + return null; + } + + return ( + + + + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'sourceTitle') { + return {sourceTitle}; + } + + if (name === 'languages') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'customFormats') { + return ( + + + + ); + } + + if (name === 'date') { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore ts(2739) + return ; + } + + if (name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (name === 'actions') { + return ( + + + + + + ); + } + + return null; + })} + + + + ); +} + +export default BlocklistRow; diff --git a/frontend/src/Activity/Blocklist/BlocklistRowConnector.js b/frontend/src/Activity/Blocklist/BlocklistRowConnector.js deleted file mode 100644 index f0b93cd25cc..00000000000 --- a/frontend/src/Activity/Blocklist/BlocklistRowConnector.js +++ /dev/null @@ -1,26 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { removeBlocklistItem } from 'Store/Actions/blocklistActions'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import BlocklistRow from './BlocklistRow'; - -function createMapStateToProps() { - return createSelector( - createSeriesSelector(), - (series) => { - return { - series - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onRemovePress() { - dispatch(removeBlocklistItem({ id: props.id })); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(BlocklistRow); diff --git a/frontend/src/Activity/History/Details/HistoryDetails.css.d.ts b/frontend/src/Activity/History/Details/HistoryDetails.css.d.ts new file mode 100644 index 00000000000..ff7055b0f08 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'description': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js deleted file mode 100644 index 293c6d47764..00000000000 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ /dev/null @@ -1,334 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; -import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; -import Link from 'Components/Link/Link'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import formatAge from 'Utilities/Number/formatAge'; -import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore'; -import styles from './HistoryDetails.css'; - -function HistoryDetails(props) { - const { - eventType, - sourceTitle, - data, - shortDateFormat, - timeFormat - } = props; - - if (eventType === 'grabbed') { - const { - indexer, - releaseGroup, - seriesMatchType, - customFormatScore, - nzbInfoUrl, - downloadClient, - downloadClientName, - downloadId, - age, - ageHours, - ageMinutes, - publishedDate - } = data; - - const downloadClientNameInfo = downloadClientName ?? downloadClient; - - return ( - - - - { - indexer ? - : - null - } - - { - releaseGroup ? - : - null - } - - { - customFormatScore && customFormatScore !== '0' ? - : - null - } - - { - seriesMatchType ? - : - null - } - - { - nzbInfoUrl ? - - - Info URL - - - - {nzbInfoUrl} - - : - null - } - - { - downloadClientNameInfo ? - : - null - } - - { - downloadId ? - : - null - } - - { - age || ageHours || ageMinutes ? - : - null - } - - { - publishedDate ? - : - null - } - - ); - } - - if (eventType === 'downloadFailed') { - const { - message - } = data; - - return ( - - - - { - message ? - : - null - } - - ); - } - - if (eventType === 'downloadFolderImported') { - const { - customFormatScore, - droppedPath, - importedPath - } = data; - - return ( - - - - { - droppedPath ? - : - null - } - - { - importedPath ? - : - null - } - - { - customFormatScore && customFormatScore !== '0' ? - : - null - } - - ); - } - - if (eventType === 'episodeFileDeleted') { - const { - reason, - customFormatScore - } = data; - - let reasonMessage = ''; - - switch (reason) { - case 'Manual': - reasonMessage = 'File was deleted by via UI'; - break; - case 'MissingFromDisk': - reasonMessage = 'Sonarr was unable to find the file on disk so the file was unlinked from the episode in the database'; - break; - case 'Upgrade': - reasonMessage = 'File was deleted to import an upgrade'; - break; - default: - reasonMessage = ''; - } - - return ( - - - - - - { - customFormatScore && customFormatScore !== '0' ? - : - null - } - - ); - } - - if (eventType === 'episodeFileRenamed') { - const { - sourcePath, - sourceRelativePath, - path, - relativePath - } = data; - - return ( - - - - - - - - - - ); - } - - if (eventType === 'downloadIgnored') { - const { - message - } = data; - - return ( - - - - { - message ? - : - null - } - - ); - } - - return ( - - - - ); -} - -HistoryDetails.propTypes = { - eventType: PropTypes.string.isRequired, - sourceTitle: PropTypes.string.isRequired, - data: PropTypes.object.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default HistoryDetails; diff --git a/frontend/src/Activity/History/Details/HistoryDetails.tsx b/frontend/src/Activity/History/Details/HistoryDetails.tsx new file mode 100644 index 00000000000..f460ec433f6 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetails.tsx @@ -0,0 +1,321 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; +import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; +import Link from 'Components/Link/Link'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { + DownloadFailedHistory, + DownloadFolderImportedHistory, + DownloadIgnoredHistory, + EpisodeFileDeletedHistory, + EpisodeFileRenamedHistory, + GrabbedHistoryData, + HistoryData, + HistoryEventType, +} from 'typings/History'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import translate from 'Utilities/String/translate'; +import styles from './HistoryDetails.css'; + +interface HistoryDetailsProps { + eventType: HistoryEventType; + sourceTitle: string; + data: HistoryData; + downloadId?: string; +} + +function HistoryDetails(props: HistoryDetailsProps) { + const { eventType, sourceTitle, data, downloadId } = props; + + const { shortDateFormat, timeFormat } = useSelector( + createUISettingsSelector() + ); + + if (eventType === 'grabbed') { + const { + indexer, + releaseGroup, + seriesMatchType, + releaseSource, + customFormatScore, + nzbInfoUrl, + downloadClient, + downloadClientName, + age, + ageHours, + ageMinutes, + publishedDate, + } = data as GrabbedHistoryData; + + const downloadClientNameInfo = downloadClientName ?? downloadClient; + + let releaseSourceMessage = ''; + + switch (releaseSource) { + case 'Unknown': + releaseSourceMessage = translate('Unknown'); + break; + case 'Rss': + releaseSourceMessage = translate('Rss'); + break; + case 'Search': + releaseSourceMessage = translate('Search'); + break; + case 'UserInvokedSearch': + releaseSourceMessage = translate('UserInvokedSearch'); + break; + case 'InteractiveSearch': + releaseSourceMessage = translate('InteractiveSearch'); + break; + case 'ReleasePush': + releaseSourceMessage = translate('ReleasePush'); + break; + default: + releaseSourceMessage = ''; + } + + return ( + + + + {indexer ? ( + + ) : null} + + {releaseGroup ? ( + + ) : null} + + {customFormatScore && customFormatScore !== '0' ? ( + + ) : null} + + {seriesMatchType ? ( + + ) : null} + + {releaseSource ? ( + + ) : null} + + {nzbInfoUrl ? ( + + + {translate('InfoUrl')} + + + + {nzbInfoUrl} + + + ) : null} + + {downloadClientNameInfo ? ( + + ) : null} + + {downloadId ? ( + + ) : null} + + {age || ageHours || ageMinutes ? ( + + ) : null} + + {publishedDate ? ( + + ) : null} + + ); + } + + if (eventType === 'downloadFailed') { + const { message } = data as DownloadFailedHistory; + + return ( + + + + {downloadId ? ( + + ) : null} + + {message ? ( + + ) : null} + + ); + } + + if (eventType === 'downloadFolderImported') { + const { customFormatScore, droppedPath, importedPath } = + data as DownloadFolderImportedHistory; + + return ( + + + + {droppedPath ? ( + + ) : null} + + {importedPath ? ( + + ) : null} + + {customFormatScore && customFormatScore !== '0' ? ( + + ) : null} + + ); + } + + if (eventType === 'episodeFileDeleted') { + const { reason, customFormatScore } = data as EpisodeFileDeletedHistory; + + let reasonMessage = ''; + + switch (reason) { + case 'Manual': + reasonMessage = translate('DeletedReasonManual'); + break; + case 'MissingFromDisk': + reasonMessage = translate('DeletedReasonEpisodeMissingFromDisk'); + break; + case 'Upgrade': + reasonMessage = translate('DeletedReasonUpgrade'); + break; + default: + reasonMessage = ''; + } + + return ( + + + + + + {customFormatScore && customFormatScore !== '0' ? ( + + ) : null} + + ); + } + + if (eventType === 'episodeFileRenamed') { + const { sourcePath, sourceRelativePath, path, relativePath } = + data as EpisodeFileRenamedHistory; + + return ( + + + + + + + + + + ); + } + + if (eventType === 'downloadIgnored') { + const { message } = data as DownloadIgnoredHistory; + + return ( + + + + {downloadId ? ( + + ) : null} + + {message ? ( + + ) : null} + + ); + } + + return ( + + + + ); +} + +export default HistoryDetails; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js b/frontend/src/Activity/History/Details/HistoryDetailsConnector.js deleted file mode 100644 index 0848c7905a6..00000000000 --- a/frontend/src/Activity/History/Details/HistoryDetailsConnector.js +++ /dev/null @@ -1,19 +0,0 @@ -import _ from 'lodash'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import HistoryDetails from './HistoryDetails'; - -function createMapStateToProps() { - return createSelector( - createUISettingsSelector(), - (uiSettings) => { - return _.pick(uiSettings, [ - 'shortDateFormat', - 'timeFormat' - ]); - } - ); -} - -export default connect(createMapStateToProps)(HistoryDetails); diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.css.d.ts b/frontend/src/Activity/History/Details/HistoryDetailsModal.css.d.ts new file mode 100644 index 00000000000..a8cc499e288 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'markAsFailedButton': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.js deleted file mode 100644 index b993729b93c..00000000000 --- a/frontend/src/Activity/History/Details/HistoryDetailsModal.js +++ /dev/null @@ -1,106 +0,0 @@ -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 HistoryDetails from './HistoryDetails'; -import styles from './HistoryDetailsModal.css'; - -function getHeaderTitle(eventType) { - switch (eventType) { - case 'grabbed': - return 'Grabbed'; - case 'downloadFailed': - return 'Download Failed'; - case 'downloadFolderImported': - return 'Episode Imported'; - case 'episodeFileDeleted': - return 'Episode File Deleted'; - case 'episodeFileRenamed': - return 'Episode File Renamed'; - case 'downloadIgnored': - return 'Download Ignored'; - default: - return 'Unknown'; - } -} - -function HistoryDetailsModal(props) { - const { - isOpen, - eventType, - sourceTitle, - data, - isMarkingAsFailed, - shortDateFormat, - timeFormat, - onMarkAsFailedPress, - onModalClose - } = props; - - return ( - - - - {getHeaderTitle(eventType)} - - - - - - - - { - eventType === 'grabbed' && - - Mark as Failed - - } - - - - - - ); -} - -HistoryDetailsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - eventType: PropTypes.string.isRequired, - sourceTitle: PropTypes.string.isRequired, - data: PropTypes.object.isRequired, - isMarkingAsFailed: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onMarkAsFailedPress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -HistoryDetailsModal.defaultProps = { - isMarkingAsFailed: false -}; - -export default HistoryDetailsModal; diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.tsx b/frontend/src/Activity/History/Details/HistoryDetailsModal.tsx new file mode 100644 index 00000000000..8134a9736b1 --- /dev/null +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.tsx @@ -0,0 +1,90 @@ +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 { HistoryData, HistoryEventType } from 'typings/History'; +import translate from 'Utilities/String/translate'; +import HistoryDetails from './HistoryDetails'; +import styles from './HistoryDetailsModal.css'; + +function getHeaderTitle(eventType: HistoryEventType) { + switch (eventType) { + case 'grabbed': + return translate('Grabbed'); + case 'downloadFailed': + return translate('DownloadFailed'); + case 'downloadFolderImported': + return translate('EpisodeImported'); + case 'episodeFileDeleted': + return translate('EpisodeFileDeleted'); + case 'episodeFileRenamed': + return translate('EpisodeFileRenamed'); + case 'downloadIgnored': + return translate('DownloadIgnored'); + default: + return translate('Unknown'); + } +} + +interface HistoryDetailsModalProps { + isOpen: boolean; + eventType: HistoryEventType; + sourceTitle: string; + data: HistoryData; + downloadId?: string; + isMarkingAsFailed: boolean; + onMarkAsFailedPress: () => void; + onModalClose: () => void; +} + +function HistoryDetailsModal(props: HistoryDetailsModalProps) { + const { + isOpen, + eventType, + sourceTitle, + data, + downloadId, + isMarkingAsFailed = false, + onMarkAsFailedPress, + onModalClose, + } = props; + + return ( + + + {getHeaderTitle(eventType)} + + + + + + + {eventType === 'grabbed' && ( + + {translate('MarkAsFailed')} + + )} + + + + + + ); +} + +export default HistoryDetailsModal; diff --git a/frontend/src/Activity/History/History.js b/frontend/src/Activity/History/History.js deleted file mode 100644 index 6297babaddd..00000000000 --- a/frontend/src/Activity/History/History.js +++ /dev/null @@ -1,172 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -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 Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import TablePager from 'Components/Table/TablePager'; -import { align, icons } from 'Helpers/Props'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import HistoryRowConnector from './HistoryRowConnector'; - -class History extends Component { - - // - // Lifecycle - - shouldComponentUpdate(nextProps) { - // Don't update when fetching has completed if items have changed, - // before episodes start fetching or when episodes start fetching. - - if ( - ( - this.props.isFetching && - nextProps.isPopulated && - hasDifferentItems(this.props.items, nextProps.items) - ) || - (!this.props.isEpisodesFetching && nextProps.isEpisodesFetching) - ) { - return false; - } - - return true; - } - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - items, - columns, - selectedFilterKey, - filters, - totalRecords, - isEpisodesFetching, - isEpisodesPopulated, - episodesError, - onFilterSelect, - onFirstPagePress, - ...otherProps - } = this.props; - - const isFetchingAny = isFetching || isEpisodesFetching; - const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length); - const hasError = error || episodesError; - - return ( - - - - - - - - - - - - - - - - - { - isFetchingAny && !isAllPopulated && - - } - - { - !isFetchingAny && hasError && -
Unable to load history
- } - - { - // If history isPopulated and it's empty show no history found and don't - // wait for the episodes to populate because they are never coming. - - isPopulated && !hasError && !items.length && -
- No history found -
- } - - { - isAllPopulated && !hasError && !!items.length && -
- - - { - items.map((item) => { - return ( - - ); - }) - } - -
- - -
- } -
-
- ); - } -} - -History.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - selectedFilterKey: PropTypes.string.isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - totalRecords: PropTypes.number, - isEpisodesFetching: PropTypes.bool.isRequired, - isEpisodesPopulated: PropTypes.bool.isRequired, - episodesError: PropTypes.object, - onFilterSelect: PropTypes.func.isRequired, - onFirstPagePress: PropTypes.func.isRequired -}; - -export default History; diff --git a/frontend/src/Activity/History/History.tsx b/frontend/src/Activity/History/History.tsx new file mode 100644 index 00000000000..9f00a1ab378 --- /dev/null +++ b/frontend/src/Activity/History/History.tsx @@ -0,0 +1,231 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import usePaging from 'Components/Table/usePaging'; +import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import { align, icons, kinds } from 'Helpers/Props'; +import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; +import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; +import { + clearHistory, + fetchHistory, + gotoHistoryPage, + setHistoryFilter, + setHistorySort, + setHistoryTableOption, +} from 'Store/Actions/historyActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import HistoryItem from 'typings/History'; +import { TableOptionsChangePayload } from 'typings/Table'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import { + registerPagePopulator, + unregisterPagePopulator, +} from 'Utilities/pagePopulator'; +import translate from 'Utilities/String/translate'; +import HistoryFilterModal from './HistoryFilterModal'; +import HistoryRow from './HistoryRow'; + +function History() { + const requestCurrentPage = useCurrentPage(); + + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + sortKey, + sortDirection, + page, + pageSize, + totalPages, + totalRecords, + } = useSelector((state: AppState) => state.history); + + const { isEpisodesFetching, isEpisodesPopulated, episodesError } = + useSelector(createEpisodesFetchingSelector()); + const customFilters = useSelector(createCustomFiltersSelector('history')); + const dispatch = useDispatch(); + + const isFetchingAny = isFetching || isEpisodesFetching; + const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length); + const hasError = error || episodesError; + + const { + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + } = usePaging({ + page, + totalPages, + gotoPage: gotoHistoryPage, + }); + + const handleFilterSelect = useCallback( + (selectedFilterKey: string) => { + dispatch(setHistoryFilter({ selectedFilterKey })); + }, + [dispatch] + ); + + const handleSortPress = useCallback( + (sortKey: string) => { + dispatch(setHistorySort({ sortKey })); + }, + [dispatch] + ); + + const handleTableOptionChange = useCallback( + (payload: TableOptionsChangePayload) => { + dispatch(setHistoryTableOption(payload)); + + if (payload.pageSize) { + dispatch(gotoHistoryPage({ page: 1 })); + } + }, + [dispatch] + ); + + useEffect(() => { + if (requestCurrentPage) { + dispatch(fetchHistory()); + } else { + dispatch(gotoHistoryPage({ page: 1 })); + } + + return () => { + dispatch(clearHistory()); + dispatch(clearEpisodes()); + dispatch(clearEpisodeFiles()); + }; + }, [requestCurrentPage, dispatch]); + + useEffect(() => { + const episodeIds = selectUniqueIds(items, 'episodeId'); + + if (episodeIds.length) { + dispatch(fetchEpisodes({ episodeIds })); + } else { + dispatch(clearEpisodes()); + } + }, [items, dispatch]); + + useEffect(() => { + const repopulate = () => { + dispatch(fetchHistory()); + }; + + registerPagePopulator(repopulate); + + return () => { + unregisterPagePopulator(repopulate); + }; + }, [dispatch]); + + return ( + + + + + + + + + + + + + + + + + {isFetchingAny && !isAllPopulated ? : null} + + {!isFetchingAny && hasError ? ( + {translate('HistoryLoadError')} + ) : null} + + { + // If history isPopulated and it's empty show no history found and don't + // wait for the episodes to populate because they are never coming. + + isPopulated && !hasError && !items.length ? ( + {translate('NoHistoryFound')} + ) : null + } + + {isAllPopulated && !hasError && items.length ? ( +
+ + + {items.map((item) => { + return ( + + ); + })} + +
+ + +
+ ) : null} +
+
+ ); +} + +export default History; diff --git a/frontend/src/Activity/History/HistoryConnector.js b/frontend/src/Activity/History/HistoryConnector.js deleted file mode 100644 index 74b7fdfb4f6..00000000000 --- a/frontend/src/Activity/History/HistoryConnector.js +++ /dev/null @@ -1,162 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import withCurrentPage from 'Components/withCurrentPage'; -import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; -import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions'; -import * as historyActions from 'Store/Actions/historyActions'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import History from './History'; - -function createMapStateToProps() { - return createSelector( - (state) => state.history, - (state) => state.episodes, - (history, episodes) => { - return { - isEpisodesFetching: episodes.isFetching, - isEpisodesPopulated: episodes.isPopulated, - episodesError: episodes.error, - ...history - }; - } - ); -} - -const mapDispatchToProps = { - ...historyActions, - fetchEpisodes, - clearEpisodes, - clearEpisodeFiles -}; - -class HistoryConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - useCurrentPage, - fetchHistory, - gotoHistoryFirstPage - } = this.props; - - registerPagePopulator(this.repopulate); - - if (useCurrentPage) { - fetchHistory(); - } else { - gotoHistoryFirstPage(); - } - } - - componentDidUpdate(prevProps) { - if (hasDifferentItems(prevProps.items, this.props.items)) { - const episodeIds = selectUniqueIds(this.props.items, 'episodeId'); - - if (episodeIds.length) { - this.props.fetchEpisodes({ episodeIds }); - } else { - this.props.clearEpisodes(); - } - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - this.props.clearHistory(); - this.props.clearEpisodes(); - this.props.clearEpisodeFiles(); - } - - // - // Control - - repopulate = () => { - this.props.fetchHistory(); - }; - - // - // Listeners - - onFirstPagePress = () => { - this.props.gotoHistoryFirstPage(); - }; - - onPreviousPagePress = () => { - this.props.gotoHistoryPreviousPage(); - }; - - onNextPagePress = () => { - this.props.gotoHistoryNextPage(); - }; - - onLastPagePress = () => { - this.props.gotoHistoryLastPage(); - }; - - onPageSelect = (page) => { - this.props.gotoHistoryPage({ page }); - }; - - onSortPress = (sortKey) => { - this.props.setHistorySort({ sortKey }); - }; - - onFilterSelect = (selectedFilterKey) => { - this.props.setHistoryFilter({ selectedFilterKey }); - }; - - onTableOptionChange = (payload) => { - this.props.setHistoryTableOption(payload); - - if (payload.pageSize) { - this.props.gotoHistoryFirstPage(); - } - }; - - // - // Render - - render() { - return ( - - ); - } -} - -HistoryConnector.propTypes = { - useCurrentPage: PropTypes.bool.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchHistory: PropTypes.func.isRequired, - gotoHistoryFirstPage: PropTypes.func.isRequired, - gotoHistoryPreviousPage: PropTypes.func.isRequired, - gotoHistoryNextPage: PropTypes.func.isRequired, - gotoHistoryLastPage: PropTypes.func.isRequired, - gotoHistoryPage: PropTypes.func.isRequired, - setHistorySort: PropTypes.func.isRequired, - setHistoryFilter: PropTypes.func.isRequired, - setHistoryTableOption: PropTypes.func.isRequired, - clearHistory: PropTypes.func.isRequired, - fetchEpisodes: PropTypes.func.isRequired, - clearEpisodes: PropTypes.func.isRequired, - clearEpisodeFiles: PropTypes.func.isRequired -}; - -export default withCurrentPage( - connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector) -); diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.css.d.ts b/frontend/src/Activity/History/HistoryEventTypeCell.css.d.ts new file mode 100644 index 00000000000..c748f6f97c3 --- /dev/null +++ b/frontend/src/Activity/History/HistoryEventTypeCell.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'cell': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.js deleted file mode 100644 index d39db646b9e..00000000000 --- a/frontend/src/Activity/History/HistoryEventTypeCell.js +++ /dev/null @@ -1,86 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import { icons, kinds } from 'Helpers/Props'; -import styles from './HistoryEventTypeCell.css'; - -function getIconName(eventType) { - switch (eventType) { - case 'grabbed': - return icons.DOWNLOADING; - case 'seriesFolderImported': - return icons.DRIVE; - case 'downloadFolderImported': - return icons.DOWNLOADED; - case 'downloadFailed': - return icons.DOWNLOADING; - case 'episodeFileDeleted': - return icons.DELETE; - case 'episodeFileRenamed': - return icons.ORGANIZE; - case 'downloadIgnored': - return icons.IGNORE; - default: - return icons.UNKNOWN; - } -} - -function getIconKind(eventType) { - switch (eventType) { - case 'downloadFailed': - return kinds.DANGER; - default: - return kinds.DEFAULT; - } -} - -function getTooltip(eventType, data) { - switch (eventType) { - case 'grabbed': - return `Episode grabbed from ${data.indexer} and sent to ${data.downloadClient}`; - case 'seriesFolderImported': - return 'Episode imported from series folder'; - case 'downloadFolderImported': - return 'Episode downloaded successfully and picked up from download client'; - case 'downloadFailed': - return 'Episode download failed'; - case 'episodeFileDeleted': - return 'Episode file deleted'; - case 'episodeFileRenamed': - return 'Episode file renamed'; - case 'downloadIgnored': - return 'Episode Download Ignored'; - default: - return 'Unknown event'; - } -} - -function HistoryEventTypeCell({ eventType, data }) { - const iconName = getIconName(eventType); - const iconKind = getIconKind(eventType); - const tooltip = getTooltip(eventType, data); - - return ( - - - - ); -} - -HistoryEventTypeCell.propTypes = { - eventType: PropTypes.string.isRequired, - data: PropTypes.object -}; - -HistoryEventTypeCell.defaultProps = { - data: {} -}; - -export default HistoryEventTypeCell; diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.tsx b/frontend/src/Activity/History/HistoryEventTypeCell.tsx new file mode 100644 index 00000000000..adedf08c0e3 --- /dev/null +++ b/frontend/src/Activity/History/HistoryEventTypeCell.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import { icons, kinds } from 'Helpers/Props'; +import { + EpisodeFileDeletedHistory, + GrabbedHistoryData, + HistoryData, + HistoryEventType, +} from 'typings/History'; +import translate from 'Utilities/String/translate'; +import styles from './HistoryEventTypeCell.css'; + +function getIconName(eventType: HistoryEventType, data: HistoryData) { + switch (eventType) { + case 'grabbed': + return icons.DOWNLOADING; + case 'seriesFolderImported': + return icons.DRIVE; + case 'downloadFolderImported': + return icons.DOWNLOADED; + case 'downloadFailed': + return icons.DOWNLOADING; + case 'episodeFileDeleted': + return (data as EpisodeFileDeletedHistory).reason === 'MissingFromDisk' + ? icons.FILE_MISSING + : icons.DELETE; + case 'episodeFileRenamed': + return icons.ORGANIZE; + case 'downloadIgnored': + return icons.IGNORE; + default: + return icons.UNKNOWN; + } +} + +function getIconKind(eventType: HistoryEventType) { + switch (eventType) { + case 'downloadFailed': + return kinds.DANGER; + default: + return kinds.DEFAULT; + } +} + +function getTooltip(eventType: HistoryEventType, data: HistoryData) { + switch (eventType) { + case 'grabbed': + return translate('EpisodeGrabbedTooltip', { + indexer: (data as GrabbedHistoryData).indexer, + downloadClient: (data as GrabbedHistoryData).downloadClient, + }); + case 'seriesFolderImported': + return translate('SeriesFolderImportedTooltip'); + case 'downloadFolderImported': + return translate('EpisodeImportedTooltip'); + case 'downloadFailed': + return translate('DownloadFailedEpisodeTooltip'); + case 'episodeFileDeleted': + return (data as EpisodeFileDeletedHistory).reason === 'MissingFromDisk' + ? translate('EpisodeFileMissingTooltip') + : translate('EpisodeFileDeletedTooltip'); + case 'episodeFileRenamed': + return translate('EpisodeFileRenamedTooltip'); + case 'downloadIgnored': + return translate('DownloadIgnoredEpisodeTooltip'); + default: + return translate('UnknownEventTooltip'); + } +} + +interface HistoryEventTypeCellProps { + eventType: HistoryEventType; + data: HistoryData; +} + +function HistoryEventTypeCell({ eventType, data }: HistoryEventTypeCellProps) { + const iconName = getIconName(eventType, data); + const iconKind = getIconKind(eventType); + const tooltip = getTooltip(eventType, data); + + return ( + + + + ); +} + +export default HistoryEventTypeCell; diff --git a/frontend/src/Activity/History/HistoryFilterModal.tsx b/frontend/src/Activity/History/HistoryFilterModal.tsx new file mode 100644 index 00000000000..f4ad2e57cc8 --- /dev/null +++ b/frontend/src/Activity/History/HistoryFilterModal.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setHistoryFilter } from 'Store/Actions/historyActions'; + +function createHistorySelector() { + return createSelector( + (state: AppState) => state.history.items, + (queueItems) => { + return queueItems; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.history.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface HistoryFilterModalProps { + isOpen: boolean; +} + +export default function HistoryFilterModal(props: HistoryFilterModalProps) { + const sectionItems = useSelector(createHistorySelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'history'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setHistoryFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Activity/History/HistoryRow.css.d.ts b/frontend/src/Activity/History/HistoryRow.css.d.ts new file mode 100644 index 00000000000..e1f54bc96c9 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'customFormatScore': string; + 'details': string; + 'downloadClient': string; + 'indexer': string; + 'releaseGroup': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js deleted file mode 100644 index 9b5a15c23e1..00000000000 --- a/frontend/src/Activity/History/HistoryRow.js +++ /dev/null @@ -1,295 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import IconButton from 'Components/Link/IconButton'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRow from 'Components/Table/TableRow'; -import episodeEntities from 'Episode/episodeEntities'; -import EpisodeFormats from 'Episode/EpisodeFormats'; -import EpisodeLanguages from 'Episode/EpisodeLanguages'; -import EpisodeQuality from 'Episode/EpisodeQuality'; -import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; -import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; -import { icons } from 'Helpers/Props'; -import SeriesTitleLink from 'Series/SeriesTitleLink'; -import formatPreferredWordScore from 'Utilities/Number/formatPreferredWordScore'; -import HistoryDetailsModal from './Details/HistoryDetailsModal'; -import HistoryEventTypeCell from './HistoryEventTypeCell'; -import styles from './HistoryRow.css'; - -class HistoryRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - componentDidUpdate(prevProps) { - if ( - prevProps.isMarkingAsFailed && - !this.props.isMarkingAsFailed && - !this.props.markAsFailedError - ) { - this.setState({ isDetailsModalOpen: false }); - } - } - - // - // Listeners - - onDetailsPress = () => { - this.setState({ isDetailsModalOpen: true }); - }; - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }); - }; - - // - // Render - - render() { - const { - episodeId, - series, - episode, - languages, - quality, - customFormats, - customFormatScore, - qualityCutoffNotMet, - eventType, - sourceTitle, - date, - data, - isMarkingAsFailed, - columns, - shortDateFormat, - timeFormat, - onMarkAsFailedPress - } = this.props; - - if (!episode) { - return null; - } - - return ( - - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'eventType') { - return ( - - ); - } - - if (name === 'series.sortTitle') { - return ( - - - - ); - } - - if (name === 'episode') { - return ( - - - - ); - } - - if (name === 'episodes.title') { - return ( - - - - ); - } - - if (name === 'languages') { - return ( - - - - ); - } - - if (name === 'quality') { - return ( - - - - ); - } - - if (name === 'customFormats') { - return ( - - - - ); - } - - if (name === 'date') { - return ( - - ); - } - - if (name === 'downloadClient') { - return ( - - {data.downloadClient} - - ); - } - - if (name === 'indexer') { - return ( - - {data.indexer} - - ); - } - - if (name === 'customFormatScore') { - return ( - - {formatPreferredWordScore(customFormatScore)} - - ); - } - - if (name === 'releaseGroup') { - return ( - - {data.releaseGroup} - - ); - } - - if (name === 'sourceTitle') { - return ( - - {sourceTitle} - - ); - } - - if (name === 'details') { - return ( - - - - ); - } - - return null; - }) - } - - - - ); - } - -} - -HistoryRow.propTypes = { - episodeId: PropTypes.number, - series: PropTypes.object.isRequired, - episode: PropTypes.object, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - quality: PropTypes.object.isRequired, - customFormats: PropTypes.arrayOf(PropTypes.object), - customFormatScore: PropTypes.number.isRequired, - qualityCutoffNotMet: PropTypes.bool.isRequired, - eventType: PropTypes.string.isRequired, - sourceTitle: PropTypes.string.isRequired, - date: PropTypes.string.isRequired, - data: PropTypes.object.isRequired, - isMarkingAsFailed: PropTypes.bool, - markAsFailedError: PropTypes.object, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onMarkAsFailedPress: PropTypes.func.isRequired -}; - -export default HistoryRow; diff --git a/frontend/src/Activity/History/HistoryRow.tsx b/frontend/src/Activity/History/HistoryRow.tsx new file mode 100644 index 00000000000..d1ba279dce9 --- /dev/null +++ b/frontend/src/Activity/History/HistoryRow.tsx @@ -0,0 +1,270 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import IconButton from 'Components/Link/IconButton'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import episodeEntities from 'Episode/episodeEntities'; +import EpisodeFormats from 'Episode/EpisodeFormats'; +import EpisodeLanguages from 'Episode/EpisodeLanguages'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import useEpisode from 'Episode/useEpisode'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { icons, tooltipPositions } from 'Helpers/Props'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import useSeries from 'Series/useSeries'; +import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; +import CustomFormat from 'typings/CustomFormat'; +import { HistoryData, HistoryEventType } from 'typings/History'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import HistoryDetailsModal from './Details/HistoryDetailsModal'; +import HistoryEventTypeCell from './HistoryEventTypeCell'; +import styles from './HistoryRow.css'; + +interface HistoryRowProps { + id: number; + episodeId: number; + seriesId: number; + languages: Language[]; + quality: QualityModel; + customFormats?: CustomFormat[]; + customFormatScore: number; + qualityCutoffNotMet: boolean; + eventType: HistoryEventType; + sourceTitle: string; + date: string; + data: HistoryData; + downloadId?: string; + isMarkingAsFailed?: boolean; + markAsFailedError?: object; + columns: Column[]; +} + +function HistoryRow(props: HistoryRowProps) { + const { + id, + episodeId, + seriesId, + languages, + quality, + customFormats = [], + customFormatScore, + qualityCutoffNotMet, + eventType, + sourceTitle, + date, + data, + downloadId, + isMarkingAsFailed = false, + markAsFailedError, + columns, + } = props; + + const wasMarkingAsFailed = usePrevious(isMarkingAsFailed); + const dispatch = useDispatch(); + const series = useSeries(seriesId); + const episode = useEpisode(episodeId, 'episodes'); + + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const handleDetailsPress = useCallback(() => { + setIsDetailsModalOpen(true); + }, [setIsDetailsModalOpen]); + + const handleDetailsModalClose = useCallback(() => { + setIsDetailsModalOpen(false); + }, [setIsDetailsModalOpen]); + + const handleMarkAsFailedPress = useCallback(() => { + dispatch(markAsFailed({ id })); + }, [id, dispatch]); + + useEffect(() => { + if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) { + setIsDetailsModalOpen(false); + dispatch(fetchHistory()); + } + }, [ + wasMarkingAsFailed, + isMarkingAsFailed, + markAsFailedError, + setIsDetailsModalOpen, + dispatch, + ]); + + if (!series || !episode) { + return null; + } + + return ( + + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'eventType') { + return ( + + ); + } + + if (name === 'series.sortTitle') { + return ( + + + + ); + } + + if (name === 'episode') { + return ( + + + + ); + } + + if (name === 'episodes.title') { + return ( + + + + ); + } + + if (name === 'languages') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + + + ); + } + + if (name === 'customFormats') { + return ( + + + + ); + } + + if (name === 'date') { + return ; + } + + if (name === 'downloadClient') { + const downloadClientName = + 'downloadClientName' in data ? data.downloadClientName : null; + const downloadClient = + 'downloadClient' in data ? data.downloadClient : null; + + return ( + + {downloadClientName ?? downloadClient ?? ''} + + ); + } + + if (name === 'indexer') { + return ( + + {'indexer' in data ? data.indexer : ''} + + ); + } + + if (name === 'customFormatScore') { + return ( + + } + position={tooltipPositions.BOTTOM} + /> + + ); + } + + if (name === 'releaseGroup') { + return ( + + {'releaseGroup' in data ? data.releaseGroup : ''} + + ); + } + + if (name === 'sourceTitle') { + return {sourceTitle}; + } + + if (name === 'details') { + return ( + + + + ); + } + + return null; + })} + + + + ); +} + +export default HistoryRow; diff --git a/frontend/src/Activity/History/HistoryRowConnector.js b/frontend/src/Activity/History/HistoryRowConnector.js deleted file mode 100644 index b5d6223f65e..00000000000 --- a/frontend/src/Activity/History/HistoryRowConnector.js +++ /dev/null @@ -1,76 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; -import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import HistoryRow from './HistoryRow'; - -function createMapStateToProps() { - return createSelector( - createSeriesSelector(), - createEpisodeSelector(), - createUISettingsSelector(), - (series, episode, uiSettings) => { - return { - series, - episode, - shortDateFormat: uiSettings.shortDateFormat, - timeFormat: uiSettings.timeFormat - }; - } - ); -} - -const mapDispatchToProps = { - fetchHistory, - markAsFailed -}; - -class HistoryRowConnector extends Component { - - // - // Lifecycle - - componentDidUpdate(prevProps) { - if ( - prevProps.isMarkingAsFailed && - !this.props.isMarkingAsFailed && - !this.props.markAsFailedError - ) { - this.props.fetchHistory(); - } - } - - // - // Listeners - - onMarkAsFailedPress = () => { - this.props.markAsFailed({ id: this.props.id }); - }; - - // - // Render - - render() { - return ( - - ); - } - -} - -HistoryRowConnector.propTypes = { - id: PropTypes.number.isRequired, - isMarkingAsFailed: PropTypes.bool, - markAsFailedError: PropTypes.object, - fetchHistory: PropTypes.func.isRequired, - markAsFailed: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(HistoryRowConnector); diff --git a/frontend/src/Activity/Queue/ProtocolLabel.css b/frontend/src/Activity/Queue/ProtocolLabel.css index 110c7e01cb9..c94e383b1e0 100644 --- a/frontend/src/Activity/Queue/ProtocolLabel.css +++ b/frontend/src/Activity/Queue/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/Activity/Queue/ProtocolLabel.css.d.ts b/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts new file mode 100644 index 00000000000..ba0cb260da8 --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'torrent': string; + 'unknown': string; + 'usenet': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/Queue/ProtocolLabel.js b/frontend/src/Activity/Queue/ProtocolLabel.js deleted file mode 100644 index e8a08943c55..00000000000 --- a/frontend/src/Activity/Queue/ProtocolLabel.js +++ /dev/null @@ -1,20 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Label from 'Components/Label'; -import styles from './ProtocolLabel.css'; - -function ProtocolLabel({ protocol }) { - const protocolName = protocol === 'usenet' ? 'nzb' : protocol; - - return ( - - ); -} - -ProtocolLabel.propTypes = { - protocol: PropTypes.string.isRequired -}; - -export default ProtocolLabel; diff --git a/frontend/src/Activity/Queue/ProtocolLabel.tsx b/frontend/src/Activity/Queue/ProtocolLabel.tsx new file mode 100644 index 00000000000..c1824452a54 --- /dev/null +++ b/frontend/src/Activity/Queue/ProtocolLabel.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import Label from 'Components/Label'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import styles from './ProtocolLabel.css'; + +interface ProtocolLabelProps { + protocol: DownloadProtocol; +} + +function ProtocolLabel({ protocol }: ProtocolLabelProps) { + const protocolName = protocol === 'usenet' ? 'nzb' : protocol; + + return ; +} + +export default ProtocolLabel; diff --git a/frontend/src/Activity/Queue/Queue.js b/frontend/src/Activity/Queue/Queue.js deleted file mode 100644 index cd030a166f5..00000000000 --- a/frontend/src/Activity/Queue/Queue.js +++ /dev/null @@ -1,333 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -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 PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import TablePager from 'Components/Table/TablePager'; -import { align, icons } from 'Helpers/Props'; -import getRemovedItems from 'Utilities/Object/getRemovedItems'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; -import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState'; -import selectAll from 'Utilities/Table/selectAll'; -import toggleSelected from 'Utilities/Table/toggleSelected'; -import QueueOptionsConnector from './QueueOptionsConnector'; -import QueueRowConnector from './QueueRowConnector'; -import RemoveQueueItemsModal from './RemoveQueueItemsModal'; - -class Queue extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._shouldBlockRefresh = false; - - this.state = { - allSelected: false, - allUnselected: false, - lastToggled: null, - selectedState: {}, - isPendingSelected: false, - isConfirmRemoveModalOpen: false, - items: props.items - }; - } - - shouldComponentUpdate() { - if (this._shouldBlockRefresh) { - return false; - } - - return true; - } - - componentDidUpdate(prevProps) { - const { - items, - isEpisodesFetching - } = this.props; - - if ( - (!isEpisodesFetching && prevProps.isEpisodesFetching) || - (hasDifferentItems(prevProps.items, items) && !items.some((e) => e.episodeId)) - ) { - this.setState((state) => { - return { - ...removeOldSelectedState(state, getRemovedItems(prevProps.items, items)), - items - }; - }); - - return; - } - - const nextState = {}; - - if (prevProps.items !== items) { - nextState.items = items; - } - - const selectedIds = this.getSelectedIds(); - const isPendingSelected = _.some(this.props.items, (item) => { - return selectedIds.indexOf(item.id) > -1 && item.status === 'delay'; - }); - - if (isPendingSelected !== this.state.isPendingSelected) { - nextState.isPendingSelected = isPendingSelected; - } - - if (!_.isEmpty(nextState)) { - this.setState(nextState); - } - } - - // - // Control - - getSelectedIds = () => { - return getSelectedIds(this.state.selectedState); - }; - - // - // Listeners - - onQueueRowModalOpenOrClose = (isOpen) => { - this._shouldBlockRefresh = isOpen; - }; - - onSelectAllChange = ({ value }) => { - this.setState(selectAll(this.state.selectedState, value)); - }; - - onSelectedChange = ({ id, value, shiftKey = false }) => { - this.setState((state) => { - return toggleSelected(state, this.props.items, id, value, shiftKey); - }); - }; - - onGrabSelectedPress = () => { - this.props.onGrabSelectedPress(this.getSelectedIds()); - }; - - onRemoveSelectedPress = () => { - this.setState({ isConfirmRemoveModalOpen: true }, () => { - this._shouldBlockRefresh = true; - }); - }; - - onRemoveSelectedConfirmed = (payload) => { - this._shouldBlockRefresh = false; - this.props.onRemoveSelectedPress({ ids: this.getSelectedIds(), ...payload }); - this.setState({ isConfirmRemoveModalOpen: false }); - }; - - onConfirmRemoveModalClose = () => { - this._shouldBlockRefresh = false; - this.setState({ isConfirmRemoveModalOpen: false }); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - isEpisodesFetching, - isEpisodesPopulated, - episodesError, - columns, - totalRecords, - isGrabbing, - isRemoving, - isRefreshMonitoredDownloadsExecuting, - onRefreshPress, - ...otherProps - } = this.props; - - const { - allSelected, - allUnselected, - selectedState, - isConfirmRemoveModalOpen, - isPendingSelected, - items - } = this.state; - - const isRefreshing = isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting; - const isAllPopulated = isPopulated && (isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId)); - const hasError = error || episodesError; - const selectedIds = this.getSelectedIds(); - const selectedCount = selectedIds.length; - const disableSelectedActions = selectedCount === 0; - - return ( - - - - - - - - - - - - - - - - - - - - - { - isRefreshing && !isAllPopulated ? - : - null - } - - { - !isRefreshing && hasError ? -
- Failed to load Queue -
: - null - } - - { - isAllPopulated && !hasError && !items.length ? -
- Queue is empty -
: - null - } - - { - isAllPopulated && !hasError && !!items.length ? -
- - - { - items.map((item) => { - return ( - - ); - }) - } - -
- - -
: - null - } -
- - { - const item = items.find((i) => i.id === id); - - return !!(item && item.seriesId && item.episodeId); - }) - )} - allPending={isConfirmRemoveModalOpen && ( - selectedIds.every((id) => { - const item = items.find((i) => i.id === id); - - if (!item) { - return false; - } - - return item.status === 'delay' || item.status === 'downloadClientUnavailable'; - }) - )} - onRemovePress={this.onRemoveSelectedConfirmed} - onModalClose={this.onConfirmRemoveModalClose} - /> -
- ); - } -} - -Queue.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - isEpisodesFetching: PropTypes.bool.isRequired, - isEpisodesPopulated: PropTypes.bool.isRequired, - episodesError: PropTypes.object, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - totalRecords: PropTypes.number, - isGrabbing: PropTypes.bool.isRequired, - isRemoving: PropTypes.bool.isRequired, - isRefreshMonitoredDownloadsExecuting: PropTypes.bool.isRequired, - onRefreshPress: PropTypes.func.isRequired, - onGrabSelectedPress: PropTypes.func.isRequired, - onRemoveSelectedPress: PropTypes.func.isRequired -}; - -export default Queue; diff --git a/frontend/src/Activity/Queue/Queue.tsx b/frontend/src/Activity/Queue/Queue.tsx new file mode 100644 index 00000000000..bd063e69a3f --- /dev/null +++ b/frontend/src/Activity/Queue/Queue.tsx @@ -0,0 +1,415 @@ +import React, { + ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import TablePager from 'Components/Table/TablePager'; +import usePaging from 'Components/Table/usePaging'; +import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { align, icons, kinds } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; +import { + clearQueue, + fetchQueue, + gotoQueuePage, + grabQueueItems, + removeQueueItems, + setQueueFilter, + setQueueSort, + setQueueTableOption, +} from 'Store/Actions/queueActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import { CheckInputChanged } from 'typings/inputs'; +import { SelectStateInputProps } from 'typings/props'; +import QueueItem from 'typings/Queue'; +import { TableOptionsChangePayload } from 'typings/Table'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import { + registerPagePopulator, + unregisterPagePopulator, +} from 'Utilities/pagePopulator'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import QueueFilterModal from './QueueFilterModal'; +import QueueOptions from './QueueOptions'; +import QueueRow from './QueueRow'; +import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal'; +import createQueueStatusSelector from './Status/createQueueStatusSelector'; + +function Queue() { + const requestCurrentPage = useCurrentPage(); + const dispatch = useDispatch(); + + const { + isFetching, + isPopulated, + error, + items, + columns, + selectedFilterKey, + filters, + sortKey, + sortDirection, + page, + pageSize, + totalPages, + totalRecords, + isGrabbing, + isRemoving, + } = useSelector((state: AppState) => state.queue.paged); + + const { count } = useSelector(createQueueStatusSelector()); + const { isEpisodesFetching, isEpisodesPopulated, episodesError } = + useSelector(createEpisodesFetchingSelector()); + const customFilters = useSelector(createCustomFiltersSelector('queue')); + + const isRefreshMonitoredDownloadsExecuting = useSelector( + createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS) + ); + + const shouldBlockRefresh = useRef(false); + const currentQueue = useRef(null); + + const [selectState, setSelectState] = useSelectState(); + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const isPendingSelected = useMemo(() => { + return items.some((item) => { + return selectedIds.indexOf(item.id) > -1 && item.status === 'delay'; + }); + }, [items, selectedIds]); + + const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] = + useState(false); + + const isRefreshing = + isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting; + const isAllPopulated = + isPopulated && + (isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId)); + const hasError = error || episodesError; + const selectedCount = selectedIds.length; + const disableSelectedActions = selectedCount === 0; + + const handleSelectAllChange = useCallback( + ({ value }: CheckInputChanged) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const handleSelectedChange = useCallback( + ({ id, value, shiftKey = false }: SelectStateInputProps) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const handleRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: commandNames.REFRESH_MONITORED_DOWNLOADS, + }) + ); + }, [dispatch]); + + const handleQueueRowModalOpenOrClose = useCallback((isOpen: boolean) => { + shouldBlockRefresh.current = isOpen; + }, []); + + const handleGrabSelectedPress = useCallback(() => { + dispatch(grabQueueItems({ ids: selectedIds })); + }, [selectedIds, dispatch]); + + const handleRemoveSelectedPress = useCallback(() => { + shouldBlockRefresh.current = true; + setIsConfirmRemoveModalOpen(true); + }, [setIsConfirmRemoveModalOpen]); + + const handleRemoveSelectedConfirmed = useCallback( + (payload: RemovePressProps) => { + shouldBlockRefresh.current = false; + dispatch(removeQueueItems({ ids: selectedIds, ...payload })); + setIsConfirmRemoveModalOpen(false); + }, + [selectedIds, setIsConfirmRemoveModalOpen, dispatch] + ); + + const handleConfirmRemoveModalClose = useCallback(() => { + shouldBlockRefresh.current = false; + setIsConfirmRemoveModalOpen(false); + }, [setIsConfirmRemoveModalOpen]); + + const { + handleFirstPagePress, + handlePreviousPagePress, + handleNextPagePress, + handleLastPagePress, + handlePageSelect, + } = usePaging({ + page, + totalPages, + gotoPage: gotoQueuePage, + }); + + const handleFilterSelect = useCallback( + (selectedFilterKey: string) => { + dispatch(setQueueFilter({ selectedFilterKey })); + }, + [dispatch] + ); + + const handleSortPress = useCallback( + (sortKey: string) => { + dispatch(setQueueSort({ sortKey })); + }, + [dispatch] + ); + + const handleTableOptionChange = useCallback( + (payload: TableOptionsChangePayload) => { + dispatch(setQueueTableOption(payload)); + + if (payload.pageSize) { + dispatch(gotoQueuePage({ page: 1 })); + } + }, + [dispatch] + ); + + useEffect(() => { + if (requestCurrentPage) { + dispatch(fetchQueue()); + } else { + dispatch(gotoQueuePage({ page: 1 })); + } + + return () => { + dispatch(clearQueue()); + }; + }, [requestCurrentPage, dispatch]); + + useEffect(() => { + const episodeIds = selectUniqueIds( + items, + 'episodeId' + ); + + if (episodeIds.length) { + dispatch(fetchEpisodes({ episodeIds })); + } else { + dispatch(clearEpisodes()); + } + }, [items, dispatch]); + + useEffect(() => { + const repopulate = () => { + dispatch(fetchQueue()); + }; + + registerPagePopulator(repopulate); + + return () => { + unregisterPagePopulator(repopulate); + }; + }, [dispatch]); + + if (!shouldBlockRefresh.current) { + currentQueue.current = ( + + {isRefreshing && !isAllPopulated ? : null} + + {!isRefreshing && hasError ? ( + {translate('QueueLoadError')} + ) : null} + + {isAllPopulated && !hasError && !items.length ? ( + + {selectedFilterKey !== 'all' && count > 0 + ? translate('QueueFilterHasNoItems') + : translate('QueueIsEmpty')} + + ) : null} + + {isAllPopulated && !hasError && !!items.length ? ( +
+ + + {items.map((item) => { + return ( + + ); + })} + +
+ + +
+ ) : null} +
+ ); + } + + return ( + + + + + + + + + + + + + + + + + + + + + + {currentQueue.current} + + { + const item = items.find((i) => i.id === id); + + return !!(item && item.downloadClientHasPostImportCategory); + }) + } + canIgnore={ + isConfirmRemoveModalOpen && + selectedIds.every((id) => { + const item = items.find((i) => i.id === id); + + return !!(item && item.seriesId && item.episodeId); + }) + } + isPending={ + isConfirmRemoveModalOpen && + selectedIds.every((id) => { + const item = items.find((i) => i.id === id); + + if (!item) { + return false; + } + + return ( + item.status === 'delay' || + item.status === 'downloadClientUnavailable' + ); + }) + } + onRemovePress={handleRemoveSelectedConfirmed} + onModalClose={handleConfirmRemoveModalClose} + /> + + ); +} + +export default Queue; diff --git a/frontend/src/Activity/Queue/QueueConnector.js b/frontend/src/Activity/Queue/QueueConnector.js deleted file mode 100644 index f7d5d515216..00000000000 --- a/frontend/src/Activity/Queue/QueueConnector.js +++ /dev/null @@ -1,192 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import withCurrentPage from 'Components/withCurrentPage'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions'; -import * as queueActions from 'Store/Actions/queueActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import Queue from './Queue'; - -function createMapStateToProps() { - return createSelector( - (state) => state.episodes, - (state) => state.queue.options, - (state) => state.queue.paged, - createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS), - (episodes, options, queue, isRefreshMonitoredDownloadsExecuting) => { - return { - isEpisodesFetching: episodes.isFetching, - isEpisodesPopulated: episodes.isPopulated, - episodesError: episodes.error, - isRefreshMonitoredDownloadsExecuting, - ...options, - ...queue - }; - } - ); -} - -const mapDispatchToProps = { - ...queueActions, - fetchEpisodes, - clearEpisodes, - executeCommand -}; - -class QueueConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - useCurrentPage, - fetchQueue, - fetchQueueStatus, - gotoQueueFirstPage - } = this.props; - - registerPagePopulator(this.repopulate); - - if (useCurrentPage) { - fetchQueue(); - } else { - gotoQueueFirstPage(); - } - - fetchQueueStatus(); - } - - componentDidUpdate(prevProps) { - if (hasDifferentItems(prevProps.items, this.props.items)) { - const episodeIds = selectUniqueIds(this.props.items, 'episodeId'); - - if (episodeIds.length) { - this.props.fetchEpisodes({ episodeIds }); - } else { - this.props.clearEpisodes(); - } - } - - if ( - this.props.includeUnknownSeriesItems !== - prevProps.includeUnknownSeriesItems - ) { - this.repopulate(); - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - this.props.clearQueue(); - this.props.clearEpisodes(); - } - - // - // Control - - repopulate = () => { - this.props.fetchQueue(); - }; - - // - // Listeners - - onFirstPagePress = () => { - this.props.gotoQueueFirstPage(); - }; - - onPreviousPagePress = () => { - this.props.gotoQueuePreviousPage(); - }; - - onNextPagePress = () => { - this.props.gotoQueueNextPage(); - }; - - onLastPagePress = () => { - this.props.gotoQueueLastPage(); - }; - - onPageSelect = (page) => { - this.props.gotoQueuePage({ page }); - }; - - onSortPress = (sortKey) => { - this.props.setQueueSort({ sortKey }); - }; - - onTableOptionChange = (payload) => { - this.props.setQueueTableOption(payload); - - if (payload.pageSize) { - this.props.gotoQueueFirstPage(); - } - }; - - onRefreshPress = () => { - this.props.executeCommand({ - name: commandNames.REFRESH_MONITORED_DOWNLOADS - }); - }; - - onGrabSelectedPress = (ids) => { - this.props.grabQueueItems({ ids }); - }; - - onRemoveSelectedPress = (payload) => { - this.props.removeQueueItems(payload); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -QueueConnector.propTypes = { - includeUnknownSeriesItems: PropTypes.bool.isRequired, - useCurrentPage: PropTypes.bool.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - fetchQueue: PropTypes.func.isRequired, - fetchQueueStatus: PropTypes.func.isRequired, - gotoQueueFirstPage: PropTypes.func.isRequired, - gotoQueuePreviousPage: PropTypes.func.isRequired, - gotoQueueNextPage: PropTypes.func.isRequired, - gotoQueueLastPage: PropTypes.func.isRequired, - gotoQueuePage: PropTypes.func.isRequired, - setQueueSort: PropTypes.func.isRequired, - setQueueTableOption: PropTypes.func.isRequired, - clearQueue: PropTypes.func.isRequired, - grabQueueItems: PropTypes.func.isRequired, - removeQueueItems: PropTypes.func.isRequired, - fetchEpisodes: PropTypes.func.isRequired, - clearEpisodes: PropTypes.func.isRequired, - executeCommand: PropTypes.func.isRequired -}; - -export default withCurrentPage( - connect(createMapStateToProps, mapDispatchToProps)(QueueConnector) -); diff --git a/frontend/src/Activity/Queue/QueueDetails.css.d.ts b/frontend/src/Activity/Queue/QueueDetails.css.d.ts new file mode 100644 index 00000000000..4f22870150d --- /dev/null +++ b/frontend/src/Activity/Queue/QueueDetails.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'progressBarContainer': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/Queue/QueueDetails.js b/frontend/src/Activity/Queue/QueueDetails.js deleted file mode 100644 index 33370f68275..00000000000 --- a/frontend/src/Activity/Queue/QueueDetails.js +++ /dev/null @@ -1,83 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import Popover from 'Components/Tooltip/Popover'; -import { icons, tooltipPositions } from 'Helpers/Props'; -import QueueStatus from './QueueStatus'; -import styles from './QueueDetails.css'; - -function QueueDetails(props) { - const { - title, - size, - sizeleft, - status, - trackedDownloadState, - trackedDownloadStatus, - statusMessages, - errorMessage, - progressBar - } = props; - - const progress = (100 - sizeleft / size * 100); - const isDownloading = status === 'downloading'; - const isPaused = status === 'paused'; - const hasWarning = trackedDownloadStatus === 'warning'; - const hasError = trackedDownloadStatus === 'error'; - - if ( - (isDownloading || isPaused) && - !hasWarning && - !hasError - ) { - const state = isPaused ? 'Paused' : 'Downloading'; - - if (progress < 5) { - return ( - - ); - } - - return ( - {title} - } - position={tooltipPositions.LEFT} - /> - ); - } - - return ( - - ); -} - -QueueDetails.propTypes = { - title: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - sizeleft: PropTypes.number.isRequired, - estimatedCompletionTime: PropTypes.string, - status: PropTypes.string.isRequired, - trackedDownloadState: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string.isRequired, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string, - progressBar: PropTypes.node.isRequired -}; - -export default QueueDetails; diff --git a/frontend/src/Activity/Queue/QueueDetails.tsx b/frontend/src/Activity/Queue/QueueDetails.tsx new file mode 100644 index 00000000000..be70ceead13 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueDetails.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import Icon from 'Components/Icon'; +import Popover from 'Components/Tooltip/Popover'; +import { icons, tooltipPositions } from 'Helpers/Props'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; +import translate from 'Utilities/String/translate'; +import QueueStatus from './QueueStatus'; +import styles from './QueueDetails.css'; + +interface QueueDetailsProps { + title: string; + size: number; + sizeleft: number; + estimatedCompletionTime?: string; + status: string; + trackedDownloadState?: QueueTrackedDownloadState; + trackedDownloadStatus?: QueueTrackedDownloadStatus; + statusMessages?: StatusMessage[]; + errorMessage?: string; + progressBar: React.ReactNode; +} + +function QueueDetails(props: QueueDetailsProps) { + const { + title, + size, + sizeleft, + status, + trackedDownloadState = 'downloading', + trackedDownloadStatus = 'ok', + statusMessages, + errorMessage, + progressBar, + } = props; + + const progress = 100 - (sizeleft / size) * 100; + const isDownloading = status === 'downloading'; + const isPaused = status === 'paused'; + const hasWarning = trackedDownloadStatus === 'warning'; + const hasError = trackedDownloadStatus === 'error'; + + if ((isDownloading || isPaused) && !hasWarning && !hasError) { + const state = isPaused ? translate('Paused') : translate('Downloading'); + + if (progress < 5) { + return ( + + ); + } + + return ( + {title}} + position={tooltipPositions.LEFT} + /> + ); + } + + return ( + + ); +} + +export default QueueDetails; diff --git a/frontend/src/Activity/Queue/QueueFilterModal.tsx b/frontend/src/Activity/Queue/QueueFilterModal.tsx new file mode 100644 index 00000000000..3fce6c1667d --- /dev/null +++ b/frontend/src/Activity/Queue/QueueFilterModal.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setQueueFilter } from 'Store/Actions/queueActions'; + +function createQueueSelector() { + return createSelector( + (state: AppState) => state.queue.paged.items, + (queueItems) => { + return queueItems; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.queue.paged.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface QueueFilterModalProps { + isOpen: boolean; +} + +export default function QueueFilterModal(props: QueueFilterModalProps) { + const sectionItems = useSelector(createQueueSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'queue'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setQueueFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Activity/Queue/QueueOptions.js b/frontend/src/Activity/Queue/QueueOptions.js deleted file mode 100644 index 98a67dd31c8..00000000000 --- a/frontend/src/Activity/Queue/QueueOptions.js +++ /dev/null @@ -1,77 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import { inputTypes } from 'Helpers/Props'; - -class QueueOptions extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - includeUnknownSeriesItems: props.includeUnknownSeriesItems - }; - } - - componentDidUpdate(prevProps) { - const { - includeUnknownSeriesItems - } = this.props; - - if (includeUnknownSeriesItems !== prevProps.includeUnknownSeriesItems) { - this.setState({ - includeUnknownSeriesItems - }); - } - } - - // - // Listeners - - onOptionChange = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onOptionChange({ - [name]: value - }); - }); - }; - - // - // Render - - render() { - const { - includeUnknownSeriesItems - } = this.state; - - return ( - - - Show Unknown Series Items - - - - - ); - } -} - -QueueOptions.propTypes = { - includeUnknownSeriesItems: PropTypes.bool.isRequired, - onOptionChange: PropTypes.func.isRequired -}; - -export default QueueOptions; diff --git a/frontend/src/Activity/Queue/QueueOptions.tsx b/frontend/src/Activity/Queue/QueueOptions.tsx new file mode 100644 index 00000000000..17a6ac1fe61 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueOptions.tsx @@ -0,0 +1,48 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import { inputTypes } from 'Helpers/Props'; +import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions'; +import { CheckInputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; + +function QueueOptions() { + const dispatch = useDispatch(); + const { includeUnknownSeriesItems } = useSelector( + (state: AppState) => state.queue.options + ); + + const handleOptionChange = useCallback( + ({ name, value }: CheckInputChanged) => { + dispatch( + setQueueOption({ + [name]: value, + }) + ); + + if (name === 'includeUnknownSeriesItems') { + dispatch(gotoQueuePage({ page: 1 })); + } + }, + [dispatch] + ); + + return ( + + {translate('ShowUnknownSeriesItems')} + + + + ); +} + +export default QueueOptions; diff --git a/frontend/src/Activity/Queue/QueueOptionsConnector.js b/frontend/src/Activity/Queue/QueueOptionsConnector.js deleted file mode 100644 index b2c99511c57..00000000000 --- a/frontend/src/Activity/Queue/QueueOptionsConnector.js +++ /dev/null @@ -1,19 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setQueueOption } from 'Store/Actions/queueActions'; -import QueueOptions from './QueueOptions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.queue.options, - (options) => { - return options; - } - ); -} - -const mapDispatchToProps = { - onOptionChange: setQueueOption -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(QueueOptions); diff --git a/frontend/src/Activity/Queue/QueueRow.css b/frontend/src/Activity/Queue/QueueRow.css index ee0483f96ee..459cdad8ec9 100644 --- a/frontend/src/Activity/Queue/QueueRow.css +++ b/frontend/src/Activity/Queue/QueueRow.css @@ -16,8 +16,15 @@ width: 150px; } +.customFormatScore { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 55px; +} + .actions { composes: cell from '~Components/Table/Cells/TableRowCell.css'; width: 70px; + text-align: right; } diff --git a/frontend/src/Activity/Queue/QueueRow.css.d.ts b/frontend/src/Activity/Queue/QueueRow.css.d.ts new file mode 100644 index 00000000000..13d67ea3a7f --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'actions': string; + 'customFormatScore': string; + 'progress': string; + 'protocol': string; + 'quality': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js deleted file mode 100644 index aba9ce3ead0..00000000000 --- a/frontend/src/Activity/Queue/QueueRow.js +++ /dev/null @@ -1,443 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; -import IconButton from 'Components/Link/IconButton'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import ProgressBar from 'Components/ProgressBar'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; -import TableRow from 'Components/Table/TableRow'; -import EpisodeFormats from 'Episode/EpisodeFormats'; -import EpisodeLanguages from 'Episode/EpisodeLanguages'; -import EpisodeQuality from 'Episode/EpisodeQuality'; -import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; -import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; -import { icons, kinds } from 'Helpers/Props'; -import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; -import SeriesTitleLink from 'Series/SeriesTitleLink'; -import formatBytes from 'Utilities/Number/formatBytes'; -import QueueStatusCell from './QueueStatusCell'; -import RemoveQueueItemModal from './RemoveQueueItemModal'; -import TimeleftCell from './TimeleftCell'; -import styles from './QueueRow.css'; - -class QueueRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isRemoveQueueItemModalOpen: false, - isInteractiveImportModalOpen: false - }; - } - - // - // Listeners - - onRemoveQueueItemPress = () => { - this.setState({ isRemoveQueueItemModalOpen: true }); - }; - - onRemoveQueueItemModalConfirmed = (blocklist) => { - const { - onRemoveQueueItemPress, - onQueueRowModalOpenOrClose - } = this.props; - - onQueueRowModalOpenOrClose(false); - onRemoveQueueItemPress(blocklist); - - this.setState({ isRemoveQueueItemModalOpen: false }); - }; - - onRemoveQueueItemModalClose = () => { - this.props.onQueueRowModalOpenOrClose(false); - - this.setState({ isRemoveQueueItemModalOpen: false }); - }; - - onInteractiveImportPress = () => { - this.props.onQueueRowModalOpenOrClose(true); - - this.setState({ isInteractiveImportModalOpen: true }); - }; - - onInteractiveImportModalClose = () => { - this.props.onQueueRowModalOpenOrClose(false); - - this.setState({ isInteractiveImportModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - downloadId, - title, - status, - trackedDownloadStatus, - trackedDownloadState, - statusMessages, - errorMessage, - series, - episode, - languages, - quality, - customFormats, - protocol, - indexer, - outputPath, - downloadClient, - estimatedCompletionTime, - timeleft, - size, - sizeleft, - showRelativeDates, - shortDateFormat, - timeFormat, - isGrabbing, - grabError, - isRemoving, - isSelected, - columns, - onSelectedChange, - onGrabPress - } = this.props; - - const { - isRemoveQueueItemModalOpen, - isInteractiveImportModalOpen - } = this.state; - - const progress = 100 - (sizeleft / size * 100); - const showInteractiveImport = status === 'completed' && trackedDownloadStatus === 'warning'; - const isPending = status === 'delay' || status === 'downloadClientUnavailable'; - - return ( - - - - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'status') { - return ( - - ); - } - - if (name === 'series.sortTitle') { - return ( - - { - series ? - : - title - } - - ); - } - - if (name === 'episode') { - return ( - - { - episode ? - : - '-' - } - - ); - } - - if (name === 'episodes.title') { - return ( - - { - episode ? - : - '-' - } - - ); - } - - if (name === 'episodes.airDateUtc') { - if (episode) { - return ( - - ); - } - - return ( - - - - - ); - } - - if (name === 'languages') { - return ( - - - - ); - } - - if (name === 'quality') { - return ( - - { - quality ? - : - null - } - - ); - } - - if (name === 'customFormats') { - return ( - - - - ); - } - - if (name === 'protocol') { - return ( - - - - ); - } - - if (name === 'indexer') { - return ( - - {indexer} - - ); - } - - if (name === 'downloadClient') { - return ( - - {downloadClient} - - ); - } - - if (name === 'title') { - return ( - - {title} - - ); - } - - if (name === 'size') { - return ( - {formatBytes(size)} - ); - } - - if (name === 'outputPath') { - return ( - - {outputPath} - - ); - } - - if (name === 'estimatedCompletionTime') { - return ( - - ); - } - - if (name === 'progress') { - return ( - - { - !!progress && - - } - - ); - } - - if (name === 'actions') { - return ( - - { - showInteractiveImport && - - } - - { - isPending && - - } - - - - ); - } - - return null; - }) - } - - - - - - ); - } - -} - -QueueRow.propTypes = { - id: PropTypes.number.isRequired, - downloadId: PropTypes.string, - title: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string, - trackedDownloadState: PropTypes.string, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string, - series: PropTypes.object, - episode: PropTypes.object, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - quality: PropTypes.object.isRequired, - customFormats: PropTypes.arrayOf(PropTypes.object), - protocol: PropTypes.string.isRequired, - indexer: PropTypes.string, - outputPath: PropTypes.string, - downloadClient: PropTypes.string, - estimatedCompletionTime: PropTypes.string, - timeleft: PropTypes.string, - size: PropTypes.number, - sizeleft: PropTypes.number, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isGrabbing: PropTypes.bool.isRequired, - grabError: PropTypes.object, - isRemoving: PropTypes.bool.isRequired, - isSelected: PropTypes.bool, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onSelectedChange: PropTypes.func.isRequired, - onGrabPress: PropTypes.func.isRequired, - onRemoveQueueItemPress: PropTypes.func.isRequired, - onQueueRowModalOpenOrClose: PropTypes.func.isRequired -}; - -QueueRow.defaultProps = { - isGrabbing: false, - isRemoving: false -}; - -export default QueueRow; diff --git a/frontend/src/Activity/Queue/QueueRow.tsx b/frontend/src/Activity/Queue/QueueRow.tsx new file mode 100644 index 00000000000..25f5cb410ac --- /dev/null +++ b/frontend/src/Activity/Queue/QueueRow.tsx @@ -0,0 +1,411 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import { Error } from 'App/State/AppSectionState'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ProgressBar from 'Components/ProgressBar'; +import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import EpisodeFormats from 'Episode/EpisodeFormats'; +import EpisodeLanguages from 'Episode/EpisodeLanguages'; +import EpisodeQuality from 'Episode/EpisodeQuality'; +import EpisodeTitleLink from 'Episode/EpisodeTitleLink'; +import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber'; +import useEpisode from 'Episode/useEpisode'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import SeriesTitleLink from 'Series/SeriesTitleLink'; +import useSeries from 'Series/useSeries'; +import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import CustomFormat from 'typings/CustomFormat'; +import { SelectStateInputProps } from 'typings/props'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; +import formatBytes from 'Utilities/Number/formatBytes'; +import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; +import translate from 'Utilities/String/translate'; +import QueueStatusCell from './QueueStatusCell'; +import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal'; +import TimeleftCell from './TimeleftCell'; +import styles from './QueueRow.css'; + +interface QueueRowProps { + id: number; + seriesId?: number; + episodeId?: number; + downloadId?: string; + title: string; + status: string; + trackedDownloadStatus?: QueueTrackedDownloadStatus; + trackedDownloadState?: QueueTrackedDownloadState; + statusMessages?: StatusMessage[]; + errorMessage?: string; + languages: Language[]; + quality: QualityModel; + customFormats?: CustomFormat[]; + customFormatScore: number; + protocol: DownloadProtocol; + indexer?: string; + outputPath?: string; + downloadClient?: string; + downloadClientHasPostImportCategory?: boolean; + estimatedCompletionTime?: string; + added?: string; + timeleft?: string; + size: number; + sizeleft: number; + isGrabbing?: boolean; + grabError?: Error; + isRemoving?: boolean; + isSelected?: boolean; + columns: Column[]; + onSelectedChange: (options: SelectStateInputProps) => void; + onQueueRowModalOpenOrClose: (isOpen: boolean) => void; +} + +function QueueRow(props: QueueRowProps) { + const { + id, + seriesId, + episodeId, + downloadId, + title, + status, + trackedDownloadStatus, + trackedDownloadState, + statusMessages, + errorMessage, + languages, + quality, + customFormats = [], + customFormatScore, + protocol, + indexer, + outputPath, + downloadClient, + downloadClientHasPostImportCategory, + estimatedCompletionTime, + added, + timeleft, + size, + sizeleft, + isGrabbing = false, + grabError, + isRemoving = false, + isSelected, + columns, + onSelectedChange, + onQueueRowModalOpenOrClose, + } = props; + + const dispatch = useDispatch(); + const series = useSeries(seriesId); + const episode = useEpisode(episodeId, 'episodes'); + const { showRelativeDates, shortDateFormat, timeFormat } = useSelector( + createUISettingsSelector() + ); + + const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] = + useState(false); + + const [isInteractiveImportModalOpen, setIsInteractiveImportModalOpen] = + useState(false); + + const handleGrabPress = useCallback(() => { + dispatch(grabQueueItem({ id })); + }, [id, dispatch]); + + const handleInteractiveImportPress = useCallback(() => { + onQueueRowModalOpenOrClose(true); + setIsInteractiveImportModalOpen(true); + }, [setIsInteractiveImportModalOpen, onQueueRowModalOpenOrClose]); + + const handleInteractiveImportModalClose = useCallback(() => { + onQueueRowModalOpenOrClose(false); + setIsInteractiveImportModalOpen(false); + }, [setIsInteractiveImportModalOpen, onQueueRowModalOpenOrClose]); + + const handleRemoveQueueItemPress = useCallback(() => { + onQueueRowModalOpenOrClose(true); + setIsRemoveQueueItemModalOpen(true); + }, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]); + + const handleRemoveQueueItemModalConfirmed = useCallback( + (payload: RemovePressProps) => { + onQueueRowModalOpenOrClose(false); + dispatch(removeQueueItem({ id, ...payload })); + setIsRemoveQueueItemModalOpen(false); + }, + [id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch] + ); + + const handleRemoveQueueItemModalClose = useCallback(() => { + onQueueRowModalOpenOrClose(false); + setIsRemoveQueueItemModalOpen(false); + }, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]); + + const progress = 100 - (sizeleft / size) * 100; + const showInteractiveImport = + status === 'completed' && trackedDownloadStatus === 'warning'; + const isPending = + status === 'delay' || status === 'downloadClientUnavailable'; + + return ( + + + + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'series.sortTitle') { + return ( + + {series ? ( + + ) : ( + title + )} + + ); + } + + if (name === 'episode') { + return ( + + {episode ? ( + + ) : ( + '-' + )} + + ); + } + + if (name === 'episodes.title') { + return ( + + {series && episode ? ( + + ) : ( + '-' + )} + + ); + } + + if (name === 'episodes.airDateUtc') { + if (episode) { + return ; + } + + return -; + } + + if (name === 'languages') { + return ( + + + + ); + } + + if (name === 'quality') { + return ( + + {quality ? : null} + + ); + } + + if (name === 'customFormats') { + return ( + + + + ); + } + + if (name === 'customFormatScore') { + return ( + + } + position={tooltipPositions.BOTTOM} + /> + + ); + } + + if (name === 'protocol') { + return ( + + + + ); + } + + if (name === 'indexer') { + return {indexer}; + } + + if (name === 'downloadClient') { + return {downloadClient}; + } + + if (name === 'title') { + return {title}; + } + + if (name === 'size') { + return {formatBytes(size)}; + } + + if (name === 'outputPath') { + return {outputPath}; + } + + if (name === 'estimatedCompletionTime') { + return ( + + ); + } + + if (name === 'progress') { + return ( + + {!!progress && ( + + )} + + ); + } + + if (name === 'added') { + return ; + } + + if (name === 'actions') { + return ( + + {showInteractiveImport ? ( + + ) : null} + + {isPending ? ( + + ) : null} + + + + ); + } + + return null; + })} + + + + + + ); +} + +export default QueueRow; diff --git a/frontend/src/Activity/Queue/QueueRowConnector.js b/frontend/src/Activity/Queue/QueueRowConnector.js deleted file mode 100644 index e1e469a7060..00000000000 --- a/frontend/src/Activity/Queue/QueueRowConnector.js +++ /dev/null @@ -1,70 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions'; -import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import QueueRow from './QueueRow'; - -function createMapStateToProps() { - return createSelector( - createSeriesSelector(), - createEpisodeSelector(), - createUISettingsSelector(), - (series, episode, uiSettings) => { - const result = { - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - timeFormat: uiSettings.timeFormat - }; - - result.series = series; - result.episode = episode; - - return result; - } - ); -} - -const mapDispatchToProps = { - grabQueueItem, - removeQueueItem -}; - -class QueueRowConnector extends Component { - - // - // Listeners - - onGrabPress = () => { - this.props.grabQueueItem({ id: this.props.id }); - }; - - onRemoveQueueItemPress = (payload) => { - this.props.removeQueueItem({ id: this.props.id, ...payload }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -QueueRowConnector.propTypes = { - id: PropTypes.number.isRequired, - episode: PropTypes.object, - grabQueueItem: PropTypes.func.isRequired, - removeQueueItem: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(QueueRowConnector); diff --git a/frontend/src/Activity/Queue/QueueStatus.css.d.ts b/frontend/src/Activity/Queue/QueueStatus.css.d.ts new file mode 100644 index 00000000000..0911f237679 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatus.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'noMessages': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/Queue/QueueStatus.js b/frontend/src/Activity/Queue/QueueStatus.js deleted file mode 100644 index 50dd3943239..00000000000 --- a/frontend/src/Activity/Queue/QueueStatus.js +++ /dev/null @@ -1,160 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import Popover from 'Components/Tooltip/Popover'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; -import styles from './QueueStatus.css'; - -function getDetailedPopoverBody(statusMessages) { - return ( -
- { - statusMessages.map(({ title, messages }) => { - return ( -
- {title} -
    - { - messages.map((message) => { - return ( -
  • - {message} -
  • - ); - }) - } -
-
- ); - }) - } -
- ); -} - -function QueueStatus(props) { - const { - sourceTitle, - status, - trackedDownloadStatus, - trackedDownloadState, - statusMessages, - errorMessage, - position, - canFlip - } = props; - - const hasWarning = trackedDownloadStatus === 'warning'; - const hasError = trackedDownloadStatus === 'error'; - - // status === 'downloading' - let iconName = icons.DOWNLOADING; - let iconKind = kinds.DEFAULT; - let title = 'Downloading'; - - if (status === 'paused') { - iconName = icons.PAUSED; - title = 'Paused'; - } - - if (status === 'queued') { - iconName = icons.QUEUED; - title = 'Queued'; - } - - if (status === 'completed') { - iconName = icons.DOWNLOADED; - title = 'Downloaded'; - - if (trackedDownloadState === 'importPending') { - title += ' - Waiting to Import'; - iconKind = kinds.PURPLE; - } - - if (trackedDownloadState === 'importing') { - title += ' - Importing'; - iconKind = kinds.PURPLE; - } - - if (trackedDownloadState === 'failedPending') { - title += ' - Waiting to Process'; - iconKind = kinds.DANGER; - } - } - - if (hasWarning) { - iconKind = kinds.WARNING; - } - - if (status === 'delay') { - iconName = icons.PENDING; - title = 'Pending'; - } - - if (status === 'downloadClientUnavailable') { - iconName = icons.PENDING; - iconKind = kinds.WARNING; - title = 'Pending - Download client is unavailable'; - } - - if (status === 'failed') { - iconName = icons.DOWNLOADING; - iconKind = kinds.DANGER; - title = 'Download failed'; - } - - if (status === 'warning') { - iconName = icons.DOWNLOADING; - iconKind = kinds.WARNING; - title = `Download warning: ${errorMessage || 'check download client for more details'}`; - } - - if (hasError) { - if (status === 'completed') { - iconName = icons.DOWNLOAD; - iconKind = kinds.DANGER; - title = `Import failed: ${sourceTitle}`; - } else { - iconName = icons.DOWNLOADING; - iconKind = kinds.DANGER; - title = 'Download failed'; - } - } - - return ( - - } - title={title} - body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle} - position={position} - canFlip={canFlip} - /> - ); -} - -QueueStatus.propTypes = { - sourceTitle: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string.isRequired, - trackedDownloadState: PropTypes.string.isRequired, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string, - position: PropTypes.oneOf(tooltipPositions.all).isRequired, - canFlip: PropTypes.bool.isRequired -}; - -QueueStatus.defaultProps = { - trackedDownloadStatus: 'Ok', - trackedDownloadState: 'Downloading', - canFlip: false -}; - -export default QueueStatus; diff --git a/frontend/src/Activity/Queue/QueueStatus.tsx b/frontend/src/Activity/Queue/QueueStatus.tsx new file mode 100644 index 00000000000..31a28f35c21 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatus.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import Icon, { IconProps } from 'Components/Icon'; +import Popover from 'Components/Tooltip/Popover'; +import { icons, kinds } from 'Helpers/Props'; +import { TooltipPosition } from 'Helpers/Props/tooltipPositions'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; +import translate from 'Utilities/String/translate'; +import styles from './QueueStatus.css'; + +function getDetailedPopoverBody(statusMessages: StatusMessage[]) { + return ( +
+ {statusMessages.map(({ title, messages }) => { + return ( +
+ {title} +
    + {messages.map((message) => { + return
  • {message}
  • ; + })} +
+
+ ); + })} +
+ ); +} + +interface QueueStatusProps { + sourceTitle: string; + status: string; + trackedDownloadStatus?: QueueTrackedDownloadStatus; + trackedDownloadState?: QueueTrackedDownloadState; + statusMessages?: StatusMessage[]; + errorMessage?: string; + position: TooltipPosition; + canFlip?: boolean; +} + +function QueueStatus(props: QueueStatusProps) { + const { + sourceTitle, + status, + trackedDownloadStatus = 'ok', + trackedDownloadState = 'downloading', + statusMessages = [], + errorMessage, + position, + canFlip = false, + } = props; + + const hasWarning = trackedDownloadStatus === 'warning'; + const hasError = trackedDownloadStatus === 'error'; + + // status === 'downloading' + let iconName = icons.DOWNLOADING; + let iconKind: IconProps['kind'] = kinds.DEFAULT; + let title = translate('Downloading'); + + if (status === 'paused') { + iconName = icons.PAUSED; + title = translate('Paused'); + } + + if (status === 'queued') { + iconName = icons.QUEUED; + title = translate('Queued'); + } + + if (status === 'completed') { + iconName = icons.DOWNLOADED; + title = translate('Downloaded'); + + if (trackedDownloadState === 'importBlocked') { + title += ` - ${translate('UnableToImportAutomatically')}`; + iconKind = kinds.WARNING; + } + + if (trackedDownloadState === 'importPending') { + title += ` - ${translate('WaitingToImport')}`; + iconKind = kinds.PURPLE; + } + + if (trackedDownloadState === 'importing') { + title += ` - ${translate('Importing')}`; + iconKind = kinds.PURPLE; + } + + if (trackedDownloadState === 'failedPending') { + title += ` - ${translate('WaitingToProcess')}`; + iconKind = kinds.DANGER; + } + } + + if (hasWarning) { + iconKind = kinds.WARNING; + } + + if (status === 'delay') { + iconName = icons.PENDING; + title = translate('Pending'); + } + + if (status === 'downloadClientUnavailable') { + iconName = icons.PENDING; + iconKind = kinds.WARNING; + title = translate('PendingDownloadClientUnavailable'); + } + + if (status === 'failed') { + iconName = icons.DOWNLOADING; + iconKind = kinds.DANGER; + title = translate('DownloadFailed'); + } + + if (status === 'warning') { + iconName = icons.DOWNLOADING; + iconKind = kinds.WARNING; + const warningMessage = + errorMessage || translate('CheckDownloadClientForDetails'); + title = translate('DownloadWarning', { warningMessage }); + } + + if (hasError) { + if (status === 'completed') { + iconName = icons.DOWNLOAD; + iconKind = kinds.DANGER; + title = translate('ImportFailed', { sourceTitle }); + } else { + iconName = icons.DOWNLOADING; + iconKind = kinds.DANGER; + title = translate('DownloadFailed'); + } + } + + return ( + } + title={title} + body={ + hasWarning || hasError + ? getDetailedPopoverBody(statusMessages) + : sourceTitle + } + position={position} + canFlip={canFlip} + /> + ); +} + +export default QueueStatus; diff --git a/frontend/src/Activity/Queue/QueueStatusCell.css.d.ts b/frontend/src/Activity/Queue/QueueStatusCell.css.d.ts new file mode 100644 index 00000000000..aefdc03b9d8 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatusCell.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'noMessages': string; + 'status': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/Queue/QueueStatusCell.js b/frontend/src/Activity/Queue/QueueStatusCell.js deleted file mode 100644 index adbccda137c..00000000000 --- a/frontend/src/Activity/Queue/QueueStatusCell.js +++ /dev/null @@ -1,47 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import { tooltipPositions } from 'Helpers/Props'; -import QueueStatus from './QueueStatus'; -import styles from './QueueStatusCell.css'; - -function QueueStatusCell(props) { - const { - sourceTitle, - status, - trackedDownloadStatus, - trackedDownloadState, - statusMessages, - errorMessage - } = props; - - return ( - - - - ); -} - -QueueStatusCell.propTypes = { - sourceTitle: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string.isRequired, - trackedDownloadState: PropTypes.string.isRequired, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string -}; - -QueueStatusCell.defaultProps = { - trackedDownloadStatus: 'Ok', - trackedDownloadState: 'Downloading' -}; - -export default QueueStatusCell; diff --git a/frontend/src/Activity/Queue/QueueStatusCell.tsx b/frontend/src/Activity/Queue/QueueStatusCell.tsx new file mode 100644 index 00000000000..634e3316464 --- /dev/null +++ b/frontend/src/Activity/Queue/QueueStatusCell.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; +import QueueStatus from './QueueStatus'; +import styles from './QueueStatusCell.css'; + +interface QueueStatusCellProps { + sourceTitle: string; + status: string; + trackedDownloadStatus?: QueueTrackedDownloadStatus; + trackedDownloadState?: QueueTrackedDownloadState; + statusMessages?: StatusMessage[]; + errorMessage?: string; +} + +function QueueStatusCell(props: QueueStatusCellProps) { + const { + sourceTitle, + status, + trackedDownloadStatus = 'ok', + trackedDownloadState = 'downloading', + statusMessages, + errorMessage, + } = props; + + return ( + + + + ); +} + +export default QueueStatusCell; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.css b/frontend/src/Activity/Queue/RemoveQueueItemModal.css similarity index 100% rename from frontend/src/Activity/Queue/RemoveQueueItemsModal.css rename to frontend/src/Activity/Queue/RemoveQueueItemModal.css diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.css.d.ts b/frontend/src/Activity/Queue/RemoveQueueItemModal.css.d.ts new file mode 100644 index 00000000000..65c237dff7b --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.js b/frontend/src/Activity/Queue/RemoveQueueItemModal.js deleted file mode 100644 index f1a15273436..00000000000 --- a/frontend/src/Activity/Queue/RemoveQueueItemModal.js +++ /dev/null @@ -1,150 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import Modal from 'Components/Modal/Modal'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes, kinds, sizes } from 'Helpers/Props'; - -class RemoveQueueItemModal extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - remove: true, - blocklist: false - }; - } - - // - // Control - - resetState = function() { - this.setState({ - remove: true, - blocklist: false - }); - }; - - // - // Listeners - - onRemoveChange = ({ value }) => { - this.setState({ remove: value }); - }; - - onBlocklistChange = ({ value }) => { - this.setState({ blocklist: value }); - }; - - onRemoveConfirmed = () => { - const state = this.state; - - this.resetState(); - this.props.onRemovePress(state); - }; - - onModalClose = () => { - this.resetState(); - this.props.onModalClose(); - }; - - // - // Render - - render() { - const { - isOpen, - sourceTitle, - canIgnore, - isPending - } = this.props; - - const { remove, blocklist } = this.state; - - return ( - - - - Remove - {sourceTitle} - - - -
- Are you sure you want to remove '{sourceTitle}' from the queue? -
- - { - isPending ? - null : - - Remove From Download Client - - - - } - - - Add Release To Blocklist - - - - -
- - - - - - -
-
- ); - } -} - -RemoveQueueItemModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - sourceTitle: PropTypes.string.isRequired, - canIgnore: PropTypes.bool.isRequired, - isPending: PropTypes.bool.isRequired, - onRemovePress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default RemoveQueueItemModal; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx b/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx new file mode 100644 index 00000000000..461fa57ad60 --- /dev/null +++ b/frontend/src/Activity/Queue/RemoveQueueItemModal.tsx @@ -0,0 +1,231 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './RemoveQueueItemModal.css'; + +export interface RemovePressProps { + remove: boolean; + changeCategory: boolean; + blocklist: boolean; + skipRedownload: boolean; +} + +interface RemoveQueueItemModalProps { + isOpen: boolean; + sourceTitle?: string; + canChangeCategory: boolean; + canIgnore: boolean; + isPending: boolean; + selectedCount?: number; + onRemovePress(props: RemovePressProps): void; + onModalClose: () => void; +} + +type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore'; +type BlocklistMethod = + | 'doNotBlocklist' + | 'blocklistAndSearch' + | 'blocklistOnly'; + +function RemoveQueueItemModal(props: RemoveQueueItemModalProps) { + const { + isOpen, + sourceTitle = '', + canIgnore, + canChangeCategory, + isPending, + selectedCount, + onRemovePress, + onModalClose, + } = props; + + const multipleSelected = selectedCount && selectedCount > 1; + + const [removalMethod, setRemovalMethod] = + useState('removeFromClient'); + const [blocklistMethod, setBlocklistMethod] = + useState('doNotBlocklist'); + + const { title, message } = useMemo(() => { + if (!selectedCount) { + return { + title: translate('RemoveQueueItem', { sourceTitle }), + message: translate('RemoveQueueItemConfirmation', { sourceTitle }), + }; + } + + if (selectedCount === 1) { + return { + title: translate('RemoveSelectedItem'), + message: translate('RemoveSelectedItemQueueMessageText'), + }; + } + + return { + title: translate('RemoveSelectedItems'), + message: translate('RemoveSelectedItemsQueueMessageText', { + selectedCount, + }), + }; + }, [sourceTitle, selectedCount]); + + const removalMethodOptions = useMemo(() => { + return [ + { + key: 'removeFromClient', + value: translate('RemoveFromDownloadClient'), + hint: multipleSelected + ? translate('RemoveMultipleFromDownloadClientHint') + : translate('RemoveFromDownloadClientHint'), + }, + { + key: 'changeCategory', + value: translate('ChangeCategory'), + isDisabled: !canChangeCategory, + hint: multipleSelected + ? translate('ChangeCategoryMultipleHint') + : translate('ChangeCategoryHint'), + }, + { + key: 'ignore', + value: multipleSelected + ? translate('IgnoreDownloads') + : translate('IgnoreDownload'), + isDisabled: !canIgnore, + hint: multipleSelected + ? translate('IgnoreDownloadsHint') + : translate('IgnoreDownloadHint'), + }, + ]; + }, [canChangeCategory, canIgnore, multipleSelected]); + + const blocklistMethodOptions = useMemo(() => { + return [ + { + key: 'doNotBlocklist', + value: translate('DoNotBlocklist'), + hint: translate('DoNotBlocklistHint'), + }, + { + key: 'blocklistAndSearch', + value: translate('BlocklistAndSearch'), + isDisabled: isPending, + hint: multipleSelected + ? translate('BlocklistAndSearchMultipleHint') + : translate('BlocklistAndSearchHint'), + }, + { + key: 'blocklistOnly', + value: translate('BlocklistOnly'), + hint: multipleSelected + ? translate('BlocklistMultipleOnlyHint') + : translate('BlocklistOnlyHint'), + }, + ]; + }, [isPending, multipleSelected]); + + const handleRemovalMethodChange = useCallback( + ({ value }: { value: RemovalMethod }) => { + setRemovalMethod(value); + }, + [setRemovalMethod] + ); + + const handleBlocklistMethodChange = useCallback( + ({ value }: { value: BlocklistMethod }) => { + setBlocklistMethod(value); + }, + [setBlocklistMethod] + ); + + const handleConfirmRemove = useCallback(() => { + onRemovePress({ + remove: removalMethod === 'removeFromClient', + changeCategory: removalMethod === 'changeCategory', + blocklist: blocklistMethod !== 'doNotBlocklist', + skipRedownload: blocklistMethod === 'blocklistOnly', + }); + + setRemovalMethod('removeFromClient'); + setBlocklistMethod('doNotBlocklist'); + }, [ + removalMethod, + blocklistMethod, + setRemovalMethod, + setBlocklistMethod, + onRemovePress, + ]); + + const handleModalClose = useCallback(() => { + setRemovalMethod('removeFromClient'); + setBlocklistMethod('doNotBlocklist'); + + onModalClose(); + }, [setRemovalMethod, setBlocklistMethod, onModalClose]); + + return ( + + + {title} + + +
{message}
+ + {isPending ? null : ( + + {translate('RemoveQueueItemRemovalMethod')} + + + + )} + + + + {multipleSelected + ? translate('BlocklistReleases') + : translate('BlocklistRelease')} + + + + +
+ + + + + + +
+
+ ); +} + +export default RemoveQueueItemModal; diff --git a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js b/frontend/src/Activity/Queue/RemoveQueueItemsModal.js deleted file mode 100644 index 295b72179ee..00000000000 --- a/frontend/src/Activity/Queue/RemoveQueueItemsModal.js +++ /dev/null @@ -1,153 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import Modal from 'Components/Modal/Modal'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes, kinds, sizes } from 'Helpers/Props'; -import styles from './RemoveQueueItemsModal.css'; - -class RemoveQueueItemsModal extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - remove: true, - blocklist: false - }; - } - - // - // Control - - resetState = function() { - this.setState({ - remove: true, - blocklist: false - }); - }; - - // - // Listeners - - onRemoveChange = ({ value }) => { - this.setState({ remove: value }); - }; - - onBlocklistChange = ({ value }) => { - this.setState({ blocklist: value }); - }; - - onRemoveConfirmed = () => { - const state = this.state; - - this.resetState(); - this.props.onRemovePress(state); - }; - - onModalClose = () => { - this.resetState(); - this.props.onModalClose(); - }; - - // - // Render - - render() { - const { - isOpen, - selectedCount, - canIgnore, - allPending - } = this.props; - - const { remove, blocklist } = this.state; - - return ( - - - - Remove Selected Item{selectedCount > 1 ? 's' : ''} - - - -
- Are you sure you want to remove {selectedCount} item{selectedCount > 1 ? 's' : ''} from the queue? -
- - { - allPending ? - null : - - Remove From Download Client - - - - } - - - - Add Release{selectedCount > 1 ? 's' : ''} To Blocklist - - - - - -
- - - - - - -
-
- ); - } -} - -RemoveQueueItemsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - selectedCount: PropTypes.number.isRequired, - canIgnore: PropTypes.bool.isRequired, - allPending: PropTypes.bool.isRequired, - onRemovePress: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default RemoveQueueItemsModal; diff --git a/frontend/src/Activity/Queue/Status/QueueStatus.tsx b/frontend/src/Activity/Queue/Status/QueueStatus.tsx new file mode 100644 index 00000000000..894434e0747 --- /dev/null +++ b/frontend/src/Activity/Queue/Status/QueueStatus.tsx @@ -0,0 +1,37 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { fetchQueueStatus } from 'Store/Actions/queueActions'; +import createQueueStatusSelector from './createQueueStatusSelector'; + +function QueueStatus() { + const dispatch = useDispatch(); + const { isConnected, isReconnecting } = useSelector( + (state: AppState) => state.app + ); + const { isPopulated, count, errors, warnings } = useSelector( + createQueueStatusSelector() + ); + + const wasReconnecting = usePrevious(isReconnecting); + + useEffect(() => { + if (!isPopulated) { + dispatch(fetchQueueStatus()); + } + }, [isPopulated, dispatch]); + + useEffect(() => { + if (isConnected && wasReconnecting) { + dispatch(fetchQueueStatus()); + } + }, [isConnected, wasReconnecting, dispatch]); + + return ( + + ); +} + +export default QueueStatus; diff --git a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js b/frontend/src/Activity/Queue/Status/QueueStatusConnector.js deleted file mode 100644 index 9412d7952b3..00000000000 --- a/frontend/src/Activity/Queue/Status/QueueStatusConnector.js +++ /dev/null @@ -1,76 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus'; -import { fetchQueueStatus } from 'Store/Actions/queueActions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app, - (state) => state.queue.status, - (state) => state.queue.options.includeUnknownSeriesItems, - (app, status, includeUnknownSeriesItems) => { - const { - errors, - warnings, - unknownErrors, - unknownWarnings, - count, - totalCount - } = status.item; - - return { - isConnected: app.isConnected, - isReconnecting: app.isReconnecting, - isPopulated: status.isPopulated, - ...status.item, - count: includeUnknownSeriesItems ? totalCount : count, - errors: includeUnknownSeriesItems ? errors || unknownErrors : errors, - warnings: includeUnknownSeriesItems ? warnings || unknownWarnings : warnings - }; - } - ); -} - -const mapDispatchToProps = { - fetchQueueStatus -}; - -class QueueStatusConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - if (!this.props.isPopulated) { - this.props.fetchQueueStatus(); - } - } - - componentDidUpdate(prevProps) { - if (this.props.isConnected && prevProps.isReconnecting) { - this.props.fetchQueueStatus(); - } - } - - // - // Render - - render() { - return ( - - ); - } -} - -QueueStatusConnector.propTypes = { - isConnected: PropTypes.bool.isRequired, - isReconnecting: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - fetchQueueStatus: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(QueueStatusConnector); diff --git a/frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts b/frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts new file mode 100644 index 00000000000..4fd37b28c2a --- /dev/null +++ b/frontend/src/Activity/Queue/Status/createQueueStatusSelector.ts @@ -0,0 +1,32 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +function createQueueStatusSelector() { + return createSelector( + (state: AppState) => state.queue.status.isPopulated, + (state: AppState) => state.queue.status.item, + (state: AppState) => state.queue.options.includeUnknownSeriesItems, + (isPopulated, status, includeUnknownSeriesItems) => { + const { + errors, + warnings, + unknownErrors, + unknownWarnings, + count, + totalCount, + } = status; + + return { + ...status, + isPopulated, + count: includeUnknownSeriesItems ? totalCount : count, + errors: includeUnknownSeriesItems ? errors || unknownErrors : errors, + warnings: includeUnknownSeriesItems + ? warnings || unknownWarnings + : warnings, + }; + } + ); +} + +export default createQueueStatusSelector; diff --git a/frontend/src/Activity/Queue/TimeleftCell.css.d.ts b/frontend/src/Activity/Queue/TimeleftCell.css.d.ts new file mode 100644 index 00000000000..f5c9402d120 --- /dev/null +++ b/frontend/src/Activity/Queue/TimeleftCell.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'timeleft': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Activity/Queue/TimeleftCell.js b/frontend/src/Activity/Queue/TimeleftCell.js deleted file mode 100644 index cfb1af743c1..00000000000 --- a/frontend/src/Activity/Queue/TimeleftCell.js +++ /dev/null @@ -1,82 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import formatTime from 'Utilities/Date/formatTime'; -import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import formatBytes from 'Utilities/Number/formatBytes'; -import styles from './TimeleftCell.css'; - -function TimeleftCell(props) { - const { - estimatedCompletionTime, - timeleft, - status, - size, - sizeleft, - showRelativeDates, - shortDateFormat, - timeFormat - } = props; - - if (status === 'delay') { - const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); - const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); - - return ( - - - - - ); - } - - if (status === 'downloadClientUnavailable') { - const date = getRelativeDate(estimatedCompletionTime, shortDateFormat, showRelativeDates); - const time = formatTime(estimatedCompletionTime, timeFormat, { includeMinuteZero: true }); - - return ( - - - - - ); - } - - if (!timeleft || status === 'completed' || status === 'failed') { - return ( - - - - - ); - } - - const totalSize = formatBytes(size); - const remainingSize = formatBytes(sizeleft); - - return ( - - {formatTimeSpan(timeleft)} - - ); -} - -TimeleftCell.propTypes = { - estimatedCompletionTime: PropTypes.string, - timeleft: PropTypes.string, - status: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - sizeleft: PropTypes.number.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default TimeleftCell; diff --git a/frontend/src/Activity/Queue/TimeleftCell.tsx b/frontend/src/Activity/Queue/TimeleftCell.tsx new file mode 100644 index 00000000000..917a6ad0d22 --- /dev/null +++ b/frontend/src/Activity/Queue/TimeleftCell.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import formatTime from 'Utilities/Date/formatTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import styles from './TimeleftCell.css'; + +interface TimeleftCellProps { + estimatedCompletionTime?: string; + timeleft?: string; + status: string; + size: number; + sizeleft: number; + showRelativeDates: boolean; + shortDateFormat: string; + timeFormat: string; +} + +function TimeleftCell(props: TimeleftCellProps) { + const { + estimatedCompletionTime, + timeleft, + status, + size, + sizeleft, + showRelativeDates, + shortDateFormat, + timeFormat, + } = props; + + if (status === 'delay') { + const date = getRelativeDate({ + date: estimatedCompletionTime, + shortDateFormat, + showRelativeDates, + }); + const time = formatTime(estimatedCompletionTime, timeFormat, { + includeMinuteZero: true, + }); + + return ( + + } + tooltip={translate('DelayingDownloadUntil', { date, time })} + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> + + ); + } + + if (status === 'downloadClientUnavailable') { + const date = getRelativeDate({ + date: estimatedCompletionTime, + shortDateFormat, + showRelativeDates, + }); + const time = formatTime(estimatedCompletionTime, timeFormat, { + includeMinuteZero: true, + }); + + return ( + + } + tooltip={translate('RetryingDownloadOn', { date, time })} + kind={kinds.INVERSE} + position={tooltipPositions.TOP} + /> + + ); + } + + if (!timeleft || status === 'completed' || status === 'failed') { + return -; + } + + const totalSize = formatBytes(size); + const remainingSize = formatBytes(sizeleft); + + return ( + + {formatTimeSpan(timeleft)} + + ); +} + +export default TimeleftCell; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css.d.ts b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css.d.ts new file mode 100644 index 00000000000..176938a161a --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css.d.ts @@ -0,0 +1,15 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'clearLookupButton': string; + 'helpText': string; + 'message': string; + 'noResults': string; + 'noSeriesText': string; + 'searchContainer': string; + 'searchIconContainer': string; + 'searchInput': string; + 'searchResults': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js index 343265e989d..18cbffddb85 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import TextInput from 'Components/Form/TextInput'; import Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; @@ -9,6 +10,7 @@ import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import { icons, kinds } from 'Helpers/Props'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; import AddNewSeriesSearchResultConnector from './AddNewSeriesSearchResultConnector'; import styles from './AddNewSeries.css'; @@ -87,7 +89,7 @@ class AddNewSeries extends Component { const isFetching = this.state.isFetching; return ( - +
@@ -126,9 +128,10 @@ class AddNewSeries extends Component { !isFetching && !!error ?
- Failed to load search results, please try again. + {translate('AddNewSeriesError')}
-
{getErrorMessage(error)}
+ + {getErrorMessage(error)}
: null } @@ -151,11 +154,11 @@ class AddNewSeries extends Component { { !isFetching && !error && !items.length && !!term &&
-
Couldn't find any results for '{term}'
-
You can also search using TVDB ID of a show. eg. tvdb:71663
+
{translate('CouldNotFindResults', { term })}
+
{translate('SearchByTvdbId')}
- Why can't I find my show? + {translate('WhyCantIFindMyShow')}
@@ -166,9 +169,9 @@ class AddNewSeries extends Component { null :
- It's easy to add a new series, just start typing the name the series you want to add. + {translate('AddNewSeriesHelpText')}
-
You can also search using TVDB ID of a show. eg. tvdb:71663
+
{translate('SearchByTvdbId')}
} @@ -176,14 +179,14 @@ class AddNewSeries extends Component { !term && !hasExistingSeries ?
- You haven't added any series yet, do you want to import some or all of your series first? + {translate('NoSeriesHaveBeenAdded')}
: diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.css.d.ts b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.css.d.ts new file mode 100644 index 00000000000..554ff6ff311 --- /dev/null +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.css.d.ts @@ -0,0 +1,18 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'addButton': string; + 'container': string; + 'info': string; + 'labelIcon': string; + 'modalFooter': string; + 'overview': string; + 'poster': string; + 'searchInput': string; + 'searchInputContainer': string; + 'searchLabel': string; + 'searchLabelContainer': string; + 'year': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js index 1c8914e6b48..285c00ecba9 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js @@ -17,6 +17,7 @@ import Popover from 'Components/Tooltip/Popover'; import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props'; import SeriesPoster from 'Series/SeriesPoster'; import * as seriesTypes from 'Utilities/Series/seriesTypes'; +import translate from 'Utilities/String/translate'; import styles from './AddNewSeriesModalContent.css'; class AddNewSeriesModalContent extends Component { @@ -119,7 +120,7 @@ class AddNewSeriesModalContent extends Component {
- Root Folder + {translate('RootFolder')} @@ -140,7 +141,7 @@ class AddNewSeriesModalContent extends Component { - Monitor + {translate('Monitor')} } - title="Monitoring Options" + title={translate('MonitoringOptions')} body={} position={tooltipPositions.RIGHT} /> @@ -164,7 +165,7 @@ class AddNewSeriesModalContent extends Component { - Quality Profile + {translate('QualityProfile')} - Series Type + {translate('SeriesType')} } - title="Series Types" + title={translate('SeriesTypes')} body={} position={tooltipPositions.RIGHT} /> @@ -197,11 +198,12 @@ class AddNewSeriesModalContent extends Component { onChange={onInputChange} {...seriesType} value={this.state.seriesType} + helpText={translate('SeriesTypesHelpText')} /> - Season Folder + {translate('SeasonFolder')} - Tags + {translate('Tags')} + { + originalLanguage?.name ? + : + null + } + { network ? : + null + } + + { + genres.length > 0 ? + : null } @@ -169,7 +209,7 @@ class AddNewSeriesSearchResult extends Component { kind={kinds.DANGER} size={sizes.LARGE} > - Ended + {translate('Ended')} : null } @@ -180,7 +220,7 @@ class AddNewSeriesSearchResult extends Component { kind={kinds.INFO} size={sizes.LARGE} > - Upcoming + {translate('Upcoming')} : null } @@ -216,6 +256,8 @@ AddNewSeriesSearchResult.propTypes = { titleSlug: PropTypes.string.isRequired, year: PropTypes.number.isRequired, network: PropTypes.string, + originalLanguage: PropTypes.object, + genres: PropTypes.arrayOf(PropTypes.string), status: PropTypes.string.isRequired, overview: PropTypes.string, statistics: PropTypes.object.isRequired, @@ -227,4 +269,8 @@ AddNewSeriesSearchResult.propTypes = { isSmallScreen: PropTypes.bool.isRequired }; +AddNewSeriesSearchResult.defaultProps = { + genres: [] +}; + export default AddNewSeriesSearchResult; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js index 8580d47af63..eecdf8495d1 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeries.js @@ -1,9 +1,12 @@ +import { reduce } from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import Alert from 'Components/Alert'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; -import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; import selectAll from 'Utilities/Table/selectAll'; import toggleSelected from 'Utilities/Table/toggleSelected'; import ImportSeriesFooterConnector from './ImportSeriesFooterConnector'; @@ -17,6 +20,8 @@ class ImportSeries extends Component { constructor(props, context) { super(props, context); + this.scrollerRef = React.createRef(); + this.state = { allSelected: false, allUnselected: false, @@ -25,18 +30,21 @@ class ImportSeries extends Component { }; } - // - // Control - - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - }; - // // Listeners getSelectedIds = () => { - return getSelectedIds(this.state.selectedState, { parseIds: false }); + return reduce( + this.state.selectedState, + (result, value, id) => { + if (value) { + result.push(id); + } + + return result; + }, + [] + ); }; onSelectAllChange = ({ value }) => { @@ -70,10 +78,6 @@ class ImportSeries extends Component { this.props.onImportPress(this.getSelectedIds()); }; - onScroll = ({ scrollTop }) => { - this.setState({ scrollTop }); - }; - // // Render @@ -90,23 +94,21 @@ class ImportSeries extends Component { const { allSelected, allUnselected, - selectedState, - scroller + selectedState } = this.state; return ( - - + + { rootFoldersFetching ? : null } { !rootFoldersFetching && !!rootFoldersError ? -
Unable to load root folders
: + + {translate('RootFoldersLoadError')} + : null } @@ -115,9 +117,9 @@ class ImportSeries extends Component { !rootFoldersFetching && rootFoldersPopulated && !unmappedFolders.length ? -
- All series in {path} have been imported -
: + + {translate('AllSeriesInRootFolderHaveBeenImported', { path })} + : null } @@ -126,14 +128,14 @@ class ImportSeries extends Component { !rootFoldersFetching && rootFoldersPopulated && !!unmappedFolders.length && - scroller ? + this.scrollerRef.current ?
- Monitor + {translate('Monitor')}
- Quality Profile + {translate('QualityProfile')}
- Series Type + {translate('SeriesType')}
- Season Folder + {translate('SeasonFolder')}
- Import {selectedCount} Series + {translate('ImportCountSeries', { selectedCount })} { @@ -203,7 +204,7 @@ class ImportSeriesFooter extends Component { kind={kinds.WARNING} onPress={onCancelLookupPress} > - Cancel Processing + {translate('CancelProcessing')} : null } @@ -215,7 +216,7 @@ class ImportSeriesFooter extends Component { kind={kinds.SUCCESS} onPress={onLookupPress} > - Start Processing + {translate('StartProcessing')} : null } @@ -231,7 +232,7 @@ class ImportSeriesFooter extends Component { { isLookingUpSeries ? - 'Processing Folders' : + translate('ProcessingFolders') : null } @@ -245,7 +246,7 @@ class ImportSeriesFooter extends Component { kind={kinds.WARNING} /> } - title="Import Errors" + title={translate('ImportErrors')} body={
    { diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.css.d.ts b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.css.d.ts new file mode 100644 index 00000000000..ecf1347f584 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.css.d.ts @@ -0,0 +1,13 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'detailsIcon': string; + 'folder': string; + 'monitor': string; + 'qualityProfile': string; + 'seasonFolder': string; + 'series': string; + 'seriesType': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js index d6ce86bad74..6f44b9754ca 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesHeader.js @@ -8,6 +8,7 @@ import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell'; import Popover from 'Components/Tooltip/Popover'; import { icons, tooltipPositions } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; import styles from './ImportSeriesHeader.css'; function ImportSeriesHeader(props) { @@ -29,14 +30,14 @@ function ImportSeriesHeader(props) { className={styles.folder} name="folder" > - Folder + {translate('Folder')} - Monitor + {translate('Monitor')} } - title="Monitoring Options" + title={translate('MonitoringOptions')} body={} position={tooltipPositions.RIGHT} /> @@ -55,14 +56,14 @@ function ImportSeriesHeader(props) { className={styles.qualityProfile} name="qualityProfileId" > - Quality Profile + {translate('QualityProfile')} - Series Type + {translate('SeriesType')} } - title="Series Type" + title={translate('SeriesType')} body={} position={tooltipPositions.RIGHT} /> @@ -81,14 +82,14 @@ function ImportSeriesHeader(props) { className={styles.seasonFolder} name="seasonFolder" > - Season Folder + {translate('SeasonFolder')} - Series + {translate('Series')} ); diff --git a/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.css.d.ts b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.css.d.ts new file mode 100644 index 00000000000..4339b9d0522 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/ImportSeriesRow.css.d.ts @@ -0,0 +1,13 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'folder': string; + 'monitor': string; + 'qualityProfile': string; + 'seasonFolder': string; + 'selectInput': string; + 'series': string; + 'seriesType': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css.d.ts b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css.d.ts new file mode 100644 index 00000000000..ff161359d13 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'container': string; + 'series': string; + 'tvdbLink': string; + 'tvdbLinkIcon': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js index e9605ed07c9..60848ce8582 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesSearchResult.js @@ -34,7 +34,7 @@ function ImportSeriesSearchResult(props) { - No match found! + {translate('NoMatchFound')}
: null } @@ -189,7 +190,7 @@ class ImportSeriesSelectSeries extends Component { kind={kinds.WARNING} /> - Search failed, please try again later. + {translate('SearchFailedError')}
: null } diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css.d.ts b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css.d.ts new file mode 100644 index 00000000000..1816cf49eb5 --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'existing': string; + 'title': string; + 'titleContainer': string; + 'year': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js index 1c698429bd1..c7ea7b961ec 100644 --- a/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js +++ b/frontend/src/AddSeries/ImportSeries/Import/SelectSeries/ImportSeriesTitle.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import Label from 'Components/Label'; import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; import styles from './ImportSeriesTitle.css'; function ImportSeriesTitle(props) { @@ -38,7 +39,7 @@ function ImportSeriesTitle(props) { : null } diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css.d.ts b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css.d.ts new file mode 100644 index 00000000000..e9d3bbc926e --- /dev/null +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.css.d.ts @@ -0,0 +1,14 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'addErrorAlert': string; + 'code': string; + 'header': string; + 'importButtonIcon': string; + 'recentFolders': string; + 'startImport': string; + 'tip': string; + 'tips': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js index 742cb2c4f27..24fcff3dc3e 100644 --- a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolder.js @@ -6,10 +6,12 @@ import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; import Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import { icons, kinds, sizes } from 'Helpers/Props'; import RootFolders from 'RootFolder/RootFolders'; +import translate from 'Utilities/String/translate'; import styles from './ImportSeriesSelectFolder.css'; class ImportSeriesSelectFolder extends Component { @@ -55,9 +57,11 @@ class ImportSeriesSelectFolder extends Component { } = this.props; const hasRootFolders = items.length > 0; + const goodFolderExample = (isWindows) ? 'C:\\tv shows' : '/tv shows'; + const badFolderExample = (isWindows) ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons'; return ( - + { isFetching && !isPopulated ? @@ -67,7 +71,7 @@ class ImportSeriesSelectFolder extends Component { { !isFetching && error ? -
Unable to load root folders
: + {translate('RootFoldersLoadError')} : null } @@ -75,20 +79,20 @@ class ImportSeriesSelectFolder extends Component { !error && isPopulated &&
- Import series you already have + {translate('LibraryImportSeriesHeader')}
- Some tips to ensure the import goes smoothly: + {translate('LibraryImportTips')}
  • - Make sure that your files include the quality in their filenames. eg. episode.s02e15.bluray.mkv +
  • - Point Sonarr to the folder containing all of your tv shows, not a specific one. eg. "{isWindows ? 'C:\\tv shows' : '/tv shows'}" and not "{isWindows ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons'}" Additionally, each series must be in its own folder within the root/library folder. +
  • - Do not use for importing downloads from your download client, this is only for existing organized libraries, not unsorted files. + {translate('LibraryImportTipsDontUseDownloadsFolder')}
@@ -96,7 +100,7 @@ class ImportSeriesSelectFolder extends Component { { hasRootFolders ?
-
+
- Unable to add root folder + {translate('AddRootFolderError')}
    { @@ -149,8 +153,8 @@ class ImportSeriesSelectFolder extends Component { /> { hasRootFolders ? - 'Choose another folder' : - 'Start Import' + translate('ChooseAnotherFolder') : + translate('StartImport') }
diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js index 76bb2f34044..1df231f4e74 100644 --- a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js +++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js @@ -5,12 +5,13 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { addRootFolder, fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector'; import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; import ImportSeriesSelectFolder from './ImportSeriesSelectFolder'; function createMapStateToProps() { return createSelector( - (state) => state.rootFolders, + createRootFoldersSelector(), createSystemStatusSelector(), (rootFolders, systemStatus) => { return { diff --git a/frontend/src/AddSeries/SeriesMonitorNewItemsOptionsPopoverContent.js b/frontend/src/AddSeries/SeriesMonitorNewItemsOptionsPopoverContent.js new file mode 100644 index 00000000000..c70ec0decf2 --- /dev/null +++ b/frontend/src/AddSeries/SeriesMonitorNewItemsOptionsPopoverContent.js @@ -0,0 +1,22 @@ +import React from 'react'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import translate from 'Utilities/String/translate'; + +function SeriesMonitorNewItemsOptionsPopoverContent() { + return ( + + + + + + ); +} + +export default SeriesMonitorNewItemsOptionsPopoverContent; diff --git a/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js b/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js index e889fbb095d..21289fcb807 100644 --- a/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js +++ b/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js @@ -1,43 +1,64 @@ import React from 'react'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import translate from 'Utilities/String/translate'; function SeriesMonitoringOptionsPopoverContent() { return ( + + + + + + + + ); diff --git a/frontend/src/AddSeries/SeriesTypePopoverContent.js b/frontend/src/AddSeries/SeriesTypePopoverContent.js index e57d49a9e5e..9771bd8dba0 100644 --- a/frontend/src/AddSeries/SeriesTypePopoverContent.js +++ b/frontend/src/AddSeries/SeriesTypePopoverContent.js @@ -1,23 +1,24 @@ import React from 'react'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import translate from 'Utilities/String/translate'; function SeriesTypePopoverContent() { return ( ); diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js deleted file mode 100644 index 781b2ca1003..00000000000 --- a/frontend/src/App/App.js +++ /dev/null @@ -1,31 +0,0 @@ -import { ConnectedRouter } from 'connected-react-router'; -import PropTypes from 'prop-types'; -import React from 'react'; -import DocumentTitle from 'react-document-title'; -import { Provider } from 'react-redux'; -import PageConnector from 'Components/Page/PageConnector'; -import ApplyTheme from './ApplyTheme'; -import AppRoutes from './AppRoutes'; - -function App({ store, history }) { - return ( - - - - - - - - - - - - ); -} - -App.propTypes = { - store: PropTypes.object.isRequired, - history: PropTypes.object.isRequired -}; - -export default App; diff --git a/frontend/src/App/App.tsx b/frontend/src/App/App.tsx new file mode 100644 index 00000000000..b71199bb378 --- /dev/null +++ b/frontend/src/App/App.tsx @@ -0,0 +1,35 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +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'; + +interface AppProps { + store: Store; + history: ConnectedRouterProps['history']; +} + +const queryClient = new QueryClient(); + +function App({ store, history }: AppProps) { + return ( + + + + + + + + + + + + + ); +} + +export default App; diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js deleted file mode 100644 index dd1bca729c4..00000000000 --- a/frontend/src/App/AppRoutes.js +++ /dev/null @@ -1,266 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { Redirect, Route } from 'react-router-dom'; -import BlocklistConnector from 'Activity/Blocklist/BlocklistConnector'; -import HistoryConnector from 'Activity/History/HistoryConnector'; -import QueueConnector from 'Activity/Queue/QueueConnector'; -import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector'; -import ImportSeries from 'AddSeries/ImportSeries/ImportSeries'; -import CalendarPageConnector from 'Calendar/CalendarPageConnector'; -import NotFound from 'Components/NotFound'; -import Switch from 'Components/Router/Switch'; -import SeasonPassConnector from 'SeasonPass/SeasonPassConnector'; -import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector'; -import SeriesEditorConnector from 'Series/Editor/SeriesEditorConnector'; -import SeriesIndexConnector from 'Series/Index/SeriesIndexConnector'; -import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector'; -import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; -import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; -import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector'; -import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector'; -import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector'; -import MetadataSettings from 'Settings/Metadata/MetadataSettings'; -import MetadataSourceSettings from 'Settings/MetadataSource/MetadataSourceSettings'; -import NotificationSettings from 'Settings/Notifications/NotificationSettings'; -import Profiles from 'Settings/Profiles/Profiles'; -import QualityConnector from 'Settings/Quality/QualityConnector'; -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'; -import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector'; -import MissingConnector from 'Wanted/Missing/MissingConnector'; - -function AppRoutes(props) { - const { - app - } = props; - - return ( - - {/* - Series - */} - - - - { - window.Sonarr.urlBase && - { - return ( - - ); - }} - /> - } - - - - - - - - - - - - {/* - Calendar - */} - - - - {/* - Activity - */} - - - - - - - - {/* - Wanted - */} - - - - - - {/* - 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 00000000000..fbe4a15bb5e --- /dev/null +++ b/frontend/src/App/AppRoutes.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { Redirect, Route } from 'react-router-dom'; +import Blocklist from 'Activity/Blocklist/Blocklist'; +import History from 'Activity/History/History'; +import Queue from 'Activity/Queue/Queue'; +import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector'; +import ImportSeries from 'AddSeries/ImportSeries/ImportSeries'; +import CalendarPage from 'Calendar/CalendarPage'; +import NotFound from 'Components/NotFound'; +import Switch from 'Components/Router/Switch'; +import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector'; +import SeriesIndex from 'Series/Index/SeriesIndex'; +import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage'; +import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; +import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; +import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector'; +import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector'; +import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector'; +import MetadataSettings from 'Settings/Metadata/MetadataSettings'; +import MetadataSourceSettings from 'Settings/MetadataSource/MetadataSourceSettings'; +import NotificationSettings from 'Settings/Notifications/NotificationSettings'; +import Profiles from 'Settings/Profiles/Profiles'; +import QualityConnector from 'Settings/Quality/QualityConnector'; +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'; +import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector'; +import MissingConnector from 'Wanted/Missing/MissingConnector'; + +function RedirectWithUrlBase() { + return ; +} + +function AppRoutes() { + return ( + + {/* + Series + */} + + + + {window.Sonarr.urlBase && ( + + )} + + + + + + + + + + + + {/* + Calendar + */} + + + + {/* + Activity + */} + + + + + + + + {/* + Wanted + */} + + + + + + {/* + 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 abc7f8832fd..00000000000 --- 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 00000000000..696d36fb244 --- /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 a21afbc5aa9..00000000000 --- 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.css b/frontend/src/App/AppUpdatedModalContent.css index 37b89c9becf..0df4183a662 100644 --- a/frontend/src/App/AppUpdatedModalContent.css +++ b/frontend/src/App/AppUpdatedModalContent.css @@ -1,6 +1,7 @@ .version { margin: 0 3px; font-weight: bold; + font-family: var(--defaultFontFamily); } .maintenance { diff --git a/frontend/src/App/AppUpdatedModalContent.css.d.ts b/frontend/src/App/AppUpdatedModalContent.css.d.ts new file mode 100644 index 00000000000..70ddbf6a1a2 --- /dev/null +++ b/frontend/src/App/AppUpdatedModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'changes': string; + 'maintenance': string; + 'version': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js deleted file mode 100644 index 6957f98300f..00000000000 --- a/frontend/src/App/AppUpdatedModalContent.js +++ /dev/null @@ -1,137 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { kinds } from 'Helpers/Props'; -import UpdateChanges from 'System/Updates/UpdateChanges'; -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 ( - - - Sonarr Updated - - - -
- Sonarr has been updated to version {version}, in order to get the latest changes you'll need to reload Sonarr. -
- - { - isPopulated && !error && !!update && -
- { - !update.changes && -
Maintenance release
- } - - { - !!update.changes && -
-
- What's new? -
- - - - -
- } -
- } - - { - !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 00000000000..6553d6270c3 --- /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.Sonarr.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 4100ee67452..00000000000 --- 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.Sonarr.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 ef177749f97..00000000000 --- 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.Sonarr.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 00000000000..ce598f2dc41 --- /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.Sonarr.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.css.d.ts b/frontend/src/App/ConnectionLostModal.css.d.ts new file mode 100644 index 00000000000..027f2a9a3ec --- /dev/null +++ b/frontend/src/App/ConnectionLostModal.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'automatic': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/App/ConnectionLostModal.js b/frontend/src/App/ConnectionLostModal.js deleted file mode 100644 index aa886b7722b..00000000000 --- a/frontend/src/App/ConnectionLostModal.js +++ /dev/null @@ -1,55 +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 styles from './ConnectionLostModal.css'; - -function ConnectionLostModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - Connection Lost - - - -
- Sonarr has lost its connection to the backend and will need to be reloaded to restore functionality. -
- -
- Sonarr will try to connect automatically, or you can click reload below. -
-
- - - -
-
- ); -} - -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 00000000000..f08f2c0e205 --- /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 8ab8e3cd07c..00000000000 --- 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/ModelBase.ts b/frontend/src/App/ModelBase.ts new file mode 100644 index 00000000000..187b12fb239 --- /dev/null +++ b/frontend/src/App/ModelBase.ts @@ -0,0 +1,5 @@ +interface ModelBase { + id: number; +} + +export default ModelBase; diff --git a/frontend/src/App/SelectContext.tsx b/frontend/src/App/SelectContext.tsx new file mode 100644 index 00000000000..66be388ce0a --- /dev/null +++ b/frontend/src/App/SelectContext.tsx @@ -0,0 +1,83 @@ +import { cloneDeep } from 'lodash'; +import React, { useCallback, useEffect } from 'react'; +import useSelectState, { SelectState } from 'Helpers/Hooks/useSelectState'; +import ModelBase from './ModelBase'; + +export type SelectContextAction = + | { type: 'reset' } + | { type: 'selectAll' } + | { type: 'unselectAll' } + | { + type: 'toggleSelected'; + id: number; + isSelected: boolean; + shiftKey: boolean; + } + | { + type: 'removeItem'; + id: number; + } + | { + type: 'updateItems'; + items: ModelBase[]; + }; + +export type SelectDispatch = (action: SelectContextAction) => void; + +interface SelectProviderOptions { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children: any; + items: Array; +} + +const SelectContext = React.createContext< + [SelectState, SelectDispatch] | undefined +>(cloneDeep(undefined)); + +export function SelectProvider( + props: SelectProviderOptions +) { + const { items } = props; + const [state, dispatch] = useSelectState(); + + const dispatchWrapper = useCallback( + (action: SelectContextAction) => { + switch (action.type) { + case 'reset': + case 'removeItem': + dispatch(action); + break; + + default: + dispatch({ + ...action, + items, + }); + break; + } + }, + [items, dispatch] + ); + + const value: [SelectState, SelectDispatch] = [state, dispatchWrapper]; + + useEffect(() => { + dispatch({ type: 'updateItems', items }); + }, [items, dispatch]); + + return ( + + {props.children} + + ); +} + +export function useSelect() { + const context = React.useContext(SelectContext); + + if (context === undefined) { + throw new Error('useSelect must be used within a SelectProvider'); + } + + return context; +} diff --git a/frontend/src/App/State/AppSectionState.ts b/frontend/src/App/State/AppSectionState.ts new file mode 100644 index 00000000000..4e9dbe7a099 --- /dev/null +++ b/frontend/src/App/State/AppSectionState.ts @@ -0,0 +1,85 @@ +import Column from 'Components/Table/Column'; +import { SortDirection } from 'Helpers/Props/sortDirections'; +import { ValidationFailure } from 'typings/pending'; +import { FilterBuilderProp, PropertyFilter } from './AppState'; + +export interface Error { + status?: number; + responseJSON: + | { + message: string | undefined; + } + | ValidationFailure[] + | undefined; +} + +export interface AppSectionDeleteState { + isDeleting: boolean; + deleteError: Error; +} + +export interface AppSectionSaveState { + isSaving: boolean; + saveError: Error; +} + +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 { + isSchemaFetching: boolean; + isSchemaPopulated: boolean; + schemaError: Error; + schema: { + items: T[]; + }; +} + +export interface AppSectionItemSchemaState { + isSchemaFetching: boolean; + isSchemaPopulated: boolean; + schemaError: Error; + schema: T; +} + +export interface AppSectionItemState { + isFetching: boolean; + isPopulated: boolean; + error: Error; + pendingChanges: Partial; + item: T; +} + +export interface AppSectionProviderState + extends AppSectionDeleteState, + AppSectionSaveState { + isFetching: boolean; + isPopulated: boolean; + error: Error; + items: T[]; + pendingChanges: Partial; +} + +interface AppSectionState { + isFetching: boolean; + isPopulated: boolean; + error: Error; + items: T[]; + sortKey: string; + sortDirection: SortDirection; +} + +export default AppSectionState; diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts new file mode 100644 index 00000000000..84bd5d0b48a --- /dev/null +++ b/frontend/src/App/State/AppState.ts @@ -0,0 +1,95 @@ +import BlocklistAppState from './BlocklistAppState'; +import CalendarAppState from './CalendarAppState'; +import CaptchaAppState from './CaptchaAppState'; +import CommandAppState from './CommandAppState'; +import EpisodeFilesAppState from './EpisodeFilesAppState'; +import EpisodesAppState from './EpisodesAppState'; +import HistoryAppState from './HistoryAppState'; +import InteractiveImportAppState from './InteractiveImportAppState'; +import OAuthAppState from './OAuthAppState'; +import ParseAppState from './ParseAppState'; +import PathsAppState from './PathsAppState'; +import ProviderOptionsAppState from './ProviderOptionsAppState'; +import QueueAppState from './QueueAppState'; +import ReleasesAppState from './ReleasesAppState'; +import RootFolderAppState from './RootFolderAppState'; +import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState'; +import SettingsAppState from './SettingsAppState'; +import SystemAppState from './SystemAppState'; +import TagsAppState from './TagsAppState'; +import WantedAppState from './WantedAppState'; + +interface FilterBuilderPropOption { + id: string; + name: string; +} + +export interface FilterBuilderProp { + name: string; + label: string; + type: string; + valueType?: string; + optionsSelector?: (items: T[]) => FilterBuilderPropOption[]; +} + +export interface PropertyFilter { + key: string; + value: boolean | string | number | string[] | number[]; + type: string; +} + +export interface Filter { + key: string; + label: string; + filters: PropertyFilter[]; +} + +export interface CustomFilter { + id: number; + type: string; + label: string; + filters: PropertyFilter[]; +} + +export interface AppSectionState { + isConnected: boolean; + isReconnecting: boolean; + isSidebarVisible: boolean; + version: string; + prevVersion?: string; + dimensions: { + isSmallScreen: boolean; + isLargeScreen: boolean; + width: number; + height: number; + }; +} + +interface AppState { + app: AppSectionState; + blocklist: BlocklistAppState; + calendar: CalendarAppState; + captcha: CaptchaAppState; + commands: CommandAppState; + episodeFiles: EpisodeFilesAppState; + episodeHistory: HistoryAppState; + episodes: EpisodesAppState; + episodesSelection: EpisodesAppState; + history: HistoryAppState; + interactiveImport: InteractiveImportAppState; + oAuth: OAuthAppState; + parse: ParseAppState; + paths: PathsAppState; + providerOptions: ProviderOptionsAppState; + queue: QueueAppState; + releases: ReleasesAppState; + rootFolders: RootFolderAppState; + series: SeriesAppState; + seriesIndex: SeriesIndexAppState; + settings: SettingsAppState; + system: SystemAppState; + tags: TagsAppState; + wanted: WantedAppState; +} + +export default AppState; diff --git a/frontend/src/App/State/BlocklistAppState.ts b/frontend/src/App/State/BlocklistAppState.ts new file mode 100644 index 00000000000..004a30732ed --- /dev/null +++ b/frontend/src/App/State/BlocklistAppState.ts @@ -0,0 +1,16 @@ +import Blocklist from 'typings/Blocklist'; +import AppSectionState, { + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState, +} from './AppSectionState'; + +interface BlocklistAppState + extends AppSectionState, + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState { + isRemoving: boolean; +} + +export default BlocklistAppState; diff --git a/frontend/src/App/State/CalendarAppState.ts b/frontend/src/App/State/CalendarAppState.ts new file mode 100644 index 00000000000..75c8b5e50bf --- /dev/null +++ b/frontend/src/App/State/CalendarAppState.ts @@ -0,0 +1,29 @@ +import moment from 'moment'; +import AppSectionState, { + AppSectionFilterState, +} from 'App/State/AppSectionState'; +import { CalendarView } from 'Calendar/calendarViews'; +import { CalendarItem } from 'typings/Calendar'; + +interface CalendarOptions { + showEpisodeInformation: boolean; + showFinaleIcon: boolean; + showSpecialIcon: boolean; + showCutoffUnmetIcon: boolean; + collapseMultipleEpisodes: boolean; + fullColorEvents: boolean; +} + +interface CalendarAppState + extends AppSectionState, + AppSectionFilterState { + searchMissingCommandId: number | null; + start: moment.Moment; + end: moment.Moment; + dates: string[]; + time: string; + view: CalendarView; + options: CalendarOptions; +} + +export default CalendarAppState; diff --git a/frontend/src/App/State/CaptchaAppState.ts b/frontend/src/App/State/CaptchaAppState.ts new file mode 100644 index 00000000000..7252937eb53 --- /dev/null +++ b/frontend/src/App/State/CaptchaAppState.ts @@ -0,0 +1,11 @@ +interface CaptchaAppState { + refreshing: false; + token: string; + siteKey: unknown; + secretToken: unknown; + ray: unknown; + stoken: unknown; + responseUrl: unknown; +} + +export default CaptchaAppState; diff --git a/frontend/src/App/State/ClientSideCollectionAppState.ts b/frontend/src/App/State/ClientSideCollectionAppState.ts new file mode 100644 index 00000000000..f4110ef73bb --- /dev/null +++ b/frontend/src/App/State/ClientSideCollectionAppState.ts @@ -0,0 +1,8 @@ +import { CustomFilter } from './AppState'; + +interface ClientSideCollectionAppState { + totalItems: number; + customFilters: CustomFilter[]; +} + +export default ClientSideCollectionAppState; diff --git a/frontend/src/App/State/CommandAppState.ts b/frontend/src/App/State/CommandAppState.ts new file mode 100644 index 00000000000..1bde3737156 --- /dev/null +++ b/frontend/src/App/State/CommandAppState.ts @@ -0,0 +1,6 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Command from 'Commands/Command'; + +export type CommandAppState = AppSectionState; + +export default CommandAppState; diff --git a/frontend/src/App/State/CustomFiltersAppState.ts b/frontend/src/App/State/CustomFiltersAppState.ts new file mode 100644 index 00000000000..6ac4820c74c --- /dev/null +++ b/frontend/src/App/State/CustomFiltersAppState.ts @@ -0,0 +1,10 @@ +import AppSectionState, { + AppSectionDeleteState, +} from 'App/State/AppSectionState'; +import { CustomFilter } from './AppState'; + +interface CustomFiltersAppState + extends AppSectionState, + AppSectionDeleteState {} + +export default CustomFiltersAppState; diff --git a/frontend/src/App/State/EpisodeFilesAppState.ts b/frontend/src/App/State/EpisodeFilesAppState.ts new file mode 100644 index 00000000000..5e6e94a06a3 --- /dev/null +++ b/frontend/src/App/State/EpisodeFilesAppState.ts @@ -0,0 +1,10 @@ +import AppSectionState, { + AppSectionDeleteState, +} from 'App/State/AppSectionState'; +import { EpisodeFile } from 'EpisodeFile/EpisodeFile'; + +interface EpisodeFilesAppState + extends AppSectionState, + AppSectionDeleteState {} + +export default EpisodeFilesAppState; diff --git a/frontend/src/App/State/EpisodesAppState.ts b/frontend/src/App/State/EpisodesAppState.ts new file mode 100644 index 00000000000..4234c0bcbd5 --- /dev/null +++ b/frontend/src/App/State/EpisodesAppState.ts @@ -0,0 +1,6 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Episode from 'Episode/Episode'; + +type EpisodesAppState = AppSectionState; + +export default EpisodesAppState; diff --git a/frontend/src/App/State/HistoryAppState.ts b/frontend/src/App/State/HistoryAppState.ts new file mode 100644 index 00000000000..632b8217932 --- /dev/null +++ b/frontend/src/App/State/HistoryAppState.ts @@ -0,0 +1,14 @@ +import AppSectionState, { + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState, +} from 'App/State/AppSectionState'; +import History from 'typings/History'; + +interface HistoryAppState + extends AppSectionState, + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState {} + +export default HistoryAppState; diff --git a/frontend/src/App/State/InteractiveImportAppState.ts b/frontend/src/App/State/InteractiveImportAppState.ts new file mode 100644 index 00000000000..84fd9f4c14c --- /dev/null +++ b/frontend/src/App/State/InteractiveImportAppState.ts @@ -0,0 +1,21 @@ +import AppSectionState from 'App/State/AppSectionState'; +import ImportMode from 'InteractiveImport/ImportMode'; +import InteractiveImport from 'InteractiveImport/InteractiveImport'; + +interface FavoriteFolder { + folder: string; +} + +interface RecentFolder { + folder: string; + lastUsed: string; +} + +interface InteractiveImportAppState extends AppSectionState { + originalItems: InteractiveImport[]; + importMode: ImportMode; + favoriteFolders: FavoriteFolder[]; + recentFolders: RecentFolder[]; +} + +export default InteractiveImportAppState; diff --git a/frontend/src/App/State/MetadataAppState.ts b/frontend/src/App/State/MetadataAppState.ts new file mode 100644 index 00000000000..495f109d8b4 --- /dev/null +++ b/frontend/src/App/State/MetadataAppState.ts @@ -0,0 +1,6 @@ +import { AppSectionProviderState } from 'App/State/AppSectionState'; +import Metadata from 'typings/Metadata'; + +type MetadataAppState = AppSectionProviderState; + +export default MetadataAppState; diff --git a/frontend/src/App/State/OAuthAppState.ts b/frontend/src/App/State/OAuthAppState.ts new file mode 100644 index 00000000000..619767929c6 --- /dev/null +++ b/frontend/src/App/State/OAuthAppState.ts @@ -0,0 +1,9 @@ +import { Error } from './AppSectionState'; + +interface OAuthAppState { + authorizing: boolean; + result: Record | null; + error: Error; +} + +export default OAuthAppState; diff --git a/frontend/src/App/State/ParseAppState.ts b/frontend/src/App/State/ParseAppState.ts new file mode 100644 index 00000000000..67fb4cc630e --- /dev/null +++ b/frontend/src/App/State/ParseAppState.ts @@ -0,0 +1,54 @@ +import ModelBase from 'App/ModelBase'; +import { AppSectionItemState } from 'App/State/AppSectionState'; +import Episode from 'Episode/Episode'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import Series from 'Series/Series'; +import CustomFormat from 'typings/CustomFormat'; + +export interface SeriesTitleInfo { + title: string; + titleWithoutYear: string; + year: number; + allTitles: string[]; +} + +export interface ParsedEpisodeInfo { + releaseTitle: string; + seriesTitle: string; + seriesTitleInfo: SeriesTitleInfo; + quality: QualityModel; + seasonNumber: number; + episodeNumbers: number[]; + absoluteEpisodeNumbers: number[]; + specialAbsoluteEpisodeNumbers: number[]; + languages: Language[]; + fullSeason: boolean; + isPartialSeason: boolean; + isMultiSeason: boolean; + isSeasonExtra: boolean; + special: boolean; + releaseHash: string; + seasonPart: number; + releaseGroup?: string; + releaseTokens: string; + airDate?: string; + isDaily: boolean; + isAbsoluteNumbering: boolean; + isPossibleSpecialEpisode: boolean; + isPossibleSceneSeasonSpecial: boolean; +} + +export interface ParseModel extends ModelBase { + title: string; + parsedEpisodeInfo: ParsedEpisodeInfo; + series?: Series; + episodes: Episode[]; + languages?: Language[]; + customFormats?: CustomFormat[]; + customFormatScore?: number; +} + +type ParseAppState = AppSectionItemState; + +export default ParseAppState; diff --git a/frontend/src/App/State/PathsAppState.ts b/frontend/src/App/State/PathsAppState.ts new file mode 100644 index 00000000000..068a48dc098 --- /dev/null +++ b/frontend/src/App/State/PathsAppState.ts @@ -0,0 +1,29 @@ +interface BasePath { + name: string; + path: string; + size: number; + lastModified: string; +} + +interface File extends BasePath { + type: 'file'; +} + +interface Folder extends BasePath { + type: 'folder'; +} + +export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent'; +export type Path = File | Folder; + +interface PathsAppState { + currentPath: string; + isFetching: boolean; + isPopulated: boolean; + error: Error; + directories: Folder[]; + files: File[]; + parent: string | null; +} + +export default PathsAppState; diff --git a/frontend/src/App/State/ProviderOptionsAppState.ts b/frontend/src/App/State/ProviderOptionsAppState.ts new file mode 100644 index 00000000000..7fb5df02b86 --- /dev/null +++ b/frontend/src/App/State/ProviderOptionsAppState.ts @@ -0,0 +1,22 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Field, { FieldSelectOption } from 'typings/Field'; + +export interface ProviderOptions { + fields?: Field[]; +} + +interface ProviderOptionsDevice { + id: string; + name: string; +} + +interface ProviderOptionsAppState { + devices: AppSectionState; + servers: AppSectionState>; + newznabCategories: AppSectionState>; + getProfiles: AppSectionState>; + getTags: AppSectionState>; + getRootFolders: AppSectionState>; +} + +export default ProviderOptionsAppState; diff --git a/frontend/src/App/State/QueueAppState.ts b/frontend/src/App/State/QueueAppState.ts new file mode 100644 index 00000000000..954d649a2ff --- /dev/null +++ b/frontend/src/App/State/QueueAppState.ts @@ -0,0 +1,44 @@ +import Queue from 'typings/Queue'; +import AppSectionState, { + AppSectionFilterState, + AppSectionItemState, + Error, + PagedAppSectionState, + TableAppSectionState, +} from './AppSectionState'; + +export interface QueueStatus { + totalCount: number; + count: number; + unknownCount: number; + errors: boolean; + warnings: boolean; + unknownErrors: boolean; + unknownWarnings: boolean; +} + +export interface QueueDetailsAppState extends AppSectionState { + params: unknown; +} + +export interface QueuePagedAppState + extends AppSectionState, + AppSectionFilterState, + PagedAppSectionState, + TableAppSectionState { + isGrabbing: boolean; + grabError: Error; + isRemoving: boolean; + removeError: Error; +} + +interface QueueAppState { + status: AppSectionItemState; + details: QueueDetailsAppState; + paged: QueuePagedAppState; + options: { + includeUnknownSeriesItems: boolean; + }; +} + +export default QueueAppState; diff --git a/frontend/src/App/State/ReleasesAppState.ts b/frontend/src/App/State/ReleasesAppState.ts new file mode 100644 index 00000000000..350f6eac8ea --- /dev/null +++ b/frontend/src/App/State/ReleasesAppState.ts @@ -0,0 +1,10 @@ +import AppSectionState, { + AppSectionFilterState, +} from 'App/State/AppSectionState'; +import Release from 'typings/Release'; + +interface ReleasesAppState + extends AppSectionState, + AppSectionFilterState {} + +export default ReleasesAppState; diff --git a/frontend/src/App/State/RootFolderAppState.ts b/frontend/src/App/State/RootFolderAppState.ts new file mode 100644 index 00000000000..9e636c95f47 --- /dev/null +++ b/frontend/src/App/State/RootFolderAppState.ts @@ -0,0 +1,12 @@ +import AppSectionState, { + AppSectionDeleteState, + AppSectionSaveState, +} from 'App/State/AppSectionState'; +import RootFolder from 'typings/RootFolder'; + +interface RootFolderAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export default RootFolderAppState; diff --git a/frontend/src/App/State/SeriesAppState.ts b/frontend/src/App/State/SeriesAppState.ts new file mode 100644 index 00000000000..5da5987dd75 --- /dev/null +++ b/frontend/src/App/State/SeriesAppState.ts @@ -0,0 +1,66 @@ +import AppSectionState, { + AppSectionDeleteState, + AppSectionSaveState, +} from 'App/State/AppSectionState'; +import Column from 'Components/Table/Column'; +import { SortDirection } from 'Helpers/Props/sortDirections'; +import Series from 'Series/Series'; +import { Filter, FilterBuilderProp } from './AppState'; + +export interface SeriesIndexAppState { + sortKey: string; + sortDirection: SortDirection; + secondarySortKey: string; + secondarySortDirection: SortDirection; + view: string; + + posterOptions: { + detailedProgressBar: boolean; + size: string; + showTitle: boolean; + showMonitored: boolean; + showQualityProfile: boolean; + showTags: boolean; + showSearchAction: boolean; + }; + + overviewOptions: { + detailedProgressBar: boolean; + size: string; + showMonitored: boolean; + showNetwork: boolean; + showQualityProfile: boolean; + showPreviousAiring: boolean; + showAdded: boolean; + showSeasonCount: boolean; + showPath: boolean; + showSizeOnDisk: boolean; + showTags: boolean; + showSearchAction: boolean; + }; + + tableOptions: { + showBanners: boolean; + showSearchAction: boolean; + }; + + selectedFilterKey: string; + filterBuilderProps: FilterBuilderProp[]; + filters: Filter[]; + columns: Column[]; +} + +interface SeriesAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState { + itemMap: Record; + + deleteOptions: { + addImportListExclusion: boolean; + }; + + pendingChanges: Partial; +} + +export default SeriesAppState; diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts new file mode 100644 index 00000000000..b8e6f495416 --- /dev/null +++ b/frontend/src/App/State/SettingsAppState.ts @@ -0,0 +1,109 @@ +import AppSectionState, { + AppSectionDeleteState, + AppSectionItemSchemaState, + AppSectionItemState, + AppSectionSaveState, + PagedAppSectionState, +} from 'App/State/AppSectionState'; +import Language from 'Language/Language'; +import CustomFormat from 'typings/CustomFormat'; +import DownloadClient from 'typings/DownloadClient'; +import ImportList from 'typings/ImportList'; +import ImportListExclusion from 'typings/ImportListExclusion'; +import ImportListOptionsSettings from 'typings/ImportListOptionsSettings'; +import Indexer from 'typings/Indexer'; +import IndexerFlag from 'typings/IndexerFlag'; +import Notification from 'typings/Notification'; +import QualityProfile from 'typings/QualityProfile'; +import General from 'typings/Settings/General'; +import NamingConfig from 'typings/Settings/NamingConfig'; +import NamingExample from 'typings/Settings/NamingExample'; +import ReleaseProfile from 'typings/Settings/ReleaseProfile'; +import UiSettings from 'typings/Settings/UiSettings'; +import MetadataAppState from './MetadataAppState'; + +export interface DownloadClientAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState { + isTestingAll: boolean; +} + +export interface GeneralAppState + extends AppSectionItemState, + AppSectionSaveState {} + +export interface NamingAppState + extends AppSectionItemState, + AppSectionSaveState {} + +export type NamingExamplesAppState = AppSectionItemState; + +export interface ImportListAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export interface IndexerAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState { + isTestingAll: boolean; +} + +export interface NotificationAppState + extends AppSectionState, + AppSectionDeleteState {} + +export interface QualityProfilesAppState + extends AppSectionState, + AppSectionItemSchemaState {} + +export interface ReleaseProfilesAppState + extends AppSectionState, + AppSectionSaveState { + pendingChanges: Partial; +} + +export interface CustomFormatAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export interface ImportListOptionsSettingsAppState + extends AppSectionItemState, + AppSectionSaveState {} + +export interface ImportListExclusionsSettingsAppState + extends AppSectionState, + AppSectionSaveState, + PagedAppSectionState, + AppSectionDeleteState { + pendingChanges: Partial; +} + +export type IndexerFlagSettingsAppState = AppSectionState; +export type LanguageSettingsAppState = AppSectionState; +export type UiSettingsAppState = AppSectionItemState; + +interface SettingsAppState { + advancedSettings: boolean; + customFormats: CustomFormatAppState; + downloadClients: DownloadClientAppState; + general: GeneralAppState; + importListExclusions: ImportListExclusionsSettingsAppState; + importListOptions: ImportListOptionsSettingsAppState; + importLists: ImportListAppState; + indexerFlags: IndexerFlagSettingsAppState; + indexers: IndexerAppState; + languages: LanguageSettingsAppState; + metadata: MetadataAppState; + naming: NamingAppState; + namingExamples: NamingExamplesAppState; + notifications: NotificationAppState; + qualityProfiles: QualityProfilesAppState; + releaseProfiles: ReleaseProfilesAppState; + ui: UiSettingsAppState; +} + +export default SettingsAppState; diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts new file mode 100644 index 00000000000..1161f0e1eed --- /dev/null +++ b/frontend/src/App/State/SystemAppState.ts @@ -0,0 +1,22 @@ +import DiskSpace from 'typings/DiskSpace'; +import Health from 'typings/Health'; +import SystemStatus from 'typings/SystemStatus'; +import Task from 'typings/Task'; +import Update from 'typings/Update'; +import AppSectionState, { AppSectionItemState } from './AppSectionState'; + +export type DiskSpaceAppState = AppSectionState; +export type HealthAppState = AppSectionState; +export type SystemStatusAppState = AppSectionItemState; +export type TaskAppState = AppSectionState; +export type UpdateAppState = AppSectionState; + +interface SystemAppState { + diskSpace: DiskSpaceAppState; + health: HealthAppState; + status: SystemStatusAppState; + tasks: TaskAppState; + updates: UpdateAppState; +} + +export default SystemAppState; diff --git a/frontend/src/App/State/TagsAppState.ts b/frontend/src/App/State/TagsAppState.ts new file mode 100644 index 00000000000..914df904425 --- /dev/null +++ b/frontend/src/App/State/TagsAppState.ts @@ -0,0 +1,32 @@ +import ModelBase from 'App/ModelBase'; +import AppSectionState, { + AppSectionDeleteState, + AppSectionSaveState, +} from 'App/State/AppSectionState'; + +export interface Tag extends ModelBase { + label: string; +} + +export interface TagDetail extends ModelBase { + label: string; + autoTagIds: number[]; + delayProfileIds: number[]; + downloadClientIds: []; + importListIds: number[]; + indexerIds: number[]; + notificationIds: number[]; + restrictionIds: number[]; + seriesIds: number[]; +} + +export interface TagDetailAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +interface TagsAppState extends AppSectionState, AppSectionDeleteState { + details: TagDetailAppState; +} + +export default TagsAppState; diff --git a/frontend/src/App/State/WantedAppState.ts b/frontend/src/App/State/WantedAppState.ts new file mode 100644 index 00000000000..b543d387906 --- /dev/null +++ b/frontend/src/App/State/WantedAppState.ts @@ -0,0 +1,13 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Episode from 'Episode/Episode'; + +type WantedCutoffUnmetAppState = AppSectionState; + +type WantedMissingAppState = AppSectionState; + +interface WantedAppState { + cutoffUnmet: WantedCutoffUnmetAppState; + missing: WantedMissingAppState; +} + +export default WantedAppState; diff --git a/frontend/src/Calendar/Agenda/Agenda.css.d.ts b/frontend/src/Calendar/Agenda/Agenda.css.d.ts new file mode 100644 index 00000000000..44421cc994e --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'agenda': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/Agenda/Agenda.js b/frontend/src/Calendar/Agenda/Agenda.js deleted file mode 100644 index 89472301d1c..00000000000 --- a/frontend/src/Calendar/Agenda/Agenda.js +++ /dev/null @@ -1,38 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React from 'react'; -import AgendaEventConnector from './AgendaEventConnector'; -import styles from './Agenda.css'; - -function Agenda(props) { - const { - items - } = props; - - return ( -
- { - items.map((item, index) => { - const momentDate = moment(item.airDateUtc); - const showDate = index === 0 || - !moment(items[index - 1].airDateUtc).isSame(momentDate, 'day'); - - return ( - - ); - }) - } -
- ); -} - -Agenda.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -export default Agenda; diff --git a/frontend/src/Calendar/Agenda/Agenda.tsx b/frontend/src/Calendar/Agenda/Agenda.tsx new file mode 100644 index 00000000000..fdef4046600 --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.tsx @@ -0,0 +1,25 @@ +import moment from 'moment'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import AgendaEvent from './AgendaEvent'; +import styles from './Agenda.css'; + +function Agenda() { + const { items } = useSelector((state: AppState) => state.calendar); + + return ( +
+ {items.map((item, index) => { + const momentDate = moment(item.airDateUtc); + const showDate = + index === 0 || + !moment(items[index - 1].airDateUtc).isSame(momentDate, 'day'); + + return ; + })} +
+ ); +} + +export default Agenda; diff --git a/frontend/src/Calendar/Agenda/AgendaConnector.js b/frontend/src/Calendar/Agenda/AgendaConnector.js deleted file mode 100644 index b6f2388736b..00000000000 --- a/frontend/src/Calendar/Agenda/AgendaConnector.js +++ /dev/null @@ -1,14 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import Agenda from './Agenda'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - (calendar) => { - return calendar; - } - ); -} - -export default connect(createMapStateToProps)(Agenda); diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css b/frontend/src/Calendar/Agenda/AgendaEvent.css index 27b91b857bf..7ad9ccf6a4f 100644 --- a/frontend/src/Calendar/Agenda/AgendaEvent.css +++ b/frontend/src/Calendar/Agenda/AgendaEvent.css @@ -103,8 +103,12 @@ composes: premiere from '~Calendar/Events/CalendarEvent.css'; } +.unaired { + composes: unaired from '~Calendar/Events/CalendarEvent.css'; +} + @media only screen and (max-width: $breakpointSmall) { - .event { + .overlay { flex-direction: column; } diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css.d.ts b/frontend/src/Calendar/Agenda/AgendaEvent.css.d.ts new file mode 100644 index 00000000000..288e11824c2 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.css.d.ts @@ -0,0 +1,25 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'absoluteEpisodeNumber': string; + 'date': string; + 'downloaded': string; + 'downloading': string; + 'episodeSeparator': string; + 'episodeTitle': string; + 'event': string; + 'eventWrapper': string; + 'missing': string; + 'onAir': string; + 'overlay': string; + 'premiere': string; + 'seasonEpisodeNumber': string; + 'seriesTitle': string; + 'statusIcon': string; + 'time': string; + 'unaired': string; + 'underlay': string; + 'unmonitored': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js deleted file mode 100644 index 155be19f154..00000000000 --- a/frontend/src/Calendar/Agenda/AgendaEvent.js +++ /dev/null @@ -1,253 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; -import getStatusStyle from 'Calendar/getStatusStyle'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; -import episodeEntities from 'Episode/episodeEntities'; -import { icons, kinds } from 'Helpers/Props'; -import formatTime from 'Utilities/Date/formatTime'; -import padNumber from 'Utilities/Number/padNumber'; -import styles from './AgendaEvent.css'; - -class AgendaEvent extends Component { - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - // - // Listeners - - onPress = () => { - this.setState({ isDetailsModalOpen: true }); - }; - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - series, - episodeFile, - title, - seasonNumber, - episodeNumber, - absoluteEpisodeNumber, - airDateUtc, - monitored, - unverifiedSceneNumbering, - hasFile, - grabbed, - queueItem, - showDate, - showEpisodeInformation, - showFinaleIcon, - showSpecialIcon, - showCutoffUnmetIcon, - timeFormat, - longDateFormat, - colorImpairedMode - } = this.props; - - const startTime = moment(airDateUtc); - const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); - const downloading = !!(queueItem || grabbed); - const isMonitored = series.monitored && monitored; - const statusStyle = getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored); - const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; - const season = series.seasons.find((s) => s.seasonNumber === seasonNumber); - const seasonStatistics = season?.statistics || {}; - - return ( -
- - -
-
- { - showDate && - startTime.format(longDateFormat) - } -
- -
-
- {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} -
- -
- {series.title} -
- - { - showEpisodeInformation && -
- {seasonNumber}x{padNumber(episodeNumber, 2)} - - { - series.seriesType === 'anime' && absoluteEpisodeNumber && - ({absoluteEpisodeNumber}) - } - -
-
-
- } - -
- { - showEpisodeInformation && - title - } -
- - { - missingAbsoluteNumber && - - } - - { - unverifiedSceneNumbering && !missingAbsoluteNumber ? - : - null - } - - { - !!queueItem && - - - - } - - { - !queueItem && grabbed && - - } - - { - showCutoffUnmetIcon && - !!episodeFile && - episodeFile.qualityCutoffNotMet && - - } - - { - episodeNumber === 1 && seasonNumber > 0 && - - } - - { - showFinaleIcon && - episodeNumber !== 1 && - seasonNumber > 0 && - episodeNumber === seasonStatistics.totalEpisodeCount && - - } - - { - showSpecialIcon && - (episodeNumber === 0 || seasonNumber === 0) && - - } -
-
- - -
- ); - } -} - -AgendaEvent.propTypes = { - id: PropTypes.number.isRequired, - series: PropTypes.object.isRequired, - episodeFile: PropTypes.object, - title: PropTypes.string.isRequired, - seasonNumber: PropTypes.number.isRequired, - episodeNumber: PropTypes.number.isRequired, - absoluteEpisodeNumber: PropTypes.number, - airDateUtc: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - unverifiedSceneNumbering: PropTypes.bool, - hasFile: PropTypes.bool.isRequired, - grabbed: PropTypes.bool, - queueItem: PropTypes.object, - showDate: PropTypes.bool.isRequired, - showEpisodeInformation: PropTypes.bool.isRequired, - showFinaleIcon: PropTypes.bool.isRequired, - showSpecialIcon: PropTypes.bool.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - colorImpairedMode: PropTypes.bool.isRequired -}; - -export default AgendaEvent; diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.tsx b/frontend/src/Calendar/Agenda/AgendaEvent.tsx new file mode 100644 index 00000000000..2fd2d7c54ba --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.tsx @@ -0,0 +1,227 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; +import episodeEntities from 'Episode/episodeEntities'; +import getFinaleTypeName from 'Episode/getFinaleTypeName'; +import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; +import { icons, kinds } from 'Helpers/Props'; +import useSeries from 'Series/useSeries'; +import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import translate from 'Utilities/String/translate'; +import styles from './AgendaEvent.css'; + +interface AgendaEventProps { + id: number; + seriesId: number; + episodeFileId: number; + title: string; + seasonNumber: number; + episodeNumber: number; + absoluteEpisodeNumber?: number; + airDateUtc: string; + monitored: boolean; + unverifiedSceneNumbering?: boolean; + finaleType?: string; + hasFile: boolean; + grabbed?: boolean; + showDate: boolean; +} + +function AgendaEvent(props: AgendaEventProps) { + const { + id, + seriesId, + episodeFileId, + title, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + airDateUtc, + monitored, + unverifiedSceneNumbering, + finaleType, + hasFile, + grabbed, + showDate, + } = props; + + const series = useSeries(seriesId)!; + const episodeFile = useEpisodeFile(episodeFileId); + const queueItem = useSelector(createQueueItemSelectorForHook(id)); + const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector( + createUISettingsSelector() + ); + + const { + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + } = useSelector((state: AppState) => state.calendar.options); + + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const startTime = moment(airDateUtc); + const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); + const downloading = !!(queueItem || grabbed); + const isMonitored = series.monitored && monitored; + const statusStyle = getStatusStyle( + hasFile, + downloading, + startTime, + endTime, + isMonitored + ); + const missingAbsoluteNumber = + series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; + + const handlePress = useCallback(() => { + setIsDetailsModalOpen(true); + }, []); + + const handleDetailsModalClose = useCallback(() => { + setIsDetailsModalOpen(false); + }, []); + + return ( +
+ + +
+
+ {showDate && startTime.format(longDateFormat)} +
+ +
+
+ {formatTime(airDateUtc, timeFormat)} -{' '} + {formatTime(endTime.toISOString(), timeFormat, { + includeMinuteZero: true, + })} +
+ +
{series.title}
+ + {showEpisodeInformation ? ( +
+ {seasonNumber}x{padNumber(episodeNumber, 2)} + {series.seriesType === 'anime' && absoluteEpisodeNumber && ( + + ({absoluteEpisodeNumber}) + + )} +
-
+
+ ) : null} + +
+ {showEpisodeInformation ? title : null} +
+ + {missingAbsoluteNumber ? ( + + ) : null} + + {unverifiedSceneNumbering && !missingAbsoluteNumber ? ( + + ) : null} + + {queueItem ? ( + + + + ) : null} + + {!queueItem && grabbed ? ( + + ) : null} + + {showCutoffUnmetIcon && + episodeFile && + episodeFile.qualityCutoffNotMet ? ( + + ) : null} + + {episodeNumber === 1 && seasonNumber > 0 && ( + + )} + + {showFinaleIcon && finaleType ? ( + + ) : null} + + {showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? ( + + ) : null} +
+
+ + +
+ ); +} + +export default AgendaEvent; diff --git a/frontend/src/Calendar/Agenda/AgendaEventConnector.js b/frontend/src/Calendar/Agenda/AgendaEventConnector.js deleted file mode 100644 index d476acf8006..00000000000 --- a/frontend/src/Calendar/Agenda/AgendaEventConnector.js +++ /dev/null @@ -1,30 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; -import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import AgendaEvent from './AgendaEvent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - createSeriesSelector(), - createEpisodeFileSelector(), - createQueueItemSelector(), - createUISettingsSelector(), - (calendarOptions, series, episodeFile, queueItem, uiSettings) => { - return { - series, - episodeFile, - queueItem, - ...calendarOptions, - timeFormat: uiSettings.timeFormat, - longDateFormat: uiSettings.longDateFormat, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(AgendaEvent); diff --git a/frontend/src/Calendar/Calendar.css.d.ts b/frontend/src/Calendar/Calendar.css.d.ts new file mode 100644 index 00000000000..503034402eb --- /dev/null +++ b/frontend/src/Calendar/Calendar.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'calendar': string; + 'calendarContent': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/Calendar.js b/frontend/src/Calendar/Calendar.js deleted file mode 100644 index 734de312154..00000000000 --- a/frontend/src/Calendar/Calendar.js +++ /dev/null @@ -1,64 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import AgendaConnector from './Agenda/AgendaConnector'; -import * as calendarViews from './calendarViews'; -import CalendarDaysConnector from './Day/CalendarDaysConnector'; -import DaysOfWeekConnector from './Day/DaysOfWeekConnector'; -import CalendarHeaderConnector from './Header/CalendarHeaderConnector'; -import styles from './Calendar.css'; - -class Calendar extends Component { - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - view - } = this.props; - - return ( -
- { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && -
Unable to load the calendar
- } - - { - !error && isPopulated && view === calendarViews.AGENDA && -
- - -
- } - - { - !error && isPopulated && view !== calendarViews.AGENDA && -
- - - -
- } -
- ); - } -} - -Calendar.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - view: PropTypes.string.isRequired -}; - -export default Calendar; diff --git a/frontend/src/Calendar/Calendar.tsx b/frontend/src/Calendar/Calendar.tsx new file mode 100644 index 00000000000..caa337cf00c --- /dev/null +++ b/frontend/src/Calendar/Calendar.tsx @@ -0,0 +1,170 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Episode from 'Episode/Episode'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { kinds } from 'Helpers/Props'; +import { + clearCalendar, + fetchCalendar, + gotoCalendarToday, +} from 'Store/Actions/calendarActions'; +import { + clearEpisodeFiles, + fetchEpisodeFiles, +} from 'Store/Actions/episodeFileActions'; +import { + clearQueueDetails, + fetchQueueDetails, +} from 'Store/Actions/queueActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import { + registerPagePopulator, + unregisterPagePopulator, +} from 'Utilities/pagePopulator'; +import translate from 'Utilities/String/translate'; +import Agenda from './Agenda/Agenda'; +import CalendarDays from './Day/CalendarDays'; +import DaysOfWeek from './Day/DaysOfWeek'; +import CalendarHeader from './Header/CalendarHeader'; +import styles from './Calendar.css'; + +const UPDATE_DELAY = 3600000; // 1 hour + +function Calendar() { + const dispatch = useDispatch(); + const requestCurrentPage = useCurrentPage(); + const updateTimeout = useRef>(); + + const { isFetching, isPopulated, error, items, time, view } = useSelector( + (state: AppState) => state.calendar + ); + + const isRefreshingSeries = useSelector( + createCommandExecutingSelector(commandNames.REFRESH_SERIES) + ); + + const firstDayOfWeek = useSelector( + (state: AppState) => state.settings.ui.item.firstDayOfWeek + ); + + const wasRefreshingSeries = usePrevious(isRefreshingSeries); + const previousFirstDayOfWeek = usePrevious(firstDayOfWeek); + const previousItems = usePrevious(items); + + const handleScheduleUpdate = useCallback(() => { + clearTimeout(updateTimeout.current); + + function updateCalendar() { + dispatch(gotoCalendarToday()); + updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY); + } + + updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY); + }, [dispatch]); + + useEffect(() => { + handleScheduleUpdate(); + + return () => { + dispatch(clearCalendar()); + dispatch(clearQueueDetails()); + dispatch(clearEpisodeFiles()); + clearTimeout(updateTimeout.current); + }; + }, [dispatch, handleScheduleUpdate]); + + useEffect(() => { + if (requestCurrentPage) { + dispatch(fetchCalendar()); + } else { + dispatch(gotoCalendarToday()); + } + }, [requestCurrentPage, dispatch]); + + useEffect(() => { + const repopulate = () => { + dispatch(fetchQueueDetails({ time, view })); + dispatch(fetchCalendar({ time, view })); + }; + + registerPagePopulator(repopulate, [ + 'episodeFileUpdated', + 'episodeFileDeleted', + ]); + + return () => { + unregisterPagePopulator(repopulate); + }; + }, [time, view, dispatch]); + + useEffect(() => { + handleScheduleUpdate(); + }, [time, handleScheduleUpdate]); + + useEffect(() => { + if ( + previousFirstDayOfWeek != null && + firstDayOfWeek !== previousFirstDayOfWeek + ) { + dispatch(fetchCalendar({ time, view })); + } + }, [time, view, firstDayOfWeek, previousFirstDayOfWeek, dispatch]); + + useEffect(() => { + if (wasRefreshingSeries && !isRefreshingSeries) { + dispatch(fetchCalendar({ time, view })); + } + }, [time, view, isRefreshingSeries, wasRefreshingSeries, dispatch]); + + useEffect(() => { + if (!previousItems || hasDifferentItems(items, previousItems)) { + const episodeIds = selectUniqueIds(items, 'id'); + const episodeFileIds = selectUniqueIds( + items, + 'episodeFileId' + ); + + if (items.length) { + dispatch(fetchQueueDetails({ episodeIds })); + } + + if (episodeFileIds.length) { + dispatch(fetchEpisodeFiles({ episodeFileIds })); + } + } + }, [items, previousItems, dispatch]); + + return ( +
+ {isFetching && !isPopulated ? : null} + + {!isFetching && error ? ( + {translate('CalendarLoadError')} + ) : null} + + {!error && isPopulated && view === 'agenda' ? ( +
+ + +
+ ) : null} + + {!error && isPopulated && view !== 'agenda' ? ( +
+ + + +
+ ) : null} +
+ ); +} + +export default Calendar; diff --git a/frontend/src/Calendar/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js deleted file mode 100644 index 1c5932219c9..00000000000 --- a/frontend/src/Calendar/CalendarConnector.js +++ /dev/null @@ -1,196 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import * as calendarActions from 'Store/Actions/calendarActions'; -import { clearEpisodeFiles, fetchEpisodeFiles } from 'Store/Actions/episodeFileActions'; -import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import Calendar from './Calendar'; - -const UPDATE_DELAY = 3600000; // 1 hour - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - (state) => state.settings.ui.item.firstDayOfWeek, - createCommandExecutingSelector(commandNames.REFRESH_SERIES), - (calendar, firstDayOfWeek, isRefreshingSeries) => { - return { - ...calendar, - isRefreshingSeries, - firstDayOfWeek - }; - } - ); -} - -const mapDispatchToProps = { - ...calendarActions, - fetchEpisodeFiles, - clearEpisodeFiles, - fetchQueueDetails, - clearQueueDetails -}; - -class CalendarConnector extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.updateTimeoutId = null; - } - - componentDidMount() { - const { - useCurrentPage, - fetchCalendar, - gotoCalendarToday - } = this.props; - - registerPagePopulator(this.repopulate); - - if (useCurrentPage) { - fetchCalendar(); - } else { - gotoCalendarToday(); - } - - this.scheduleUpdate(); - } - - componentDidUpdate(prevProps) { - const { - items, - time, - view, - isRefreshingSeries, - firstDayOfWeek - } = this.props; - - if (hasDifferentItems(prevProps.items, items)) { - const episodeIds = selectUniqueIds(items, 'id'); - const episodeFileIds = selectUniqueIds(items, 'episodeFileId'); - - if (items.length) { - this.props.fetchQueueDetails({ episodeIds }); - } - - if (episodeFileIds.length) { - this.props.fetchEpisodeFiles({ episodeFileIds }); - } - } - - if (prevProps.time !== time) { - this.scheduleUpdate(); - } - - if (prevProps.firstDayOfWeek !== firstDayOfWeek) { - this.props.fetchCalendar({ time, view }); - } - - if (prevProps.isRefreshingSeries && !isRefreshingSeries) { - this.props.fetchCalendar({ time, view }); - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - this.props.clearCalendar(); - this.props.clearQueueDetails(); - this.props.clearEpisodeFiles(); - this.clearUpdateTimeout(); - } - - // - // Control - - repopulate = () => { - const { - time, - view - } = this.props; - - this.props.fetchQueueDetails({ time, view }); - this.props.fetchCalendar({ time, view }); - }; - - scheduleUpdate = () => { - this.clearUpdateTimeout(); - - this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY); - }; - - clearUpdateTimeout = () => { - if (this.updateTimeoutId) { - clearTimeout(this.updateTimeoutId); - } - }; - - updateCalendar = () => { - this.props.gotoCalendarToday(); - this.scheduleUpdate(); - }; - - // - // Listeners - - onCalendarViewChange = (view) => { - this.props.setCalendarView({ view }); - }; - - onTodayPress = () => { - this.props.gotoCalendarToday(); - }; - - onPreviousPress = () => { - this.props.gotoCalendarPreviousRange(); - }; - - onNextPress = () => { - this.props.gotoCalendarNextRange(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -CalendarConnector.propTypes = { - useCurrentPage: PropTypes.bool.isRequired, - time: PropTypes.string, - view: PropTypes.string.isRequired, - firstDayOfWeek: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - isRefreshingSeries: PropTypes.bool.isRequired, - setCalendarView: PropTypes.func.isRequired, - gotoCalendarToday: PropTypes.func.isRequired, - gotoCalendarPreviousRange: PropTypes.func.isRequired, - gotoCalendarNextRange: PropTypes.func.isRequired, - clearCalendar: PropTypes.func.isRequired, - fetchCalendar: PropTypes.func.isRequired, - fetchEpisodeFiles: PropTypes.func.isRequired, - clearEpisodeFiles: PropTypes.func.isRequired, - fetchQueueDetails: PropTypes.func.isRequired, - clearQueueDetails: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector); diff --git a/frontend/src/Calendar/CalendarFilterModal.tsx b/frontend/src/Calendar/CalendarFilterModal.tsx new file mode 100644 index 00000000000..e26b2928bb3 --- /dev/null +++ b/frontend/src/Calendar/CalendarFilterModal.tsx @@ -0,0 +1,54 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setCalendarFilter } from 'Store/Actions/calendarActions'; + +function createCalendarSelector() { + return createSelector( + (state: AppState) => state.calendar.items, + (calendar) => { + return calendar; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.calendar.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface CalendarFilterModalProps { + isOpen: boolean; +} + +export default function CalendarFilterModal(props: CalendarFilterModalProps) { + const sectionItems = useSelector(createCalendarSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'calendar'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setCalendarFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Calendar/CalendarPage.css.d.ts b/frontend/src/Calendar/CalendarPage.css.d.ts new file mode 100644 index 00000000000..30befba5530 --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'calendarInnerPageBody': string; + 'calendarPageBody': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js deleted file mode 100644 index 7b5a987a79b..00000000000 --- a/frontend/src/Calendar/CalendarPage.js +++ /dev/null @@ -1,192 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Measure from 'Components/Measure'; -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 PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; -import { align, icons } from 'Helpers/Props'; -import NoSeries from 'Series/NoSeries'; -import CalendarConnector from './CalendarConnector'; -import CalendarLinkModal from './iCal/CalendarLinkModal'; -import LegendConnector from './Legend/LegendConnector'; -import CalendarOptionsModal from './Options/CalendarOptionsModal'; -import styles from './CalendarPage.css'; - -const MINIMUM_DAY_WIDTH = 120; - -class CalendarPage extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isCalendarLinkModalOpen: false, - isOptionsModalOpen: false, - width: 0 - }; - } - - // - // Listeners - - onMeasure = ({ width }) => { - this.setState({ width }); - const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH))); - - this.props.onDaysCountChange(days); - }; - - onGetCalendarLinkPress = () => { - this.setState({ isCalendarLinkModalOpen: true }); - }; - - onGetCalendarLinkModalClose = () => { - this.setState({ isCalendarLinkModalOpen: false }); - }; - - onOptionsPress = () => { - this.setState({ isOptionsModalOpen: true }); - }; - - onOptionsModalClose = () => { - this.setState({ isOptionsModalOpen: false }); - }; - - onSearchMissingPress = () => { - const { - missingEpisodeIds, - onSearchMissingPress - } = this.props; - - onSearchMissingPress(missingEpisodeIds); - }; - - // - // Render - - render() { - const { - selectedFilterKey, - filters, - hasSeries, - missingEpisodeIds, - isRssSyncExecuting, - isSearchingForMissing, - useCurrentPage, - onRssSyncPress, - onFilterSelect - } = this.props; - - const { - isCalendarLinkModalOpen, - isOptionsModalOpen - } = this.state; - - const isMeasured = this.state.width > 0; - const PageComponent = hasSeries ? CalendarConnector : NoSeries; - - return ( - - - - - - - - - - - - - - - - - - - - - - { - isMeasured ? - : -
- } - - - { - hasSeries && - - } - - - - - - - ); - } -} - -CalendarPage.propTypes = { - selectedFilterKey: PropTypes.string.isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - hasSeries: PropTypes.bool.isRequired, - missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired, - isRssSyncExecuting: PropTypes.bool.isRequired, - isSearchingForMissing: PropTypes.bool.isRequired, - useCurrentPage: PropTypes.bool.isRequired, - onSearchMissingPress: PropTypes.func.isRequired, - onDaysCountChange: PropTypes.func.isRequired, - onRssSyncPress: PropTypes.func.isRequired, - onFilterSelect: PropTypes.func.isRequired -}; - -export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPage.tsx b/frontend/src/Calendar/CalendarPage.tsx new file mode 100644 index 00000000000..f408b6a60c0 --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.tsx @@ -0,0 +1,226 @@ +import moment from 'moment'; +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Measure from 'Components/Measure'; +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 PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import { align, icons } from 'Helpers/Props'; +import NoSeries from 'Series/NoSeries'; +import { + searchMissing, + setCalendarDaysCount, + setCalendarFilter, +} from 'Store/Actions/calendarActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector'; +import { isCommandExecuting } from 'Utilities/Command'; +import isBefore from 'Utilities/Date/isBefore'; +import translate from 'Utilities/String/translate'; +import Calendar from './Calendar'; +import CalendarFilterModal from './CalendarFilterModal'; +import CalendarLinkModal from './iCal/CalendarLinkModal'; +import Legend from './Legend/Legend'; +import CalendarOptionsModal from './Options/CalendarOptionsModal'; +import styles from './CalendarPage.css'; + +const MINIMUM_DAY_WIDTH = 120; + +function createMissingEpisodeIdsSelector() { + return createSelector( + (state: AppState) => state.calendar.start, + (state: AppState) => state.calendar.end, + (state: AppState) => state.calendar.items, + (state: AppState) => state.queue.details.items, + (start, end, episodes, queueDetails) => { + return episodes.reduce((acc, episode) => { + const airDateUtc = episode.airDateUtc; + + if ( + !episode.episodeFileId && + moment(airDateUtc).isAfter(start) && + moment(airDateUtc).isBefore(end) && + isBefore(episode.airDateUtc) && + !queueDetails.some( + (details) => !!details.episode && details.episode.id === episode.id + ) + ) { + acc.push(episode.id); + } + + return acc; + }, []); + } + ); +} + +function createIsSearchingSelector() { + return createSelector( + (state: AppState) => state.calendar.searchMissingCommandId, + createCommandsSelector(), + (searchMissingCommandId, commands) => { + if (searchMissingCommandId == null) { + return false; + } + + return isCommandExecuting( + commands.find((command) => { + return command.id === searchMissingCommandId; + }) + ); + } + ); +} + +function CalendarPage() { + const dispatch = useDispatch(); + + const { selectedFilterKey, filters } = useSelector( + (state: AppState) => state.calendar + ); + const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector()); + const isSearchingForMissing = useSelector(createIsSearchingSelector()); + const isRssSyncExecuting = useSelector( + createCommandExecutingSelector(commandNames.RSS_SYNC) + ); + const customFilters = useSelector(createCustomFiltersSelector('calendar')); + const hasSeries = !!useSelector(createSeriesCountSelector()); + + const [isCalendarLinkModalOpen, setIsCalendarLinkModalOpen] = useState(false); + const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); + const [width, setWidth] = useState(0); + + const isMeasured = width > 0; + const PageComponent = hasSeries ? Calendar : NoSeries; + + const handleMeasure = useCallback( + ({ width: newWidth }: { width: number }) => { + setWidth(newWidth); + + const dayCount = Math.max( + 3, + Math.min(7, Math.floor(newWidth / MINIMUM_DAY_WIDTH)) + ); + + dispatch(setCalendarDaysCount({ dayCount })); + }, + [dispatch] + ); + + const handleGetCalendarLinkPress = useCallback(() => { + setIsCalendarLinkModalOpen(true); + }, []); + + const handleGetCalendarLinkModalClose = useCallback(() => { + setIsCalendarLinkModalOpen(false); + }, []); + + const handleOptionsPress = useCallback(() => { + setIsOptionsModalOpen(true); + }, []); + + const handleOptionsModalClose = useCallback(() => { + setIsOptionsModalOpen(false); + }, []); + + const handleRssSyncPress = useCallback(() => { + dispatch( + executeCommand({ + name: commandNames.RSS_SYNC, + }) + ); + }, [dispatch]); + + const handleSearchMissingPress = useCallback(() => { + dispatch(searchMissing({ episodeIds: missingEpisodeIds })); + }, [missingEpisodeIds, dispatch]); + + const handleFilterSelect = useCallback( + (key: string) => { + dispatch(setCalendarFilter({ selectedFilterKey: key })); + }, + [dispatch] + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + {isMeasured ? :
} + + + {hasSeries && } + + + + + + + ); +} + +export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js deleted file mode 100644 index 350377c5605..00000000000 --- a/frontend/src/Calendar/CalendarPageConnector.js +++ /dev/null @@ -1,113 +0,0 @@ -import moment from 'moment'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import withCurrentPage from 'Components/withCurrentPage'; -import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; -import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import { isCommandExecuting } from 'Utilities/Command'; -import isBefore from 'Utilities/Date/isBefore'; -import CalendarPage from './CalendarPage'; - -function createMissingEpisodeIdsSelector() { - return createSelector( - (state) => state.calendar.start, - (state) => state.calendar.end, - (state) => state.calendar.items, - (state) => state.queue.details.items, - (start, end, episodes, queueDetails) => { - return episodes.reduce((acc, episode) => { - const airDateUtc = episode.airDateUtc; - - if ( - !episode.episodeFileId && - moment(airDateUtc).isAfter(start) && - moment(airDateUtc).isBefore(end) && - isBefore(episode.airDateUtc) && - !queueDetails.some((details) => !!details.episode && details.episode.id === episode.id) - ) { - acc.push(episode.id); - } - - return acc; - }, []); - } - ); -} - -function createIsSearchingSelector() { - return createSelector( - (state) => state.calendar.searchMissingCommandId, - createCommandsSelector(), - (searchMissingCommandId, commands) => { - if (searchMissingCommandId == null) { - return false; - } - - return isCommandExecuting(commands.find((command) => { - return command.id === searchMissingCommandId; - })); - } - ); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.selectedFilterKey, - (state) => state.calendar.filters, - createSeriesCountSelector(), - createUISettingsSelector(), - createMissingEpisodeIdsSelector(), - createCommandExecutingSelector(commandNames.RSS_SYNC), - createIsSearchingSelector(), - ( - selectedFilterKey, - filters, - seriesCount, - uiSettings, - missingEpisodeIds, - isRssSyncExecuting, - isSearchingForMissing - ) => { - return { - selectedFilterKey, - filters, - colorImpairedMode: uiSettings.enableColorImpairedMode, - hasSeries: !!seriesCount, - missingEpisodeIds, - isRssSyncExecuting, - isSearchingForMissing - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onRssSyncPress() { - dispatch(executeCommand({ - name: commandNames.RSS_SYNC - })); - }, - - onSearchMissingPress(episodeIds) { - dispatch(searchMissing({ episodeIds })); - }, - - onDaysCountChange(dayCount) { - dispatch(setCalendarDaysCount({ dayCount })); - }, - - onFilterSelect(selectedFilterKey) { - dispatch(setCalendarFilter({ selectedFilterKey })); - } - }; -} - -export default withCurrentPage( - connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage) -); diff --git a/frontend/src/Calendar/Day/CalendarDay.css.d.ts b/frontend/src/Calendar/Day/CalendarDay.css.d.ts new file mode 100644 index 00000000000..f32def3dd07 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDay.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'day': string; + 'dayOfMonth': string; + 'isDifferentMonth': string; + 'isSingleDay': string; + 'isToday': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/Day/CalendarDay.js b/frontend/src/Calendar/Day/CalendarDay.js deleted file mode 100644 index b3da227dd4f..00000000000 --- a/frontend/src/Calendar/Day/CalendarDay.js +++ /dev/null @@ -1,74 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; -import CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector'; -import styles from './CalendarDay.css'; - -function CalendarDay(props) { - const { - date, - time, - isTodaysDate, - events, - view, - onEventModalOpenToggle - } = props; - - return ( -
- { - view === calendarViews.MONTH && -
- {moment(date).date()} -
- } -
- { - events.map((event) => { - if (event.isGroup) { - return ( - - ); - } - - return ( - - ); - }) - } -
-
- ); -} - -CalendarDay.propTypes = { - date: PropTypes.string.isRequired, - time: PropTypes.string.isRequired, - isTodaysDate: PropTypes.bool.isRequired, - events: PropTypes.arrayOf(PropTypes.object).isRequired, - view: PropTypes.string.isRequired, - onEventModalOpenToggle: PropTypes.func.isRequired -}; - -export default CalendarDay; diff --git a/frontend/src/Calendar/Day/CalendarDay.tsx b/frontend/src/Calendar/Day/CalendarDay.tsx new file mode 100644 index 00000000000..a619109ca01 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDay.tsx @@ -0,0 +1,159 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import * as calendarViews from 'Calendar/calendarViews'; +import CalendarEvent from 'Calendar/Events/CalendarEvent'; +import CalendarEventGroup from 'Calendar/Events/CalendarEventGroup'; +import { + CalendarEvent as CalendarEventModel, + CalendarEventGroup as CalendarEventGroupModel, + CalendarItem, +} from 'typings/Calendar'; +import styles from './CalendarDay.css'; + +function sort(items: (CalendarEventModel | CalendarEventGroupModel)[]) { + return items.sort((a, b) => { + const aDate = a.isGroup + ? moment(a.events[0].airDateUtc).unix() + : moment(a.airDateUtc).unix(); + + const bDate = b.isGroup + ? moment(b.events[0].airDateUtc).unix() + : moment(b.airDateUtc).unix(); + + return aDate - bDate; + }); +} + +function createCalendarEventsConnector(date: string) { + return createSelector( + (state: AppState) => state.calendar.items, + (state: AppState) => state.calendar.options.collapseMultipleEpisodes, + (items, collapseMultipleEpisodes) => { + const momentDate = moment(date); + + const filtered = items.filter((item) => { + return momentDate.isSame(moment(item.airDateUtc), 'day'); + }); + + if (!collapseMultipleEpisodes) { + return sort( + filtered.map((item) => ({ + isGroup: false, + ...item, + })) + ); + } + + const groupedObject = Object.groupBy( + filtered, + (item: CalendarItem) => `${item.seriesId}-${item.seasonNumber}` + ); + + const grouped = Object.entries(groupedObject).reduce< + (CalendarEventModel | CalendarEventGroupModel)[] + >((acc, [, events]) => { + if (!events) { + return acc; + } + + if (events.length === 1) { + acc.push({ + isGroup: false, + ...events[0], + }); + } else { + acc.push({ + isGroup: true, + seriesId: events[0].seriesId, + seasonNumber: events[0].seasonNumber, + episodeIds: events.map((event) => event.id), + events: events.sort( + (a, b) => + moment(a.airDateUtc).unix() - moment(b.airDateUtc).unix() + ), + }); + } + + return acc; + }, []); + + return sort(grouped); + } + ); +} + +interface CalendarDayProps { + date: string; + isTodaysDate: boolean; + onEventModalOpenToggle(isOpen: boolean): unknown; +} + +function CalendarDay({ + date, + isTodaysDate, + onEventModalOpenToggle, +}: CalendarDayProps) { + const { time, view } = useSelector((state: AppState) => state.calendar); + const events = useSelector(createCalendarEventsConnector(date)); + + const ref = React.useRef(null); + + React.useEffect(() => { + if (isTodaysDate && view === calendarViews.MONTH && ref.current) { + ref.current.scrollIntoView(); + } + }, [time, isTodaysDate, view]); + + return ( +
+ {view === calendarViews.MONTH && ( +
+ {moment(date).date()} +
+ )} +
+ {events.map((event) => { + if (event.isGroup) { + return ( + + ); + } + + return ( + + ); + })} +
+
+ ); +} + +export default CalendarDay; diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js deleted file mode 100644 index 8fd6cc5a147..00000000000 --- a/frontend/src/Calendar/Day/CalendarDayConnector.js +++ /dev/null @@ -1,91 +0,0 @@ -import _ from 'lodash'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import CalendarDay from './CalendarDay'; - -function sort(items) { - return _.sortBy(items, (item) => { - if (item.isGroup) { - return moment(item.events[0].airDateUtc).unix(); - } - - return moment(item.airDateUtc).unix(); - }); -} - -function createCalendarEventsConnector() { - return createSelector( - (state, { date }) => date, - (state) => state.calendar.items, - (state) => state.calendar.options.collapseMultipleEpisodes, - (date, items, collapseMultipleEpisodes) => { - const filtered = _.filter(items, (item) => { - return moment(date).isSame(moment(item.airDateUtc), 'day'); - }); - - if (!collapseMultipleEpisodes) { - return sort(filtered); - } - - const groupedObject = _.groupBy(filtered, (item) => `${item.seriesId}-${item.seasonNumber}`); - const grouped = []; - - Object.keys(groupedObject).forEach((key) => { - const events = groupedObject[key]; - - if (events.length === 1) { - grouped.push(events[0]); - } else { - grouped.push({ - isGroup: true, - seriesId: events[0].seriesId, - seasonNumber: events[0].seasonNumber, - episodeIds: events.map((event) => event.id), - events: _.sortBy(events, (item) => moment(item.airDateUtc).unix()) - }); - } - }); - - const sorted = sort(grouped); - - return sorted; - } - ); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - createCalendarEventsConnector(), - (calendar, events) => { - return { - time: calendar.time, - view: calendar.view, - events - }; - } - ); -} - -class CalendarDayConnector extends Component { - - // - // Render - - render() { - return ( - - ); - } -} - -CalendarDayConnector.propTypes = { - date: PropTypes.string.isRequired -}; - -export default connect(createMapStateToProps)(CalendarDayConnector); diff --git a/frontend/src/Calendar/Day/CalendarDays.css.d.ts b/frontend/src/Calendar/Day/CalendarDays.css.d.ts new file mode 100644 index 00000000000..ae3e7aebc0b --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'day': string; + 'days': string; + 'forecast': string; + 'month': string; + 'week': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/Day/CalendarDays.js b/frontend/src/Calendar/Day/CalendarDays.js deleted file mode 100644 index f2bb4c8d456..00000000000 --- a/frontend/src/Calendar/Day/CalendarDays.js +++ /dev/null @@ -1,164 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import isToday from 'Utilities/Date/isToday'; -import CalendarDayConnector from './CalendarDayConnector'; -import styles from './CalendarDays.css'; - -class CalendarDays extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._touchStart = null; - - this.state = { - todaysDate: moment().startOf('day').toISOString(), - isEventModalOpen: false - }; - - this.updateTimeoutId = null; - } - - // Lifecycle - - componentDidMount() { - const view = this.props.view; - - if (view === calendarViews.MONTH) { - this.scheduleUpdate(); - } - - window.addEventListener('touchstart', this.onTouchStart); - window.addEventListener('touchend', this.onTouchEnd); - window.addEventListener('touchcancel', this.onTouchCancel); - window.addEventListener('touchmove', this.onTouchMove); - } - - componentWillUnmount() { - this.clearUpdateTimeout(); - - window.removeEventListener('touchstart', this.onTouchStart); - window.removeEventListener('touchend', this.onTouchEnd); - window.removeEventListener('touchcancel', this.onTouchCancel); - window.removeEventListener('touchmove', this.onTouchMove); - } - - // - // Control - - scheduleUpdate = () => { - this.clearUpdateTimeout(); - const todaysDate = moment().startOf('day'); - const diff = moment().diff(todaysDate.clone().add(1, 'day')); - - this.setState({ todaysDate: todaysDate.toISOString() }); - - this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); - }; - - clearUpdateTimeout = () => { - if (this.updateTimeoutId) { - clearTimeout(this.updateTimeoutId); - } - }; - - // - // Listeners - - onEventModalOpenToggle = (isEventModalOpen) => { - this.setState({ isEventModalOpen }); - }; - - onTouchStart = (event) => { - const touches = event.touches; - const touchStart = touches[0].pageX; - - if (touches.length !== 1) { - return; - } - - if ( - touchStart < 50 || - this.props.isSidebarVisible || - this.state.isEventModalOpen - ) { - return; - } - - this._touchStart = touchStart; - }; - - onTouchEnd = (event) => { - const touches = event.changedTouches; - const currentTouch = touches[0].pageX; - - if (!this._touchStart) { - return; - } - - if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) { - this.props.onNavigatePrevious(); - } else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) { - this.props.onNavigateNext(); - } - - this._touchStart = null; - }; - - onTouchCancel = (event) => { - this._touchStart = null; - }; - - onTouchMove = (event) => { - if (!this._touchStart) { - return; - } - }; - - // - // Render - - render() { - const { - dates, - view - } = this.props; - - return ( -
- { - dates.map((date) => { - return ( - - ); - }) - } -
- ); - } -} - -CalendarDays.propTypes = { - dates: PropTypes.arrayOf(PropTypes.string).isRequired, - view: PropTypes.string.isRequired, - isSidebarVisible: PropTypes.bool.isRequired, - onNavigatePrevious: PropTypes.func.isRequired, - onNavigateNext: PropTypes.func.isRequired -}; - -export default CalendarDays; diff --git a/frontend/src/Calendar/Day/CalendarDays.tsx b/frontend/src/Calendar/Day/CalendarDays.tsx new file mode 100644 index 00000000000..149dc145557 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.tsx @@ -0,0 +1,135 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as calendarViews from 'Calendar/calendarViews'; +import { + gotoCalendarNextRange, + gotoCalendarPreviousRange, +} from 'Store/Actions/calendarActions'; +import CalendarDay from './CalendarDay'; +import styles from './CalendarDays.css'; + +function CalendarDays() { + const dispatch = useDispatch(); + const { dates, view } = useSelector((state: AppState) => state.calendar); + const isSidebarVisible = useSelector( + (state: AppState) => state.app.isSidebarVisible + ); + + const updateTimeout = useRef>(); + const touchStart = useRef(null); + const isEventModalOpen = useRef(false); + const [todaysDate, setTodaysDate] = useState( + moment().startOf('day').toISOString() + ); + + const handleEventModalOpenToggle = useCallback((isOpen: boolean) => { + isEventModalOpen.current = isOpen; + }, []); + + const scheduleUpdate = useCallback(() => { + clearTimeout(updateTimeout.current); + + const todaysDate = moment().startOf('day'); + const diff = moment().diff(todaysDate.clone().add(1, 'day')); + + setTodaysDate(todaysDate.toISOString()); + + updateTimeout.current = setTimeout(scheduleUpdate, diff); + }, []); + + const handleTouchStart = useCallback( + (event: TouchEvent) => { + const touches = event.touches; + const currentTouch = touches[0].pageX; + + if (touches.length !== 1) { + return; + } + + if (currentTouch < 50 || isSidebarVisible || isEventModalOpen.current) { + return; + } + + touchStart.current = currentTouch; + }, + [isSidebarVisible] + ); + + const handleTouchEnd = useCallback( + (event: TouchEvent) => { + const touches = event.changedTouches; + const currentTouch = touches[0].pageX; + + if (!touchStart.current) { + return; + } + + if ( + currentTouch > touchStart.current && + currentTouch - touchStart.current > 100 + ) { + dispatch(gotoCalendarPreviousRange()); + } else if ( + currentTouch < touchStart.current && + touchStart.current - currentTouch > 100 + ) { + dispatch(gotoCalendarNextRange()); + } + + touchStart.current = null; + }, + [dispatch] + ); + + const handleTouchCancel = useCallback(() => { + touchStart.current = null; + }, []); + + const handleTouchMove = useCallback(() => { + if (!touchStart.current) { + return; + } + }, []); + + useEffect(() => { + if (view === calendarViews.MONTH) { + scheduleUpdate(); + } + }, [view, scheduleUpdate]); + + useEffect(() => { + window.addEventListener('touchstart', handleTouchStart); + window.addEventListener('touchend', handleTouchEnd); + window.addEventListener('touchcancel', handleTouchCancel); + window.addEventListener('touchmove', handleTouchMove); + + return () => { + window.removeEventListener('touchstart', handleTouchStart); + window.removeEventListener('touchend', handleTouchEnd); + window.removeEventListener('touchcancel', handleTouchCancel); + window.removeEventListener('touchmove', handleTouchMove); + }; + }, [handleTouchStart, handleTouchEnd, handleTouchCancel, handleTouchMove]); + + return ( +
+ {dates.map((date) => { + return ( + + ); + })} +
+ ); +} + +export default CalendarDays; diff --git a/frontend/src/Calendar/Day/CalendarDaysConnector.js b/frontend/src/Calendar/Day/CalendarDaysConnector.js deleted file mode 100644 index 0acce70b909..00000000000 --- a/frontend/src/Calendar/Day/CalendarDaysConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { gotoCalendarNextRange, gotoCalendarPreviousRange } from 'Store/Actions/calendarActions'; -import CalendarDays from './CalendarDays'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - (state) => state.app.isSidebarVisible, - (calendar, isSidebarVisible) => { - return { - dates: calendar.dates, - view: calendar.view, - isSidebarVisible - }; - } - ); -} - -const mapDispatchToProps = { - onNavigatePrevious: gotoCalendarPreviousRange, - onNavigateNext: gotoCalendarNextRange -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays); diff --git a/frontend/src/Calendar/Day/DayOfWeek.css b/frontend/src/Calendar/Day/DayOfWeek.css index bb73fa3ba87..2d31a30be0c 100644 --- a/frontend/src/Calendar/Day/DayOfWeek.css +++ b/frontend/src/Calendar/Day/DayOfWeek.css @@ -1,6 +1,6 @@ .dayOfWeek { flex: 1 0 14.28%; - background-color: var(--calendarBackgroudColor); + background-color: var(--calendarBackgroundColor); text-align: center; } diff --git a/frontend/src/Calendar/Day/DayOfWeek.css.d.ts b/frontend/src/Calendar/Day/DayOfWeek.css.d.ts new file mode 100644 index 00000000000..a377e4a8e19 --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'dayOfWeek': string; + 'isSingleDay': string; + 'isToday': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/Day/DayOfWeek.js b/frontend/src/Calendar/Day/DayOfWeek.js deleted file mode 100644 index 39e40fce85f..00000000000 --- a/frontend/src/Calendar/Day/DayOfWeek.js +++ /dev/null @@ -1,56 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import styles from './DayOfWeek.css'; - -class DayOfWeek extends Component { - - // - // Render - - render() { - const { - date, - view, - isTodaysDate, - calendarWeekColumnHeader, - shortDateFormat, - showRelativeDates - } = this.props; - - const highlightToday = view !== calendarViews.MONTH && isTodaysDate; - const momentDate = moment(date); - let formatedDate = momentDate.format('dddd'); - - if (view === calendarViews.WEEK) { - formatedDate = momentDate.format(calendarWeekColumnHeader); - } else if (view === calendarViews.FORECAST) { - formatedDate = getRelativeDate(date, shortDateFormat, showRelativeDates); - } - - return ( -
- {formatedDate} -
- ); - } -} - -DayOfWeek.propTypes = { - date: PropTypes.string.isRequired, - view: PropTypes.string.isRequired, - isTodaysDate: PropTypes.bool.isRequired, - calendarWeekColumnHeader: PropTypes.string.isRequired, - shortDateFormat: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired -}; - -export default DayOfWeek; diff --git a/frontend/src/Calendar/Day/DayOfWeek.tsx b/frontend/src/Calendar/Day/DayOfWeek.tsx new file mode 100644 index 00000000000..c8b493b7c9a --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.tsx @@ -0,0 +1,54 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React from 'react'; +import * as calendarViews from 'Calendar/calendarViews'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import styles from './DayOfWeek.css'; + +interface DayOfWeekProps { + date: string; + view: string; + isTodaysDate: boolean; + calendarWeekColumnHeader: string; + shortDateFormat: string; + showRelativeDates: boolean; +} + +function DayOfWeek(props: DayOfWeekProps) { + const { + date, + view, + isTodaysDate, + calendarWeekColumnHeader, + shortDateFormat, + showRelativeDates, + } = props; + + const highlightToday = view !== calendarViews.MONTH && isTodaysDate; + const momentDate = moment(date); + let formatedDate = momentDate.format('dddd'); + + if (view === calendarViews.WEEK) { + formatedDate = momentDate.format(calendarWeekColumnHeader); + } else if (view === calendarViews.FORECAST) { + formatedDate = getRelativeDate({ + date, + shortDateFormat, + showRelativeDates, + }); + } + + return ( +
+ {formatedDate} +
+ ); +} + +export default DayOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.css.d.ts b/frontend/src/Calendar/Day/DaysOfWeek.css.d.ts new file mode 100644 index 00000000000..5bc224b68e9 --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'daysOfWeek': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.js b/frontend/src/Calendar/Day/DaysOfWeek.js deleted file mode 100644 index 9f94b1079d1..00000000000 --- a/frontend/src/Calendar/Day/DaysOfWeek.js +++ /dev/null @@ -1,97 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import DayOfWeek from './DayOfWeek'; -import styles from './DaysOfWeek.css'; - -class DaysOfWeek extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - todaysDate: moment().startOf('day').toISOString() - }; - - this.updateTimeoutId = null; - } - - // Lifecycle - - componentDidMount() { - const view = this.props.view; - - if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) { - this.scheduleUpdate(); - } - } - - componentWillUnmount() { - this.clearUpdateTimeout(); - } - - // - // Control - - scheduleUpdate = () => { - this.clearUpdateTimeout(); - const todaysDate = moment().startOf('day'); - const diff = todaysDate.clone().add(1, 'day').diff(moment()); - - this.setState({ - todaysDate: todaysDate.toISOString() - }); - - this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); - }; - - clearUpdateTimeout = () => { - if (this.updateTimeoutId) { - clearTimeout(this.updateTimeoutId); - } - }; - - // - // Render - - render() { - const { - dates, - view, - ...otherProps - } = this.props; - - if (view === calendarViews.AGENDA) { - return null; - } - - return ( -
- { - dates.map((date) => { - return ( - - ); - }) - } -
- ); - } -} - -DaysOfWeek.propTypes = { - dates: PropTypes.arrayOf(PropTypes.string), - view: PropTypes.string.isRequired -}; - -export default DaysOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.tsx b/frontend/src/Calendar/Day/DaysOfWeek.tsx new file mode 100644 index 00000000000..64bc886ccd3 --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.tsx @@ -0,0 +1,60 @@ +import moment from 'moment'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as calendarViews from 'Calendar/calendarViews'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import DayOfWeek from './DayOfWeek'; +import styles from './DaysOfWeek.css'; + +function DaysOfWeek() { + const { dates, view } = useSelector((state: AppState) => state.calendar); + const { calendarWeekColumnHeader, shortDateFormat, showRelativeDates } = + useSelector(createUISettingsSelector()); + + const updateTimeout = useRef>(); + const [todaysDate, setTodaysDate] = useState( + moment().startOf('day').toISOString() + ); + + const scheduleUpdate = useCallback(() => { + clearTimeout(updateTimeout.current); + + const todaysDate = moment().startOf('day'); + const diff = moment().diff(todaysDate.clone().add(1, 'day')); + + setTodaysDate(todaysDate.toISOString()); + + updateTimeout.current = setTimeout(scheduleUpdate, diff); + }, []); + + useEffect(() => { + if (view !== calendarViews.AGENDA && view !== calendarViews.MONTH) { + scheduleUpdate(); + } + }, [view, scheduleUpdate]); + + if (view === calendarViews.AGENDA) { + return null; + } + + return ( +
+ {dates.map((date) => { + return ( + + ); + })} +
+ ); +} + +export default DaysOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeekConnector.js b/frontend/src/Calendar/Day/DaysOfWeekConnector.js deleted file mode 100644 index 7f5cdef1989..00000000000 --- a/frontend/src/Calendar/Day/DaysOfWeekConnector.js +++ /dev/null @@ -1,22 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import DaysOfWeek from './DaysOfWeek'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - createUISettingsSelector(), - (calendar, UiSettings) => { - return { - dates: calendar.dates.slice(0, 7), - view: calendar.view, - calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader, - shortDateFormat: UiSettings.shortDateFormat, - showRelativeDates: UiSettings.showRelativeDates - }; - } - ); -} - -export default connect(createMapStateToProps)(DaysOfWeek); diff --git a/frontend/src/Calendar/Events/CalendarEvent.css b/frontend/src/Calendar/Events/CalendarEvent.css index 0cf3900bf31..679b4cc515b 100644 --- a/frontend/src/Calendar/Events/CalendarEvent.css +++ b/frontend/src/Calendar/Events/CalendarEvent.css @@ -52,6 +52,10 @@ $fullColorGradient: rgba(244, 245, 246, 0.2); .statusContainer { display: flex; align-items: center; + + &:global(.fullColor) { + filter: var(--calendarFullColorFilter) + } } .statusIcon { diff --git a/frontend/src/Calendar/Events/CalendarEvent.css.d.ts b/frontend/src/Calendar/Events/CalendarEvent.css.d.ts new file mode 100644 index 00000000000..f099df21174 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.css.d.ts @@ -0,0 +1,23 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'absoluteEpisodeNumber': string; + 'airTime': string; + 'downloaded': string; + 'downloading': string; + 'episodeInfo': string; + 'episodeTitle': string; + 'event': string; + 'info': string; + 'missing': string; + 'onAir': string; + 'overlay': string; + 'seriesTitle': string; + 'statusContainer': string; + 'statusIcon': string; + 'unaired': string; + 'underlay': string; + 'unmonitored': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js deleted file mode 100644 index 81d2beaf421..00000000000 --- a/frontend/src/Calendar/Events/CalendarEvent.js +++ /dev/null @@ -1,261 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import getStatusStyle from 'Calendar/getStatusStyle'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; -import episodeEntities from 'Episode/episodeEntities'; -import { icons, kinds } from 'Helpers/Props'; -import formatTime from 'Utilities/Date/formatTime'; -import padNumber from 'Utilities/Number/padNumber'; -import CalendarEventQueueDetails from './CalendarEventQueueDetails'; -import styles from './CalendarEvent.css'; - -class CalendarEvent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - // - // Listeners - - onPress = () => { - this.setState({ isDetailsModalOpen: true }, () => { - this.props.onEventModalOpenToggle(true); - }); - }; - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }, () => { - this.props.onEventModalOpenToggle(false); - }); - }; - - // - // Render - - render() { - const { - id, - series, - episodeFile, - title, - seasonNumber, - episodeNumber, - absoluteEpisodeNumber, - airDateUtc, - monitored, - unverifiedSceneNumbering, - hasFile, - grabbed, - queueItem, - showEpisodeInformation, - showFinaleIcon, - showSpecialIcon, - showCutoffUnmetIcon, - fullColorEvents, - timeFormat, - colorImpairedMode - } = this.props; - - if (!series) { - return null; - } - - const startTime = moment(airDateUtc); - const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); - const isDownloading = !!(queueItem || grabbed); - const isMonitored = series.monitored && monitored; - const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, endTime, isMonitored); - const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; - const season = series.seasons.find((s) => s.seasonNumber === seasonNumber); - const seasonStatistics = season?.statistics || {}; - - return ( -
- - -
-
-
- {series.title} -
- -
- { - missingAbsoluteNumber ? - : - null - } - - { - unverifiedSceneNumbering && !missingAbsoluteNumber ? - : - null - } - - { - queueItem ? - - - : - null - } - - { - !queueItem && grabbed ? - : - null - } - - { - showCutoffUnmetIcon && - !!episodeFile && - episodeFile.qualityCutoffNotMet ? - : - null - } - - { - episodeNumber === 1 && seasonNumber > 0 ? - : - null - } - - { - showFinaleIcon && - episodeNumber !== 1 && - seasonNumber > 0 && - episodeNumber === seasonStatistics.totalEpisodeCount ? - : - null - } - - { - showSpecialIcon && - (episodeNumber === 0 || seasonNumber === 0) ? - : - null - } -
-
- - { - showEpisodeInformation ? -
-
- {title} -
- -
- {seasonNumber}x{padNumber(episodeNumber, 2)} - - { - series.seriesType === 'anime' && absoluteEpisodeNumber ? - ({absoluteEpisodeNumber}) : null - } -
-
: - null - } - -
- {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} -
-
- - -
- ); - } -} - -CalendarEvent.propTypes = { - id: PropTypes.number.isRequired, - series: PropTypes.object.isRequired, - episodeFile: PropTypes.object, - title: PropTypes.string.isRequired, - seasonNumber: PropTypes.number.isRequired, - episodeNumber: PropTypes.number.isRequired, - absoluteEpisodeNumber: PropTypes.number, - airDateUtc: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - unverifiedSceneNumbering: PropTypes.bool, - hasFile: PropTypes.bool.isRequired, - grabbed: PropTypes.bool, - queueItem: PropTypes.object, - showEpisodeInformation: PropTypes.bool.isRequired, - showFinaleIcon: PropTypes.bool.isRequired, - showSpecialIcon: PropTypes.bool.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired, - colorImpairedMode: PropTypes.bool.isRequired, - onEventModalOpenToggle: PropTypes.func.isRequired -}; - -export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEvent.tsx b/frontend/src/Calendar/Events/CalendarEvent.tsx new file mode 100644 index 00000000000..079256a0e83 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.tsx @@ -0,0 +1,240 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; +import episodeEntities from 'Episode/episodeEntities'; +import getFinaleTypeName from 'Episode/getFinaleTypeName'; +import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; +import { icons, kinds } from 'Helpers/Props'; +import useSeries from 'Series/useSeries'; +import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import translate from 'Utilities/String/translate'; +import CalendarEventQueueDetails from './CalendarEventQueueDetails'; +import styles from './CalendarEvent.css'; + +interface CalendarEventProps { + id: number; + episodeId: number; + seriesId: number; + episodeFileId?: number; + title: string; + seasonNumber: number; + episodeNumber: number; + absoluteEpisodeNumber?: number; + airDateUtc: string; + monitored: boolean; + unverifiedSceneNumbering?: boolean; + finaleType?: string; + hasFile: boolean; + grabbed?: boolean; + onEventModalOpenToggle: (isOpen: boolean) => void; +} + +function CalendarEvent(props: CalendarEventProps) { + const { + id, + seriesId, + episodeFileId, + title, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + airDateUtc, + monitored, + unverifiedSceneNumbering, + finaleType, + hasFile, + grabbed, + onEventModalOpenToggle, + } = props; + + const series = useSeries(seriesId); + const episodeFile = useEpisodeFile(episodeFileId); + const queueItem = useSelector(createQueueItemSelectorForHook(id)); + + const { timeFormat, enableColorImpairedMode } = useSelector( + createUISettingsSelector() + ); + + const { + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + fullColorEvents, + } = useSelector((state: AppState) => state.calendar.options); + + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const handlePress = useCallback(() => { + setIsDetailsModalOpen(true); + onEventModalOpenToggle(true); + }, [onEventModalOpenToggle]); + + const handleDetailsModalClose = useCallback(() => { + setIsDetailsModalOpen(false); + onEventModalOpenToggle(false); + }, [onEventModalOpenToggle]); + + if (!series) { + return null; + } + + const startTime = moment(airDateUtc); + const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); + const isDownloading = !!(queueItem || grabbed); + const isMonitored = series.monitored && monitored; + const statusStyle = getStatusStyle( + hasFile, + isDownloading, + startTime, + endTime, + isMonitored + ); + const missingAbsoluteNumber = + series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; + + return ( +
+ + +
+
+
{series.title}
+ +
+ {missingAbsoluteNumber ? ( + + ) : null} + + {unverifiedSceneNumbering && !missingAbsoluteNumber ? ( + + ) : null} + + {queueItem ? ( + + + + ) : null} + + {!queueItem && grabbed ? ( + + ) : null} + + {showCutoffUnmetIcon && + !!episodeFile && + episodeFile.qualityCutoffNotMet ? ( + + ) : null} + + {episodeNumber === 1 && seasonNumber > 0 ? ( + + ) : null} + + {showFinaleIcon && finaleType ? ( + + ) : null} + + {showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? ( + + ) : null} +
+
+ + {showEpisodeInformation ? ( +
+
{title}
+ +
+ {seasonNumber}x{padNumber(episodeNumber, 2)} + {series.seriesType === 'anime' && absoluteEpisodeNumber ? ( + + ({absoluteEpisodeNumber}) + + ) : null} +
+
+ ) : null} + +
+ {formatTime(airDateUtc, timeFormat)} -{' '} + {formatTime(endTime.toISOString(), timeFormat, { + includeMinuteZero: true, + })} +
+
+ + +
+ ); +} + +export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEventConnector.js b/frontend/src/Calendar/Events/CalendarEventConnector.js deleted file mode 100644 index e1ac2096d01..00000000000 --- a/frontend/src/Calendar/Events/CalendarEventConnector.js +++ /dev/null @@ -1,29 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; -import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import CalendarEvent from './CalendarEvent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - createSeriesSelector(), - createEpisodeFileSelector(), - createQueueItemSelector(), - createUISettingsSelector(), - (calendarOptions, series, episodeFile, queueItem, uiSettings) => { - return { - series, - episodeFile, - queueItem, - ...calendarOptions, - timeFormat: uiSettings.timeFormat, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(CalendarEvent); diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.css b/frontend/src/Calendar/Events/CalendarEventGroup.css index 68a12851d96..990d994ec92 100644 --- a/frontend/src/Calendar/Events/CalendarEventGroup.css +++ b/frontend/src/Calendar/Events/CalendarEventGroup.css @@ -43,6 +43,7 @@ .expandContainer, .collapseContainer { display: flex; + align-items: center; justify-content: center; } @@ -50,6 +51,15 @@ margin-bottom: 5px; } +.statusContainer { + display: flex; + align-items: center; + + &:global(.fullColor) { + filter: var(--calendarFullColorFilter) + } +} + .statusIcon { margin-left: 3px; } diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.css.d.ts b/frontend/src/Calendar/Events/CalendarEventGroup.css.d.ts new file mode 100644 index 00000000000..c527feff1f8 --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroup.css.d.ts @@ -0,0 +1,25 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'absoluteEpisodeNumber': string; + 'airTime': string; + 'airingInfo': string; + 'collapseContainer': string; + 'downloaded': string; + 'downloading': string; + 'episodeInfo': string; + 'eventGroup': string; + 'expandContainer': string; + 'expandContainerInline': string; + 'info': string; + 'missing': string; + 'onAir': string; + 'premiere': string; + 'seriesTitle': string; + 'statusContainer': string; + 'statusIcon': string; + 'unaired': string; + 'unmonitored': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.js b/frontend/src/Calendar/Events/CalendarEventGroup.js deleted file mode 100644 index e62232e2069..00000000000 --- a/frontend/src/Calendar/Events/CalendarEventGroup.js +++ /dev/null @@ -1,249 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; -import getStatusStyle from 'Calendar/getStatusStyle'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import { icons, kinds } from 'Helpers/Props'; -import formatTime from 'Utilities/Date/formatTime'; -import padNumber from 'Utilities/Number/padNumber'; -import styles from './CalendarEventGroup.css'; - -function getEventsInfo(series, events) { - let files = 0; - let queued = 0; - let monitored = 0; - let absoluteEpisodeNumbers = 0; - - events.forEach((event) => { - if (event.episodeFileId) { - files++; - } - - if (event.queued) { - queued++; - } - - if (series.monitored && event.monitored) { - monitored++; - } - - if (event.absoluteEpisodeNumber) { - absoluteEpisodeNumbers++; - } - }); - - return { - allDownloaded: files === events.length, - anyQueued: queued > 0, - anyMonitored: monitored > 0, - allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length - }; -} - -class CalendarEventGroup extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isExpanded: false - }; - } - - // - // Listeners - - onExpandPress = () => { - this.setState({ isExpanded: !this.state.isExpanded }); - }; - - // - // Render - - render() { - const { - series, - events, - isDownloading, - showEpisodeInformation, - showFinaleIcon, - timeFormat, - fullColorEvents, - colorImpairedMode, - onEventModalOpenToggle - } = this.props; - - const { isExpanded } = this.state; - const { - allDownloaded, - anyQueued, - anyMonitored, - allAbsoluteEpisodeNumbers - } = getEventsInfo(series, events); - const anyDownloading = isDownloading || anyQueued; - const firstEpisode = events[0]; - const lastEpisode = events[events.length -1]; - const airDateUtc = firstEpisode.airDateUtc; - const startTime = moment(airDateUtc); - const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes'); - const seasonNumber = firstEpisode.seasonNumber; - const statusStyle = getStatusStyle(allDownloaded, anyDownloading, startTime, endTime, anyMonitored); - const isMissingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !allAbsoluteEpisodeNumbers; - - if (isExpanded) { - return ( -
- { - events.map((event) => { - if (event.isGroup) { - return null; - } - - return ( - - ); - }) - } - - - - -
- ); - } - - return ( -
-
-
- {series.title} -
- - { - isMissingAbsoluteNumber && - - } - - { - anyDownloading && - - } - - { - firstEpisode.episodeNumber === 1 && seasonNumber > 0 && - - } - - { - showFinaleIcon && - lastEpisode.episodeNumber !== 1 && - seasonNumber > 0 && - lastEpisode.episodeNumber === series.seasons.find((season) => season.seasonNumber === seasonNumber).statistics.totalEpisodeCount && - - } -
- -
-
- {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} -
- - { - showEpisodeInformation ? -
- {seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-{padNumber(lastEpisode.episodeNumber, 2)} - - { - series.seriesType === 'anime' && - firstEpisode.absoluteEpisodeNumber && - lastEpisode.absoluteEpisodeNumber && - - ({firstEpisode.absoluteEpisodeNumber}-{lastEpisode.absoluteEpisodeNumber}) - - } -
: - - - - } -
- - { - showEpisodeInformation && - - - - } -
- ); - } -} - -CalendarEventGroup.propTypes = { - series: PropTypes.object.isRequired, - events: PropTypes.arrayOf(PropTypes.object).isRequired, - isDownloading: PropTypes.bool.isRequired, - showEpisodeInformation: PropTypes.bool.isRequired, - showFinaleIcon: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired, - colorImpairedMode: PropTypes.bool.isRequired, - onEventModalOpenToggle: PropTypes.func.isRequired -}; - -export default CalendarEventGroup; diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.tsx b/frontend/src/Calendar/Events/CalendarEventGroup.tsx new file mode 100644 index 00000000000..1ee981cfdff --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroup.tsx @@ -0,0 +1,253 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import getFinaleTypeName from 'Episode/getFinaleTypeName'; +import { icons, kinds } from 'Helpers/Props'; +import useSeries from 'Series/useSeries'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { CalendarItem } from 'typings/Calendar'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import translate from 'Utilities/String/translate'; +import CalendarEvent from './CalendarEvent'; +import styles from './CalendarEventGroup.css'; + +function createIsDownloadingSelector(episodeIds: number[]) { + return createSelector( + (state: AppState) => state.queue.details, + (details) => { + return details.items.some((item) => { + return !!(item.episodeId && episodeIds.includes(item.episodeId)); + }); + } + ); +} + +interface CalendarEventGroupProps { + episodeIds: number[]; + seriesId: number; + events: CalendarItem[]; + onEventModalOpenToggle: (isOpen: boolean) => void; +} + +function CalendarEventGroup({ + episodeIds, + seriesId, + events, + onEventModalOpenToggle, +}: CalendarEventGroupProps) { + const isDownloading = useSelector(createIsDownloadingSelector(episodeIds)); + const series = useSeries(seriesId)!; + + const { timeFormat, enableColorImpairedMode } = useSelector( + createUISettingsSelector() + ); + + const { showEpisodeInformation, showFinaleIcon, fullColorEvents } = + useSelector((state: AppState) => state.calendar.options); + + const [isExpanded, setIsExpanded] = useState(false); + + const firstEpisode = events[0]; + const lastEpisode = events[events.length - 1]; + const airDateUtc = firstEpisode.airDateUtc; + const startTime = moment(airDateUtc); + const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes'); + const seasonNumber = firstEpisode.seasonNumber; + + const { allDownloaded, anyQueued, anyMonitored, allAbsoluteEpisodeNumbers } = + useMemo(() => { + let files = 0; + let queued = 0; + let monitored = 0; + let absoluteEpisodeNumbers = 0; + + events.forEach((event) => { + if (event.episodeFileId) { + files++; + } + + if (event.queued) { + queued++; + } + + if (series.monitored && event.monitored) { + monitored++; + } + + if (event.absoluteEpisodeNumber) { + absoluteEpisodeNumbers++; + } + }); + + return { + allDownloaded: files === events.length, + anyQueued: queued > 0, + anyMonitored: monitored > 0, + allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length, + }; + }, [series, events]); + + const anyDownloading = isDownloading || anyQueued; + + const statusStyle = getStatusStyle( + allDownloaded, + anyDownloading, + startTime, + endTime, + anyMonitored + ); + const isMissingAbsoluteNumber = + series.seriesType === 'anime' && + seasonNumber > 0 && + !allAbsoluteEpisodeNumbers; + + const handleExpandPress = useCallback(() => { + setIsExpanded((state) => !state); + }, []); + + if (isExpanded) { + return ( +
+ {events.map((event) => { + return ( + + ); + })} + + + + +
+ ); + } + + return ( +
+
+
{series.title}
+ +
+ {isMissingAbsoluteNumber ? ( + + ) : null} + + {anyDownloading ? ( + + ) : null} + + {firstEpisode.episodeNumber === 1 && seasonNumber > 0 ? ( + + ) : null} + + {showFinaleIcon && lastEpisode.finaleType ? ( + + ) : null} +
+
+ +
+
+ {formatTime(airDateUtc, timeFormat)} -{' '} + {formatTime(endTime.toISOString(), timeFormat, { + includeMinuteZero: true, + })} +
+ + {showEpisodeInformation ? ( +
+ {seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}- + {padNumber(lastEpisode.episodeNumber, 2)} + {series.seriesType === 'anime' && + firstEpisode.absoluteEpisodeNumber && + lastEpisode.absoluteEpisodeNumber ? ( + + ({firstEpisode.absoluteEpisodeNumber}- + {lastEpisode.absoluteEpisodeNumber}) + + ) : null} +
+ ) : ( + + + + )} +
+ + {showEpisodeInformation ? ( + +   + +   + + ) : null} +
+ ); +} + +export default CalendarEventGroup; diff --git a/frontend/src/Calendar/Events/CalendarEventGroupConnector.js b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js deleted file mode 100644 index dbd96778493..00000000000 --- a/frontend/src/Calendar/Events/CalendarEventGroupConnector.js +++ /dev/null @@ -1,37 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import CalendarEventGroup from './CalendarEventGroup'; - -function createIsDownloadingSelector() { - return createSelector( - (state, { episodeIds }) => episodeIds, - (state) => state.queue.details, - (episodeIds, details) => { - return details.items.some((item) => { - return item.episode && episodeIds.includes(item.episode.id); - }); - } - ); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - createSeriesSelector(), - createIsDownloadingSelector(), - createUISettingsSelector(), - (calendarOptions, series, isDownloading, uiSettings) => { - return { - series, - isDownloading, - ...calendarOptions, - timeFormat: uiSettings.timeFormat, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(CalendarEventGroup); diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js deleted file mode 100644 index db26eb1d281..00000000000 --- a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js +++ /dev/null @@ -1,56 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import QueueDetails from 'Activity/Queue/QueueDetails'; -import CircularProgressBar from 'Components/CircularProgressBar'; - -function CalendarEventQueueDetails(props) { - const { - title, - size, - sizeleft, - estimatedCompletionTime, - status, - trackedDownloadState, - trackedDownloadStatus, - statusMessages, - errorMessage - } = props; - - const progress = size ? (100 - sizeleft / size * 100) : 0; - - return ( - - } - /> - ); -} - -CalendarEventQueueDetails.propTypes = { - title: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - sizeleft: PropTypes.number.isRequired, - estimatedCompletionTime: PropTypes.string, - status: PropTypes.string.isRequired, - trackedDownloadState: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string.isRequired, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string -}; - -export default CalendarEventQueueDetails; diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx b/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx new file mode 100644 index 00000000000..2372bc78eef --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import QueueDetails from 'Activity/Queue/QueueDetails'; +import CircularProgressBar from 'Components/CircularProgressBar'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; + +interface CalendarEventQueueDetailsProps { + title: string; + size: number; + sizeleft: number; + estimatedCompletionTime?: string; + status: string; + trackedDownloadState: QueueTrackedDownloadState; + trackedDownloadStatus: QueueTrackedDownloadStatus; + statusMessages?: StatusMessage[]; + errorMessage?: string; +} + +function CalendarEventQueueDetails({ + title, + size, + sizeleft, + estimatedCompletionTime, + status, + trackedDownloadState, + trackedDownloadStatus, + statusMessages, + errorMessage, +}: CalendarEventQueueDetailsProps) { + const progress = size ? 100 - (sizeleft / size) * 100 : 0; + + return ( + + } + /> + ); +} + +export default CalendarEventQueueDetails; diff --git a/frontend/src/Calendar/Header/CalendarHeader.css.d.ts b/frontend/src/Calendar/Header/CalendarHeader.css.d.ts new file mode 100644 index 00000000000..700b53652bc --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.css.d.ts @@ -0,0 +1,14 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'header': string; + 'loading': string; + 'navigationButtons': string; + 'titleDesktop': string; + 'titleMobile': string; + 'todayButton': string; + 'viewButtonsContainer': string; + 'viewMenu': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/Header/CalendarHeader.js b/frontend/src/Calendar/Header/CalendarHeader.js deleted file mode 100644 index 71dcd67a85b..00000000000 --- a/frontend/src/Calendar/Header/CalendarHeader.js +++ /dev/null @@ -1,267 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Menu from 'Components/Menu/Menu'; -import MenuButton from 'Components/Menu/MenuButton'; -import MenuContent from 'Components/Menu/MenuContent'; -import ViewMenuItem from 'Components/Menu/ViewMenuItem'; -import { align, icons } from 'Helpers/Props'; -import CalendarHeaderViewButton from './CalendarHeaderViewButton'; -import styles from './CalendarHeader.css'; - -function getTitle(time, start, end, view, longDateFormat) { - const timeMoment = moment(time); - const startMoment = moment(start); - const endMoment = moment(end); - - if (view === 'day') { - return timeMoment.format(longDateFormat); - } else if (view === 'month') { - return timeMoment.format('MMMM YYYY'); - } else if (view === 'agenda') { - return 'Agenda'; - } - - let startFormat = 'MMM D YYYY'; - let endFormat = 'MMM D YYYY'; - - if (startMoment.isSame(endMoment, 'month')) { - startFormat = 'MMM D'; - endFormat = 'D YYYY'; - } else if (startMoment.isSame(endMoment, 'year')) { - startFormat = 'MMM D'; - endFormat = 'MMM D YYYY'; - } - - return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`; -} - -// TODO Convert to a stateful Component so we can track view internally when changed - -class CalendarHeader extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - view: props.view - }; - } - - componentDidUpdate(prevProps) { - const view = this.props.view; - - if (prevProps.view !== view) { - this.setState({ view }); - } - } - - // - // Listeners - - onViewChange = (view) => { - this.setState({ view }, () => { - this.props.onViewChange(view); - }); - }; - - // - // Render - - render() { - const { - isFetching, - time, - start, - end, - longDateFormat, - isSmallScreen, - collapseViewButtons, - onTodayPress, - onPreviousPress, - onNextPress - } = this.props; - - const view = this.state.view; - - const title = getTitle(time, start, end, view, longDateFormat); - - return ( -
- { - isSmallScreen && -
- {title} -
- } - -
-
- - - - - -
- - { - !isSmallScreen && -
- {title} -
- } - -
- { - isFetching && - - } - - { - collapseViewButtons ? - - - - - - - { - isSmallScreen ? - null : - - Month - - } - - - Week - - - - Forecast - - - - Day - - - - Agenda - - - : - -
- - - - - - - - - -
- } -
-
-
- ); - } -} - -CalendarHeader.propTypes = { - isFetching: PropTypes.bool.isRequired, - time: PropTypes.string.isRequired, - start: PropTypes.string.isRequired, - end: PropTypes.string.isRequired, - view: PropTypes.oneOf(calendarViews.all).isRequired, - isSmallScreen: PropTypes.bool.isRequired, - collapseViewButtons: PropTypes.bool.isRequired, - longDateFormat: PropTypes.string.isRequired, - onViewChange: PropTypes.func.isRequired, - onTodayPress: PropTypes.func.isRequired, - onPreviousPress: PropTypes.func.isRequired, - onNextPress: PropTypes.func.isRequired -}; - -export default CalendarHeader; diff --git a/frontend/src/Calendar/Header/CalendarHeader.tsx b/frontend/src/Calendar/Header/CalendarHeader.tsx new file mode 100644 index 00000000000..2faaca25ef0 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.tsx @@ -0,0 +1,221 @@ +import moment from 'moment'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import { CalendarView } from 'Calendar/calendarViews'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; +import { align, icons } from 'Helpers/Props'; +import { + gotoCalendarNextRange, + gotoCalendarPreviousRange, + gotoCalendarToday, + setCalendarView, +} from 'Store/Actions/calendarActions'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import translate from 'Utilities/String/translate'; +import CalendarHeaderViewButton from './CalendarHeaderViewButton'; +import styles from './CalendarHeader.css'; + +function CalendarHeader() { + const dispatch = useDispatch(); + + const { isFetching, view, time, start, end } = useSelector( + (state: AppState) => state.calendar + ); + + const { isSmallScreen, isLargeScreen } = useSelector( + createDimensionsSelector() + ); + + const { longDateFormat } = useSelector(createUISettingsSelector()); + + const handleViewChange = useCallback( + (newView: CalendarView) => { + dispatch(setCalendarView({ view: newView })); + }, + [dispatch] + ); + + const handleTodayPress = useCallback(() => { + dispatch(gotoCalendarToday()); + }, [dispatch]); + + const handlePreviousPress = useCallback(() => { + dispatch(gotoCalendarPreviousRange()); + }, [dispatch]); + + const handleNextPress = useCallback(() => { + dispatch(gotoCalendarNextRange()); + }, [dispatch]); + + const title = useMemo(() => { + const timeMoment = moment(time); + const startMoment = moment(start); + const endMoment = moment(end); + + if (view === 'day') { + return timeMoment.format(longDateFormat); + } else if (view === 'month') { + return timeMoment.format('MMMM YYYY'); + } else if (view === 'agenda') { + return translate('Agenda'); + } + + let startFormat = 'MMM D YYYY'; + let endFormat = 'MMM D YYYY'; + + if (startMoment.isSame(endMoment, 'month')) { + startFormat = 'MMM D'; + endFormat = 'D YYYY'; + } else if (startMoment.isSame(endMoment, 'year')) { + startFormat = 'MMM D'; + endFormat = 'MMM D YYYY'; + } + + return `${startMoment.format(startFormat)} \u2014 ${endMoment.format( + endFormat + )}`; + }, [time, start, end, view, longDateFormat]); + + return ( +
+ {isSmallScreen ?
{title}
: null} + +
+
+ + + + + +
+ + {isSmallScreen ? null : ( +
{title}
+ )} + +
+ {isFetching ? ( + + ) : null} + + {isLargeScreen ? ( + + + + + + + {isSmallScreen ? null : ( + + {translate('Month')} + + )} + + + {translate('Week')} + + + + {translate('Forecast')} + + + + {translate('Day')} + + + + {translate('Agenda')} + + + + ) : ( + <> + + + + + + + + + + + )} +
+
+
+ ); +} + +export default CalendarHeader; diff --git a/frontend/src/Calendar/Header/CalendarHeaderConnector.js b/frontend/src/Calendar/Header/CalendarHeaderConnector.js deleted file mode 100644 index 616e48650c5..00000000000 --- a/frontend/src/Calendar/Header/CalendarHeaderConnector.js +++ /dev/null @@ -1,85 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { gotoCalendarNextRange, gotoCalendarPreviousRange, gotoCalendarToday, setCalendarView } from 'Store/Actions/calendarActions'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import CalendarHeader from './CalendarHeader'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - createDimensionsSelector(), - createUISettingsSelector(), - (calendar, dimensions, uiSettings) => { - const result = _.pick(calendar, [ - 'isFetching', - 'view', - 'time', - 'start', - 'end' - ]); - - result.isSmallScreen = dimensions.isSmallScreen; - result.collapseViewButtons = dimensions.isLargeScreen; - result.longDateFormat = uiSettings.longDateFormat; - - return result; - } - ); -} - -const mapDispatchToProps = { - setCalendarView, - gotoCalendarToday, - gotoCalendarPreviousRange, - gotoCalendarNextRange -}; - -class CalendarHeaderConnector extends Component { - - // - // Listeners - - onViewChange = (view) => { - this.props.setCalendarView({ view }); - }; - - onTodayPress = () => { - this.props.gotoCalendarToday(); - }; - - onPreviousPress = () => { - this.props.gotoCalendarPreviousRange(); - }; - - onNextPress = () => { - this.props.gotoCalendarNextRange(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -CalendarHeaderConnector.propTypes = { - setCalendarView: PropTypes.func.isRequired, - gotoCalendarToday: PropTypes.func.isRequired, - gotoCalendarPreviousRange: PropTypes.func.isRequired, - gotoCalendarNextRange: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector); diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js deleted file mode 100644 index 98958af038f..00000000000 --- a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js +++ /dev/null @@ -1,45 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import Button from 'Components/Link/Button'; -import titleCase from 'Utilities/String/titleCase'; -// import styles from './CalendarHeaderViewButton.css'; - -class CalendarHeaderViewButton extends Component { - - // - // Listeners - - onPress = () => { - this.props.onPress(this.props.view); - }; - - // - // Render - - render() { - const { - view, - selectedView, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -CalendarHeaderViewButton.propTypes = { - view: PropTypes.oneOf(calendarViews.all).isRequired, - selectedView: PropTypes.oneOf(calendarViews.all).isRequired, - onPress: PropTypes.func.isRequired -}; - -export default CalendarHeaderViewButton; diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx b/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx new file mode 100644 index 00000000000..c9366f9ef87 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx @@ -0,0 +1,34 @@ +import React, { useCallback } from 'react'; +import { CalendarView } from 'Calendar/calendarViews'; +import Button, { ButtonProps } from 'Components/Link/Button'; +import titleCase from 'Utilities/String/titleCase'; + +interface CalendarHeaderViewButtonProps + extends Omit { + view: CalendarView; + selectedView: CalendarView; + onPress: (view: CalendarView) => void; +} + +function CalendarHeaderViewButton({ + view, + selectedView, + onPress, + ...otherProps +}: CalendarHeaderViewButtonProps) { + const handlePress = useCallback(() => { + onPress(view); + }, [view, onPress]); + + return ( + + ); +} + +export default CalendarHeaderViewButton; diff --git a/frontend/src/Calendar/Legend/Legend.css.d.ts b/frontend/src/Calendar/Legend/Legend.css.d.ts new file mode 100644 index 00000000000..19c0339b43e --- /dev/null +++ b/frontend/src/Calendar/Legend/Legend.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'legend': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/Legend/Legend.js b/frontend/src/Calendar/Legend/Legend.js deleted file mode 100644 index ba0e2663a03..00000000000 --- a/frontend/src/Calendar/Legend/Legend.js +++ /dev/null @@ -1,144 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { icons, kinds } from 'Helpers/Props'; -import LegendIconItem from './LegendIconItem'; -import LegendItem from './LegendItem'; -import styles from './Legend.css'; - -function Legend(props) { - const { - view, - showFinaleIcon, - showSpecialIcon, - showCutoffUnmetIcon, - fullColorEvents, - colorImpairedMode - } = props; - - const iconsToShow = []; - const isAgendaView = view === 'agenda'; - - if (showFinaleIcon) { - iconsToShow.push( - - ); - } - - if (showSpecialIcon) { - iconsToShow.push( - - ); - } - - if (showCutoffUnmetIcon) { - iconsToShow.push( - - ); - } - - return ( -
-
- - - -
- -
- - - -
- -
- - - -
- -
- - - {iconsToShow[0]} -
- - { - iconsToShow.length > 1 && -
- {iconsToShow[1]} - {iconsToShow[2]} -
- } -
- ); -} - -Legend.propTypes = { - view: PropTypes.string.isRequired, - showFinaleIcon: PropTypes.bool.isRequired, - showSpecialIcon: PropTypes.bool.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - colorImpairedMode: PropTypes.bool.isRequired -}; - -export default Legend; diff --git a/frontend/src/Calendar/Legend/Legend.tsx b/frontend/src/Calendar/Legend/Legend.tsx new file mode 100644 index 00000000000..b9887f856fd --- /dev/null +++ b/frontend/src/Calendar/Legend/Legend.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import { icons, kinds } from 'Helpers/Props'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import translate from 'Utilities/String/translate'; +import LegendIconItem from './LegendIconItem'; +import LegendItem from './LegendItem'; +import styles from './Legend.css'; + +function Legend() { + const view = useSelector((state: AppState) => state.calendar.view); + const { + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + fullColorEvents, + } = useSelector((state: AppState) => state.calendar.options); + const { enableColorImpairedMode } = useSelector(createUISettingsSelector()); + + const iconsToShow = []; + const isAgendaView = view === 'agenda'; + + if (showFinaleIcon) { + iconsToShow.push( + + ); + + iconsToShow.push( + + ); + } + + if (showSpecialIcon) { + iconsToShow.push( + + ); + } + + if (showCutoffUnmetIcon) { + iconsToShow.push( + + ); + } + + return ( +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + {iconsToShow[0]} +
+ + {iconsToShow.length > 1 ? ( +
+ {iconsToShow[1]} + {iconsToShow[2]} +
+ ) : null} + {iconsToShow.length > 3 ?
{iconsToShow[3]}
: null} +
+ ); +} + +export default Legend; diff --git a/frontend/src/Calendar/Legend/LegendConnector.js b/frontend/src/Calendar/Legend/LegendConnector.js deleted file mode 100644 index 889b7a0024f..00000000000 --- a/frontend/src/Calendar/Legend/LegendConnector.js +++ /dev/null @@ -1,21 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import Legend from './Legend'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - (state) => state.calendar.view, - createUISettingsSelector(), - (calendarOptions, view, uiSettings) => { - return { - ...calendarOptions, - view, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(Legend); diff --git a/frontend/src/Calendar/Legend/LegendIconItem.css b/frontend/src/Calendar/Legend/LegendIconItem.css index 01db0ba5a06..c6c12027d67 100644 --- a/frontend/src/Calendar/Legend/LegendIconItem.css +++ b/frontend/src/Calendar/Legend/LegendIconItem.css @@ -7,4 +7,8 @@ .icon { margin-right: 5px; + + &:global(.fullColorEvents) { + filter: var(--calendarFullColorFilter) + } } diff --git a/frontend/src/Calendar/Legend/LegendIconItem.css.d.ts b/frontend/src/Calendar/Legend/LegendIconItem.css.d.ts new file mode 100644 index 00000000000..5d618d24b33 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'icon': string; + 'legendIconItem': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/Legend/LegendIconItem.js b/frontend/src/Calendar/Legend/LegendIconItem.js deleted file mode 100644 index 5ce5f725be0..00000000000 --- a/frontend/src/Calendar/Legend/LegendIconItem.js +++ /dev/null @@ -1,44 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import styles from './LegendIconItem.css'; - -function LegendIconItem(props) { - const { - name, - icon, - kind, - darken, - tooltip - } = props; - - return ( -
- - - {name} -
- ); -} - -LegendIconItem.propTypes = { - name: PropTypes.string.isRequired, - icon: PropTypes.object.isRequired, - kind: PropTypes.string.isRequired, - darken: PropTypes.bool.isRequired, - tooltip: PropTypes.string.isRequired -}; - -LegendIconItem.defaultProps = { - darken: false -}; - -export default LegendIconItem; diff --git a/frontend/src/Calendar/Legend/LegendIconItem.tsx b/frontend/src/Calendar/Legend/LegendIconItem.tsx new file mode 100644 index 00000000000..88a758c4495 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.tsx @@ -0,0 +1,33 @@ +import { FontAwesomeIconProps } from '@fortawesome/react-fontawesome'; +import classNames from 'classnames'; +import React from 'react'; +import Icon, { IconProps } from 'Components/Icon'; +import styles from './LegendIconItem.css'; + +interface LegendIconItemProps extends Pick { + name: string; + fullColorEvents: boolean; + icon: FontAwesomeIconProps['icon']; + tooltip: string; +} + +function LegendIconItem(props: LegendIconItemProps) { + const { name, fullColorEvents, icon, kind, tooltip } = props; + + return ( +
+ + + {name} +
+ ); +} + +export default LegendIconItem; diff --git a/frontend/src/Calendar/Legend/LegendItem.css.d.ts b/frontend/src/Calendar/Legend/LegendItem.css.d.ts new file mode 100644 index 00000000000..155e029c6e1 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendItem.css.d.ts @@ -0,0 +1,14 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'downloaded': string; + 'downloading': string; + 'legendItem': string; + 'missing': string; + 'onAir': string; + 'premiere': string; + 'unaired': string; + 'unmonitored': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Calendar/Legend/LegendItem.js b/frontend/src/Calendar/Legend/LegendItem.js deleted file mode 100644 index f0304b9e603..00000000000 --- a/frontend/src/Calendar/Legend/LegendItem.js +++ /dev/null @@ -1,41 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import titleCase from 'Utilities/String/titleCase'; -import styles from './LegendItem.css'; - -function LegendItem(props) { - const { - name, - status, - tooltip, - isAgendaView, - fullColorEvents, - colorImpairedMode - } = props; - - return ( -
- {name ? name : titleCase(status)} -
- ); -} - -LegendItem.propTypes = { - name: PropTypes.string, - status: PropTypes.string.isRequired, - tooltip: PropTypes.string.isRequired, - isAgendaView: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - colorImpairedMode: PropTypes.bool.isRequired -}; - -export default LegendItem; diff --git a/frontend/src/Calendar/Legend/LegendItem.tsx b/frontend/src/Calendar/Legend/LegendItem.tsx new file mode 100644 index 00000000000..40466ab9dde --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendItem.tsx @@ -0,0 +1,41 @@ +import classNames from 'classnames'; +import React from 'react'; +import { CalendarStatus } from 'typings/Calendar'; +import titleCase from 'Utilities/String/titleCase'; +import styles from './LegendItem.css'; + +interface LegendItemProps { + name?: string; + status: CalendarStatus; + tooltip: string; + isAgendaView: boolean; + fullColorEvents: boolean; + colorImpairedMode: boolean; +} + +function LegendItem(props: LegendItemProps) { + const { + name, + status, + tooltip, + isAgendaView, + fullColorEvents, + colorImpairedMode, + } = props; + + return ( +
+ {name ? name : titleCase(status)} +
+ ); +} + +export default LegendItem; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.js b/frontend/src/Calendar/Options/CalendarOptionsModal.js deleted file mode 100644 index b68c83f3011..00000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModal.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector'; - -function CalendarOptionsModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - ); -} - -CalendarOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarOptionsModal; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.tsx b/frontend/src/Calendar/Options/CalendarOptionsModal.tsx new file mode 100644 index 00000000000..ae782a684b6 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarOptionsModalContent from './CalendarOptionsModalContent'; + +interface CalendarOptionsModalProps { + isOpen: boolean; + onModalClose: () => void; +} + +function CalendarOptionsModal({ + isOpen, + onModalClose, +}: CalendarOptionsModalProps) { + return ( + + + + ); +} + +export default CalendarOptionsModal; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js deleted file mode 100644 index b7e738e7223..00000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js +++ /dev/null @@ -1,275 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FieldSet from 'Components/FieldSet'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes } from 'Helpers/Props'; -import { firstDayOfWeekOptions, timeFormatOptions, weekColumnOptions } from 'Settings/UI/UISettings'; - -class CalendarOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode, - fullColorEvents - } = props; - - this.state = { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode, - fullColorEvents - }; - } - - componentDidUpdate(prevProps) { - const { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode - } = this.props; - - if ( - prevProps.firstDayOfWeek !== firstDayOfWeek || - prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader || - prevProps.timeFormat !== timeFormat || - prevProps.enableColorImpairedMode !== enableColorImpairedMode - ) { - this.setState({ - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode - }); - } - } - - // - // Listeners - - onOptionInputChange = ({ name, value }) => { - const { - dispatchSetCalendarOption - } = this.props; - - dispatchSetCalendarOption({ [name]: value }); - }; - - onGlobalInputChange = ({ name, value }) => { - const { - dispatchSaveUISettings - } = this.props; - - const setting = { [name]: value }; - - this.setState(setting, () => { - dispatchSaveUISettings(setting); - }); - }; - - onLinkFocus = (event) => { - event.target.select(); - }; - - // - // Render - - render() { - const { - collapseMultipleEpisodes, - showEpisodeInformation, - showFinaleIcon, - showSpecialIcon, - showCutoffUnmetIcon, - fullColorEvents, - onModalClose - } = this.props; - - const { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode - } = this.state; - - return ( - - - Calendar Options - - - -
- - - Collapse Multiple Episodes - - - - - - Show Episode Information - - - - - - Icon for Finales - - - - - - Icon for Specials - - - - - - Icon for Cutoff Unmet - - - - - - Full Color Events - - - - -
- -
-
- - First Day of Week - - - - - - Week Column Header - - - - - - Time Format - - - - - - Enable Color-Impaired Mode - - - -
-
-
- - - - -
- ); - } -} - -CalendarOptionsModalContent.propTypes = { - collapseMultipleEpisodes: PropTypes.bool.isRequired, - showEpisodeInformation: PropTypes.bool.isRequired, - showFinaleIcon: PropTypes.bool.isRequired, - showSpecialIcon: PropTypes.bool.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - firstDayOfWeek: PropTypes.number.isRequired, - calendarWeekColumnHeader: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - enableColorImpairedMode: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - dispatchSetCalendarOption: PropTypes.func.isRequired, - dispatchSaveUISettings: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarOptionsModalContent; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx b/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx new file mode 100644 index 00000000000..4f974dda36d --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx @@ -0,0 +1,228 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import { + firstDayOfWeekOptions, + timeFormatOptions, + weekColumnOptions, +} from 'Settings/UI/UISettings'; +import { setCalendarOption } from 'Store/Actions/calendarActions'; +import { saveUISettings } from 'Store/Actions/settingsActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { InputChanged } from 'typings/inputs'; +import UiSettings from 'typings/Settings/UiSettings'; +import translate from 'Utilities/String/translate'; + +interface CalendarOptionsModalContentProps { + onModalClose: () => void; +} + +function CalendarOptionsModalContent({ + onModalClose, +}: CalendarOptionsModalContentProps) { + const dispatch = useDispatch(); + + const { + collapseMultipleEpisodes, + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + fullColorEvents, + } = useSelector((state: AppState) => state.calendar.options); + + const uiSettings = useSelector(createUISettingsSelector()); + + const [state, setState] = useState>({ + firstDayOfWeek: uiSettings.firstDayOfWeek, + calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader, + timeFormat: uiSettings.timeFormat, + enableColorImpairedMode: uiSettings.enableColorImpairedMode, + }); + + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode, + } = state; + + const handleOptionInputChange = useCallback( + ({ name, value }: InputChanged) => { + dispatch(setCalendarOption({ [name]: value })); + }, + [dispatch] + ); + + const handleGlobalInputChange = useCallback( + ({ name, value }: InputChanged) => { + setState((prevState) => ({ ...prevState, [name]: value })); + + dispatch(saveUISettings({ [name]: value })); + }, + [dispatch] + ); + + useEffect(() => { + setState({ + firstDayOfWeek: uiSettings.firstDayOfWeek, + calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader, + timeFormat: uiSettings.timeFormat, + enableColorImpairedMode: uiSettings.enableColorImpairedMode, + }); + }, [uiSettings]); + + return ( + + {translate('CalendarOptions')} + + +
+
+ + {translate('CollapseMultipleEpisodes')} + + + + + + {translate('ShowEpisodeInformation')} + + + + + + {translate('IconForFinales')} + + + + + + {translate('IconForSpecials')} + + + + + + {translate('IconForCutoffUnmet')} + + + + + + {translate('FullColorEvents')} + + + +
+
+ +
+
+ + {translate('FirstDayOfWeek')} + + + + + + {translate('WeekColumnHeader')} + + + + + + {translate('TimeFormat')} + + + + + + {translate('EnableColorImpairedMode')} + + + +
+
+
+ + + + +
+ ); +} + +export default CalendarOptionsModalContent; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js deleted file mode 100644 index 1f517b69898..00000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setCalendarOption } from 'Store/Actions/calendarActions'; -import { saveUISettings } from 'Store/Actions/settingsActions'; -import CalendarOptionsModalContent from './CalendarOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - (state) => state.settings.ui.item, - (options, uiSettings) => { - return { - ...options, - ...uiSettings - }; - } - ); -} - -const mapDispatchToProps = { - dispatchSetCalendarOption: setCalendarOption, - dispatchSaveUISettings: saveUISettings -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent); diff --git a/frontend/src/Calendar/calendarViews.js b/frontend/src/Calendar/calendarViews.js deleted file mode 100644 index 929958b66ca..00000000000 --- a/frontend/src/Calendar/calendarViews.js +++ /dev/null @@ -1,7 +0,0 @@ -export const DAY = 'day'; -export const WEEK = 'week'; -export const MONTH = 'month'; -export const FORECAST = 'forecast'; -export const AGENDA = 'agenda'; - -export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA]; diff --git a/frontend/src/Calendar/calendarViews.ts b/frontend/src/Calendar/calendarViews.ts new file mode 100644 index 00000000000..4f5549dbd1b --- /dev/null +++ b/frontend/src/Calendar/calendarViews.ts @@ -0,0 +1,9 @@ +export const DAY = 'day'; +export const WEEK = 'week'; +export const MONTH = 'month'; +export const FORECAST = 'forecast'; +export const AGENDA = 'agenda'; + +export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA]; + +export type CalendarView = 'agenda' | 'day' | 'forecast' | 'month' | 'week'; diff --git a/frontend/src/Calendar/getStatusStyle.js b/frontend/src/Calendar/getStatusStyle.js deleted file mode 100644 index b149a8aab46..00000000000 --- a/frontend/src/Calendar/getStatusStyle.js +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint max-params: 0 */ -import moment from 'moment'; - -function getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored) { - const currentTime = moment(); - - if (hasFile) { - return 'downloaded'; - } - - if (downloading) { - return 'downloading'; - } - - if (!isMonitored) { - return 'unmonitored'; - } - - if (currentTime.isAfter(startTime) && currentTime.isBefore(endTime)) { - return 'onAir'; - } - - if (endTime.isBefore(currentTime) && !hasFile) { - return 'missing'; - } - - return 'unaired'; -} - -export default getStatusStyle; diff --git a/frontend/src/Calendar/getStatusStyle.ts b/frontend/src/Calendar/getStatusStyle.ts new file mode 100644 index 00000000000..678e6c2a1b5 --- /dev/null +++ b/frontend/src/Calendar/getStatusStyle.ts @@ -0,0 +1,36 @@ +import moment from 'moment'; +import { CalendarStatus } from 'typings/Calendar'; + +function getStatusStyle( + hasFile: boolean, + downloading: boolean, + startTime: moment.Moment, + endTime: moment.Moment, + isMonitored: boolean +): CalendarStatus { + const currentTime = moment(); + + if (hasFile) { + return 'downloaded'; + } + + if (downloading) { + return 'downloading'; + } + + if (!isMonitored) { + return 'unmonitored'; + } + + if (currentTime.isAfter(startTime) && currentTime.isBefore(endTime)) { + return 'onAir'; + } + + if (endTime.isBefore(currentTime) && !hasFile) { + return 'missing'; + } + + return 'unaired'; +} + +export default getStatusStyle; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js deleted file mode 100644 index 8cc487c1622..00000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModal.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector'; - -function CalendarLinkModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - ); -} - -CalendarLinkModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarLinkModal; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.tsx b/frontend/src/Calendar/iCal/CalendarLinkModal.tsx new file mode 100644 index 00000000000..f0eecbd4a47 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarLinkModalContent from './CalendarLinkModalContent'; + +interface CalendarLinkModalProps { + isOpen: boolean; + onModalClose: () => void; +} + +function CalendarLinkModal(props: CalendarLinkModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default CalendarLinkModal; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js deleted file mode 100644 index 2df0caf6d13..00000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js +++ /dev/null @@ -1,221 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputButton from 'Components/Form/FormInputButton'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import ClipboardButton from 'Components/Link/ClipboardButton'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { icons, inputTypes, kinds, sizes } from 'Helpers/Props'; - -function getUrls(state) { - const { - unmonitored, - premieresOnly, - asAllDay, - tags - } = state; - - let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`; - - if (unmonitored) { - icalUrl += 'unmonitored=true&'; - } - - if (premieresOnly) { - icalUrl += 'premieresOnly=true&'; - } - - if (asAllDay) { - icalUrl += 'asAllDay=true&'; - } - - if (tags.length) { - icalUrl += `tags=${tags.toString()}&`; - } - - icalUrl += `apikey=${window.Sonarr.apiKey}`; - - const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`; - const iCalWebCalUrl = `webcal://${icalUrl}`; - - return { - iCalHttpUrl, - iCalWebCalUrl - }; -} - -class CalendarLinkModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const defaultState = { - unmonitored: false, - premieresOnly: false, - asAllDay: false, - tags: [] - }; - - const urls = getUrls(defaultState); - - this.state = { - ...defaultState, - ...urls - }; - } - - // - // Listeners - - onInputChange = ({ name, value }) => { - const state = { - ...this.state, - [name]: value - }; - - const urls = getUrls(state); - - this.setState({ - [name]: value, - ...urls - }); - }; - - onLinkFocus = (event) => { - event.target.select(); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - unmonitored, - premieresOnly, - asAllDay, - tags, - iCalHttpUrl, - iCalWebCalUrl - } = this.state; - - return ( - - - Sonarr Calendar Feed - - - -
- - Include Unmonitored - - - - - - Season Premieres Only - - - - - - Show as All-Day Events - - - - - - Tags - - - - - - iCal Feed - - , - - - - - ]} - onChange={this.onInputChange} - onFocus={this.onLinkFocus} - /> - -
-
- - - - -
- ); - } -} - -CalendarLinkModalContent.propTypes = { - tagList: PropTypes.arrayOf(PropTypes.object).isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarLinkModalContent; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx b/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx new file mode 100644 index 00000000000..aa90db30137 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx @@ -0,0 +1,166 @@ +import React, { FocusEvent, useCallback, useMemo, useState } from 'react'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputButton from 'Components/Form/FormInputButton'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import ClipboardButton from 'Components/Link/ClipboardButton'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { icons, inputTypes, kinds, sizes } from 'Helpers/Props'; +import { InputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; + +interface CalendarLinkModalContentProps { + onModalClose: () => void; +} + +function CalendarLinkModalContent({ + onModalClose, +}: CalendarLinkModalContentProps) { + const [state, setState] = useState({ + unmonitored: false, + premieresOnly: false, + asAllDay: false, + tags: [], + }); + + const { unmonitored, premieresOnly, asAllDay, tags } = state; + + const handleInputChange = useCallback(({ name, value }: InputChanged) => { + setState((prevState) => ({ ...prevState, [name]: value })); + }, []); + + const handleLinkFocus = useCallback( + (event: FocusEvent) => { + event.target.select(); + }, + [] + ); + + const { iCalHttpUrl, iCalWebCalUrl } = useMemo(() => { + let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`; + + if (unmonitored) { + icalUrl += 'unmonitored=true&'; + } + + if (premieresOnly) { + icalUrl += 'premieresOnly=true&'; + } + + if (asAllDay) { + icalUrl += 'asAllDay=true&'; + } + + if (tags.length) { + icalUrl += `tags=${tags.toString()}&`; + } + + icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`; + + return { + iCalHttpUrl: `${window.location.protocol}//${icalUrl}`, + iCalWebCalUrl: `webcal://${icalUrl}`, + }; + }, [unmonitored, premieresOnly, asAllDay, tags]); + + return ( + + {translate('CalendarFeed')} + + +
+ + {translate('IncludeUnmonitored')} + + + + + + {translate('SeasonPremieresOnly')} + + + + + + {translate('ICalShowAsAllDayEvents')} + + + + + + {translate('Tags')} + + + + + + {translate('ICalFeed')} + + , + + + + , + ]} + onChange={handleInputChange} + onFocus={handleLinkFocus} + /> + +
+
+ + + + +
+ ); +} + +export default CalendarLinkModalContent; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js deleted file mode 100644 index e10c5c3f909..00000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js +++ /dev/null @@ -1,17 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import CalendarLinkModalContent from './CalendarLinkModalContent'; - -function createMapStateToProps() { - return createSelector( - createTagsSelector(), - (tagList) => { - return { - tagList - }; - } - ); -} - -export default connect(createMapStateToProps)(CalendarLinkModalContent); diff --git a/frontend/src/Commands/Command.ts b/frontend/src/Commands/Command.ts new file mode 100644 index 00000000000..cd875d56b28 --- /dev/null +++ b/frontend/src/Commands/Command.ts @@ -0,0 +1,52 @@ +import ModelBase from 'App/ModelBase'; + +export type CommandStatus = + | 'queued' + | 'started' + | 'completed' + | 'failed' + | 'aborted' + | 'cancelled' + | 'orphaned'; + +export type CommandResult = 'unknown' | 'successful' | 'unsuccessful'; + +export interface CommandBody { + sendUpdatesToClient: boolean; + updateScheduledTask: boolean; + completionMessage: string; + requiresDiskAccess: boolean; + isExclusive: boolean; + isLongRunning: boolean; + name: string; + lastExecutionTime: string; + lastStartTime: string; + trigger: string; + suppressMessages: boolean; + seriesId?: number; + seriesIds?: number[]; + seasonNumber?: number; + episodeIds?: number[]; + [key: string]: string | number | boolean | number[] | undefined; +} + +interface Command extends ModelBase { + name: string; + commandName: string; + message: string; + body: CommandBody; + priority: string; + status: CommandStatus; + result: CommandResult; + queued: string; + started: string; + ended: string; + duration: string; + trigger: string; + stateChangeTime: string; + sendUpdatesToClient: boolean; + updateScheduledTask: boolean; + lastExecutionTime: string; +} + +export default Command; diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js index c2edf05bd4a..13ac9d62c1f 100644 --- a/frontend/src/Commands/commandNames.js +++ b/frontend/src/Commands/commandNames.js @@ -6,7 +6,7 @@ export const CLEAR_LOGS = 'ClearLog'; export const CUTOFF_UNMET_EPISODE_SEARCH = 'CutoffUnmetEpisodeSearch'; export const DELETE_LOG_FILES = 'DeleteLogFiles'; export const DELETE_UPDATE_LOG_FILES = 'DeleteUpdateLogFiles'; -export const DOWNLOADED_EPSIODES_SCAN = 'DownloadedEpisodesScan'; +export const DOWNLOADED_EPISODES_SCAN = 'DownloadedEpisodesScan'; export const EPISODE_SEARCH = 'EpisodeSearch'; export const INTERACTIVE_IMPORT = 'ManualImport'; export const MISSING_EPISODE_SEARCH = 'MissingEpisodeSearch'; diff --git a/frontend/src/Components/Alert.css.d.ts b/frontend/src/Components/Alert.css.d.ts new file mode 100644 index 00000000000..daffec2e634 --- /dev/null +++ b/frontend/src/Components/Alert.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'alert': string; + 'danger': string; + 'info': string; + 'success': string; + 'warning': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Alert.js b/frontend/src/Components/Alert.js deleted file mode 100644 index 10f124c7856..00000000000 --- a/frontend/src/Components/Alert.js +++ /dev/null @@ -1,32 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { kinds } from 'Helpers/Props'; -import styles from './Alert.css'; - -function Alert({ className, kind, children, ...otherProps }) { - return ( -
- {children} -
- ); -} - -Alert.propTypes = { - className: PropTypes.string.isRequired, - kind: PropTypes.oneOf(kinds.all).isRequired, - children: PropTypes.node.isRequired -}; - -Alert.defaultProps = { - className: styles.alert, - kind: kinds.INFO -}; - -export default Alert; diff --git a/frontend/src/Components/Alert.tsx b/frontend/src/Components/Alert.tsx new file mode 100644 index 00000000000..92c89e74134 --- /dev/null +++ b/frontend/src/Components/Alert.tsx @@ -0,0 +1,18 @@ +import classNames from 'classnames'; +import React from 'react'; +import { Kind } from 'Helpers/Props/kinds'; +import styles from './Alert.css'; + +interface AlertProps { + className?: string; + kind?: Extract; + children: React.ReactNode; +} + +function Alert(props: AlertProps) { + const { className = styles.alert, kind = 'info', children } = props; + + return
{children}
; +} + +export default Alert; diff --git a/frontend/src/Components/Card.css.d.ts b/frontend/src/Components/Card.css.d.ts new file mode 100644 index 00000000000..fb3ea792e54 --- /dev/null +++ b/frontend/src/Components/Card.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'card': string; + 'overlay': string; + 'underlay': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Card.js b/frontend/src/Components/Card.js deleted file mode 100644 index c5a4d164c14..00000000000 --- a/frontend/src/Components/Card.js +++ /dev/null @@ -1,60 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Link from 'Components/Link/Link'; -import styles from './Card.css'; - -class Card extends Component { - - // - // Render - - render() { - const { - className, - overlayClassName, - overlayContent, - children, - onPress - } = this.props; - - if (overlayContent) { - return ( -
- - -
- {children} -
-
- ); - } - - return ( - - {children} - - ); - } -} - -Card.propTypes = { - className: PropTypes.string.isRequired, - overlayClassName: PropTypes.string.isRequired, - overlayContent: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired, - onPress: PropTypes.func.isRequired -}; - -Card.defaultProps = { - className: styles.card, - overlayClassName: styles.overlay, - overlayContent: false -}; - -export default Card; diff --git a/frontend/src/Components/Card.tsx b/frontend/src/Components/Card.tsx new file mode 100644 index 00000000000..24588c841c7 --- /dev/null +++ b/frontend/src/Components/Card.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import Link, { LinkProps } from 'Components/Link/Link'; +import styles from './Card.css'; + +interface CardProps extends Pick { + // TODO: Consider using different properties for classname depending if it's overlaying content or not + className?: string; + overlayClassName?: string; + overlayContent?: boolean; + children: React.ReactNode; +} + +function Card(props: CardProps) { + const { + className = styles.card, + overlayClassName = styles.overlay, + overlayContent = false, + children, + onPress, + } = props; + + if (overlayContent) { + return ( +
+ + +
{children}
+
+ ); + } + + return ( + + {children} + + ); +} + +export default Card; diff --git a/frontend/src/Components/CircularProgressBar.css.d.ts b/frontend/src/Components/CircularProgressBar.css.d.ts new file mode 100644 index 00000000000..45179620cbf --- /dev/null +++ b/frontend/src/Components/CircularProgressBar.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'circularProgressBar': string; + 'circularProgressBarContainer': string; + 'circularProgressBarText': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/CircularProgressBar.js b/frontend/src/Components/CircularProgressBar.js deleted file mode 100644 index 3af5665a95e..00000000000 --- a/frontend/src/Components/CircularProgressBar.js +++ /dev/null @@ -1,138 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import styles from './CircularProgressBar.css'; - -class CircularProgressBar extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - progress: 0 - }; - } - - componentDidMount() { - this._progressStep(); - } - - componentDidUpdate(prevProps) { - const progress = this.props.progress; - - if (prevProps.progress !== progress) { - this._cancelProgressStep(); - this._progressStep(); - } - } - - componentWillUnmount() { - this._cancelProgressStep(); - } - - // - // Control - - _progressStep() { - this.requestAnimationFrame = window.requestAnimationFrame(() => { - this.setState({ - progress: this.state.progress + 1 - }, () => { - if (this.state.progress < this.props.progress) { - this._progressStep(); - } - }); - }); - } - - _cancelProgressStep() { - if (this.requestAnimationFrame) { - window.cancelAnimationFrame(this.requestAnimationFrame); - } - } - - // - // Render - - render() { - const { - className, - containerClassName, - size, - strokeWidth, - strokeColor, - showProgressText - } = this.props; - - const progress = this.state.progress; - - const center = size / 2; - const radius = center - strokeWidth; - const circumference = Math.PI * (radius * 2); - const sizeInPixels = `${size}px`; - const strokeDashoffset = ((100 - progress) / 100) * circumference; - const progressText = `${Math.round(progress)}%`; - - return ( -
- - - - - { - showProgressText && -
- {progressText} -
- } -
- ); - } -} - -CircularProgressBar.propTypes = { - className: PropTypes.string, - containerClassName: PropTypes.string, - size: PropTypes.number, - progress: PropTypes.number.isRequired, - strokeWidth: PropTypes.number, - strokeColor: PropTypes.string, - showProgressText: PropTypes.bool -}; - -CircularProgressBar.defaultProps = { - className: styles.circularProgressBar, - containerClassName: styles.circularProgressBarContainer, - size: 60, - strokeWidth: 5, - strokeColor: '#35c5f4', - showProgressText: false -}; - -export default CircularProgressBar; diff --git a/frontend/src/Components/CircularProgressBar.tsx b/frontend/src/Components/CircularProgressBar.tsx new file mode 100644 index 00000000000..b14f5fc6aa9 --- /dev/null +++ b/frontend/src/Components/CircularProgressBar.tsx @@ -0,0 +1,99 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import styles from './CircularProgressBar.css'; + +interface CircularProgressBarProps { + className?: string; + containerClassName?: string; + size?: number; + progress: number; + strokeWidth?: number; + strokeColor?: string; + showProgressText?: boolean; +} + +function CircularProgressBar({ + className = styles.circularProgressBar, + containerClassName = styles.circularProgressBarContainer, + size = 60, + strokeWidth = 5, + strokeColor = '#35c5f4', + showProgressText = false, + progress, +}: CircularProgressBarProps) { + const [currentProgress, setCurrentProgress] = useState(0); + const raf = React.useRef(0); + const center = size / 2; + const radius = center - strokeWidth; + const circumference = Math.PI * (radius * 2); + const sizeInPixels = `${size}px`; + const strokeDashoffset = ((100 - currentProgress) / 100) * circumference; + const progressText = `${Math.round(currentProgress)}%`; + + const handleAnimation = useCallback( + (p: number) => { + setCurrentProgress((prevProgress) => { + if (prevProgress < p) { + return prevProgress + Math.min(1, p - prevProgress); + } + + return prevProgress; + }); + }, + [setCurrentProgress] + ); + + useEffect(() => { + if (progress > currentProgress) { + cancelAnimationFrame(raf.current); + + raf.current = requestAnimationFrame(() => handleAnimation(progress)); + } + }, [progress, currentProgress, handleAnimation]); + + useEffect( + () => { + return () => cancelAnimationFrame(raf.current); + }, + // We only want to run this effect once + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ( +
+ + + + + {showProgressText && ( +
{progressText}
+ )} +
+ ); +} + +export default CircularProgressBar; diff --git a/frontend/src/Components/DescriptionList/DescriptionList.css.d.ts b/frontend/src/Components/DescriptionList/DescriptionList.css.d.ts new file mode 100644 index 00000000000..34c1578a48d --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionList.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'descriptionList': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/DescriptionList/DescriptionList.js b/frontend/src/Components/DescriptionList/DescriptionList.js deleted file mode 100644 index be2c87c550c..00000000000 --- a/frontend/src/Components/DescriptionList/DescriptionList.js +++ /dev/null @@ -1,33 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import styles from './DescriptionList.css'; - -class DescriptionList extends Component { - - // - // Render - - render() { - const { - className, - children - } = this.props; - - return ( -
- {children} -
- ); - } -} - -DescriptionList.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.node -}; - -DescriptionList.defaultProps = { - className: styles.descriptionList -}; - -export default DescriptionList; diff --git a/frontend/src/Components/DescriptionList/DescriptionList.tsx b/frontend/src/Components/DescriptionList/DescriptionList.tsx new file mode 100644 index 00000000000..6deee77e5e9 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionList.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import styles from './DescriptionList.css'; + +interface DescriptionListProps { + className?: string; + children?: React.ReactNode; +} + +function DescriptionList(props: DescriptionListProps) { + const { className = styles.descriptionList, children } = props; + + return
{children}
; +} + +export default DescriptionList; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.js b/frontend/src/Components/DescriptionList/DescriptionListItem.js deleted file mode 100644 index 39f634cc95d..00000000000 --- a/frontend/src/Components/DescriptionList/DescriptionListItem.js +++ /dev/null @@ -1,44 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import DescriptionListItemDescription from './DescriptionListItemDescription'; -import DescriptionListItemTitle from './DescriptionListItemTitle'; - -class DescriptionListItem extends Component { - - // - // Render - - render() { - const { - titleClassName, - descriptionClassName, - title, - data - } = this.props; - - return ( - - - {title} - - - - {data} - - - ); - } -} - -DescriptionListItem.propTypes = { - titleClassName: PropTypes.string, - descriptionClassName: PropTypes.string, - title: PropTypes.string, - data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) -}; - -export default DescriptionListItem; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItem.tsx b/frontend/src/Components/DescriptionList/DescriptionListItem.tsx new file mode 100644 index 00000000000..13a7efdd035 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItem.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import DescriptionListItemDescription, { + DescriptionListItemDescriptionProps, +} from './DescriptionListItemDescription'; +import DescriptionListItemTitle, { + DescriptionListItemTitleProps, +} from './DescriptionListItemTitle'; + +interface DescriptionListItemProps { + className?: string; + titleClassName?: DescriptionListItemTitleProps['className']; + descriptionClassName?: DescriptionListItemDescriptionProps['className']; + title?: DescriptionListItemTitleProps['children']; + data?: DescriptionListItemDescriptionProps['children']; +} + +function DescriptionListItem(props: DescriptionListItemProps) { + const { className, titleClassName, descriptionClassName, title, data } = + props; + + return ( +
+ + {title} + + + + {data} + +
+ ); +} + +export default DescriptionListItem; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css index b23415a76d5..786123fb7a4 100644 --- a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css @@ -1,9 +1,7 @@ -.description { - line-height: $lineHeight; -} - .description { margin-left: 0; + line-height: $lineHeight; + overflow-wrap: break-word; } @media (min-width: 768px) { diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css.d.ts b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css.d.ts new file mode 100644 index 00000000000..ff7055b0f08 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'description': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js deleted file mode 100644 index 4ef3c015e66..00000000000 --- a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.js +++ /dev/null @@ -1,27 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './DescriptionListItemDescription.css'; - -function DescriptionListItemDescription(props) { - const { - className, - children - } = props; - - return ( -
- {children} -
- ); -} - -DescriptionListItemDescription.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]) -}; - -DescriptionListItemDescription.defaultProps = { - className: styles.description -}; - -export default DescriptionListItemDescription; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemDescription.tsx b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.tsx new file mode 100644 index 00000000000..e08c117dc84 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemDescription.tsx @@ -0,0 +1,17 @@ +import React, { ReactNode } from 'react'; +import styles from './DescriptionListItemDescription.css'; + +export interface DescriptionListItemDescriptionProps { + className?: string; + children?: ReactNode; +} + +function DescriptionListItemDescription( + props: DescriptionListItemDescriptionProps +) { + const { className = styles.description, children } = props; + + return
{children}
; +} + +export default DescriptionListItemDescription; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css.d.ts b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css.d.ts new file mode 100644 index 00000000000..86bceec0622 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'title': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js deleted file mode 100644 index e1632c1cfef..00000000000 --- a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.js +++ /dev/null @@ -1,27 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './DescriptionListItemTitle.css'; - -function DescriptionListItemTitle(props) { - const { - className, - children - } = props; - - return ( -
- {children} -
- ); -} - -DescriptionListItemTitle.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.string -}; - -DescriptionListItemTitle.defaultProps = { - className: styles.title -}; - -export default DescriptionListItemTitle; diff --git a/frontend/src/Components/DescriptionList/DescriptionListItemTitle.tsx b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.tsx new file mode 100644 index 00000000000..59ea6955c05 --- /dev/null +++ b/frontend/src/Components/DescriptionList/DescriptionListItemTitle.tsx @@ -0,0 +1,15 @@ +import React, { ReactNode } from 'react'; +import styles from './DescriptionListItemTitle.css'; + +export interface DescriptionListItemTitleProps { + className?: string; + children?: ReactNode; +} + +function DescriptionListItemTitle(props: DescriptionListItemTitleProps) { + const { className = styles.title, children } = props; + + return
{children}
; +} + +export default DescriptionListItemTitle; diff --git a/frontend/src/Components/DragPreviewLayer.css.d.ts b/frontend/src/Components/DragPreviewLayer.css.d.ts new file mode 100644 index 00000000000..6944a829d15 --- /dev/null +++ b/frontend/src/Components/DragPreviewLayer.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'dragLayer': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/DragPreviewLayer.js b/frontend/src/Components/DragPreviewLayer.js deleted file mode 100644 index a111df70e4c..00000000000 --- a/frontend/src/Components/DragPreviewLayer.js +++ /dev/null @@ -1,22 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './DragPreviewLayer.css'; - -function DragPreviewLayer({ children, ...otherProps }) { - return ( -
- {children} -
- ); -} - -DragPreviewLayer.propTypes = { - children: PropTypes.node, - className: PropTypes.string -}; - -DragPreviewLayer.defaultProps = { - className: styles.dragLayer -}; - -export default DragPreviewLayer; diff --git a/frontend/src/Components/DragPreviewLayer.tsx b/frontend/src/Components/DragPreviewLayer.tsx new file mode 100644 index 00000000000..2e578504bc8 --- /dev/null +++ b/frontend/src/Components/DragPreviewLayer.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import styles from './DragPreviewLayer.css'; + +interface DragPreviewLayerProps { + className?: string; + children?: React.ReactNode; +} + +function DragPreviewLayer({ + className = styles.dragLayer, + children, + ...otherProps +}: DragPreviewLayerProps) { + return ( +
+ {children} +
+ ); +} + +export default DragPreviewLayer; diff --git a/frontend/src/Components/Error/ErrorBoundary.js b/frontend/src/Components/Error/ErrorBoundary.js deleted file mode 100644 index 88412ad19a9..00000000000 --- a/frontend/src/Components/Error/ErrorBoundary.js +++ /dev/null @@ -1,62 +0,0 @@ -import * as sentry from '@sentry/browser'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; - -class ErrorBoundary extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - error: null, - info: null - }; - } - - componentDidCatch(error, info) { - this.setState({ - error, - info - }); - - sentry.captureException(error); - } - - // - // Render - - render() { - const { - children, - errorComponent: ErrorComponent, - ...otherProps - } = this.props; - - const { - error, - info - } = this.state; - - if (error) { - return ( - - ); - } - - return children; - } -} - -ErrorBoundary.propTypes = { - children: PropTypes.node.isRequired, - errorComponent: PropTypes.elementType.isRequired -}; - -export default ErrorBoundary; diff --git a/frontend/src/Components/Error/ErrorBoundary.tsx b/frontend/src/Components/Error/ErrorBoundary.tsx new file mode 100644 index 00000000000..6b27f7a093d --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundary.tsx @@ -0,0 +1,46 @@ +import * as sentry from '@sentry/browser'; +import React, { Component, ErrorInfo } from 'react'; + +interface ErrorBoundaryProps { + children: React.ReactNode; + errorComponent: React.ElementType; +} + +interface ErrorBoundaryState { + error: Error | null; + info: ErrorInfo | null; +} + +// Class component until componentDidCatch is supported in functional components +class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + + this.state = { + error: null, + info: null, + }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + this.setState({ + error, + info, + }); + + sentry.captureException(error); + } + + render() { + const { children, errorComponent: ErrorComponent } = this.props; + const { error, info } = this.state; + + if (error) { + return ; + } + + return children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/Components/Error/ErrorBoundaryError.css b/frontend/src/Components/Error/ErrorBoundaryError.css index b6d1f917e64..3e7a0430278 100644 --- a/frontend/src/Components/Error/ErrorBoundaryError.css +++ b/frontend/src/Components/Error/ErrorBoundaryError.css @@ -25,6 +25,10 @@ white-space: pre-wrap; } +.version { + margin-top: 20px; +} + @media only screen and (max-width: $breakpointMedium) { .image { height: 250px; diff --git a/frontend/src/Components/Error/ErrorBoundaryError.css.d.ts b/frontend/src/Components/Error/ErrorBoundaryError.css.d.ts new file mode 100644 index 00000000000..e19fd804dcb --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundaryError.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'container': string; + 'details': string; + 'image': string; + 'imageContainer': string; + 'message': string; + 'version': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Error/ErrorBoundaryError.js b/frontend/src/Components/Error/ErrorBoundaryError.js deleted file mode 100644 index e0181db9634..00000000000 --- a/frontend/src/Components/Error/ErrorBoundaryError.js +++ /dev/null @@ -1,60 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './ErrorBoundaryError.css'; - -function ErrorBoundaryError(props) { - const { - className, - messageClassName, - detailsClassName, - message, - error, - info - } = props; - - return ( -
-
- {message} -
- -
- -
- -
- { - error && -
- {error.toString()} -
- } - -
- {info.componentStack} -
-
-
- ); -} - -ErrorBoundaryError.propTypes = { - className: PropTypes.string.isRequired, - messageClassName: PropTypes.string.isRequired, - detailsClassName: PropTypes.string.isRequired, - message: PropTypes.string.isRequired, - error: PropTypes.object.isRequired, - info: PropTypes.object.isRequired -}; - -ErrorBoundaryError.defaultProps = { - className: styles.container, - messageClassName: styles.message, - detailsClassName: styles.details, - message: 'There was an error loading this content' -}; - -export default ErrorBoundaryError; diff --git a/frontend/src/Components/Error/ErrorBoundaryError.tsx b/frontend/src/Components/Error/ErrorBoundaryError.tsx new file mode 100644 index 00000000000..870b280589e --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundaryError.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useState } from 'react'; +import StackTrace from 'stacktrace-js'; +import translate from 'Utilities/String/translate'; +import styles from './ErrorBoundaryError.css'; + +interface ErrorBoundaryErrorProps { + className: string; + messageClassName: string; + detailsClassName: string; + message: string; + error: Error; + info: { + componentStack: string; + }; +} + +function ErrorBoundaryError(props: ErrorBoundaryErrorProps) { + const { + className = styles.container, + messageClassName = styles.message, + detailsClassName = styles.details, + message = translate('ErrorLoadingContent'), + error, + info, + } = props; + + const [detailedError, setDetailedError] = useState< + StackTrace.StackFrame[] | null + >(null); + + useEffect(() => { + if (error) { + StackTrace.fromError(error).then((de) => { + setDetailedError(de); + }); + } else { + setDetailedError(null); + } + }, [error, setDetailedError]); + + return ( +
+
{message}
+ +
+ +
+ +
+ {error ?
{error.message}
: null} + + {detailedError ? ( + detailedError.map((d, index) => { + return ( +
+ {` at ${d.functionName} (${d.fileName}:${d.lineNumber}:${d.columnNumber})`} +
+ ); + }) + ) : ( +
{info.componentStack}
+ )} + +
Version: {window.Sonarr.version}
+
+
+ ); +} + +export default ErrorBoundaryError; diff --git a/frontend/src/Components/FieldSet.css.d.ts b/frontend/src/Components/FieldSet.css.d.ts new file mode 100644 index 00000000000..74e99779a5f --- /dev/null +++ b/frontend/src/Components/FieldSet.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'fieldSet': string; + 'legend': string; + 'small': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/FieldSet.js b/frontend/src/Components/FieldSet.js deleted file mode 100644 index 8243fd00c9c..00000000000 --- a/frontend/src/Components/FieldSet.js +++ /dev/null @@ -1,41 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { sizes } from 'Helpers/Props'; -import styles from './FieldSet.css'; - -class FieldSet extends Component { - - // - // Render - - render() { - const { - size, - legend, - children - } = this.props; - - return ( -
- - {legend} - - {children} -
- ); - } - -} - -FieldSet.propTypes = { - size: PropTypes.oneOf(sizes.all).isRequired, - legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), - children: PropTypes.node -}; - -FieldSet.defaultProps = { - size: sizes.MEDIUM -}; - -export default FieldSet; diff --git a/frontend/src/Components/FieldSet.tsx b/frontend/src/Components/FieldSet.tsx new file mode 100644 index 00000000000..c2ff03a7f58 --- /dev/null +++ b/frontend/src/Components/FieldSet.tsx @@ -0,0 +1,29 @@ +import classNames from 'classnames'; +import React, { ComponentProps } from 'react'; +import { sizes } from 'Helpers/Props'; +import { Size } from 'Helpers/Props/sizes'; +import styles from './FieldSet.css'; + +interface FieldSetProps { + size?: Size; + legend?: ComponentProps<'legend'>['children']; + children?: React.ReactNode; +} + +function FieldSet({ size = sizes.MEDIUM, legend, children }: FieldSetProps) { + return ( +
+ + {legend} + + {children} +
+ ); +} + +export default FieldSet; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.css.d.ts b/frontend/src/Components/FileBrowser/FileBrowserModal.css.d.ts new file mode 100644 index 00000000000..5d00cca7ea5 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModal.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modal': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.js b/frontend/src/Components/FileBrowser/FileBrowserModal.js deleted file mode 100644 index 6b58dbb8c2a..00000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserModal.js +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Modal from 'Components/Modal/Modal'; -import FileBrowserModalContentConnector from './FileBrowserModalContentConnector'; -import styles from './FileBrowserModal.css'; - -class FileBrowserModal extends Component { - - // - // Render - - render() { - const { - isOpen, - onModalClose, - ...otherProps - } = this.props; - - return ( - - - - ); - } -} - -FileBrowserModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default FileBrowserModal; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModal.tsx b/frontend/src/Components/FileBrowser/FileBrowserModal.tsx new file mode 100644 index 00000000000..0925890de23 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModal.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import FileBrowserModalContent, { + FileBrowserModalContentProps, +} from './FileBrowserModalContent'; +import styles from './FileBrowserModal.css'; + +interface FileBrowserModalProps extends FileBrowserModalContentProps { + isOpen: boolean; + onModalClose: () => void; +} + +function FileBrowserModal(props: FileBrowserModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default FileBrowserModal; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.css.d.ts b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css.d.ts new file mode 100644 index 00000000000..e83c1307526 --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'faqLink': string; + 'loading': string; + 'mappedDrivesWarning': string; + 'modalBody': string; + 'pathInput': string; + 'scroller': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js b/frontend/src/Components/FileBrowser/FileBrowserModalContent.js deleted file mode 100644 index 2fbeaaf8417..00000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserModalContent.js +++ /dev/null @@ -1,257 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ReactDOM from 'react-dom'; -import Alert from 'Components/Alert'; -import PathInput from 'Components/Form/PathInput'; -import Button from 'Components/Link/Button'; -import Link from 'Components/Link/Link'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import 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 FileBrowserRow from './FileBrowserRow'; -import styles from './FileBrowserModalContent.css'; - -const columns = [ - { - name: 'type', - label: 'Type', - isVisible: true - }, - { - name: 'name', - label: 'Name', - isVisible: true - } -]; - -class FileBrowserModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scrollerNode = null; - - this.state = { - isFileBrowserModalOpen: false, - currentPath: props.value - }; - } - - componentDidUpdate(prevProps, prevState) { - const { - currentPath - } = this.props; - - if ( - currentPath !== this.state.currentPath && - currentPath !== prevState.currentPath - ) { - this.setState({ currentPath }); - this._scrollerNode.scrollTop = 0; - } - } - - // - // Control - - setScrollerRef = (ref) => { - if (ref) { - this._scrollerNode = ReactDOM.findDOMNode(ref); - } else { - this._scrollerNode = null; - } - }; - - // - // Listeners - - onPathInputChange = ({ value }) => { - this.setState({ currentPath: value }); - }; - - onRowPress = (path) => { - this.props.onFetchPaths(path); - }; - - onOkPress = () => { - this.props.onChange({ - name: this.props.name, - value: this.state.currentPath - }); - - this.props.onClearPaths(); - this.props.onModalClose(); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - parent, - directories, - files, - isWindowsService, - onModalClose, - ...otherProps - } = this.props; - - const emptyParent = parent === ''; - - return ( - - - File Browser - - - - { - isWindowsService && - - Mapped network drives are not available when running as a Windows Service, see the FAQ for more information. - - } - - - - - { - !!error && -
Error loading contents
- } - - { - isPopulated && !error && - - - { - emptyParent && - - } - - { - !emptyParent && parent && - - } - - { - directories.map((directory) => { - return ( - - ); - }) - } - - { - files.map((file) => { - return ( - - ); - }) - } - -
- } -
-
- - - { - isFetching && - - } - - - - - -
- ); - } -} - -FileBrowserModalContent.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - parent: PropTypes.string, - currentPath: PropTypes.string.isRequired, - directories: PropTypes.arrayOf(PropTypes.object).isRequired, - files: PropTypes.arrayOf(PropTypes.object).isRequired, - isWindowsService: PropTypes.bool.isRequired, - onFetchPaths: PropTypes.func.isRequired, - onClearPaths: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default FileBrowserModalContent; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx new file mode 100644 index 00000000000..41338cb39be --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx @@ -0,0 +1,237 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Alert from 'Components/Alert'; +import { PathInputInternal } from 'Components/Form/PathInput'; +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 Scroller from 'Components/Scroller/Scroller'; +import Column from 'Components/Table/Column'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { kinds, scrollDirections } from 'Helpers/Props'; +import { clearPaths, fetchPaths } from 'Store/Actions/pathActions'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import { InputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; +import createPathsSelector from './createPathsSelector'; +import FileBrowserRow from './FileBrowserRow'; +import styles from './FileBrowserModalContent.css'; + +const columns: Column[] = [ + { + name: 'type', + label: () => translate('Type'), + isVisible: true, + }, + { + name: 'name', + label: () => translate('Name'), + isVisible: true, + }, +]; + +const handleClearPaths = () => {}; + +export interface FileBrowserModalContentProps { + name: string; + value: string; + includeFiles?: boolean; + onChange: (args: InputChanged) => unknown; + onModalClose: () => void; +} + +function FileBrowserModalContent(props: FileBrowserModalContentProps) { + const { name, value, includeFiles = true, onChange, onModalClose } = props; + + const dispatch = useDispatch(); + + const { isWindows, mode } = useSelector(createSystemStatusSelector()); + const { isFetching, isPopulated, error, parent, directories, files, paths } = + useSelector(createPathsSelector()); + + const [currentPath, setCurrentPath] = useState(value); + const scrollerRef = useRef(null); + const previousValue = usePrevious(value); + + const emptyParent = parent === ''; + const isWindowsService = isWindows && mode === 'service'; + + const handlePathInputChange = useCallback( + ({ value }: InputChanged) => { + setCurrentPath(value); + }, + [] + ); + + const handleRowPress = useCallback( + (path: string) => { + setCurrentPath(path); + + dispatch( + fetchPaths({ + path, + allowFoldersWithoutTrailingSlashes: true, + includeFiles, + }) + ); + }, + [includeFiles, dispatch, setCurrentPath] + ); + + const handleOkPress = useCallback(() => { + onChange({ + name, + value: currentPath, + }); + + dispatch(clearPaths()); + onModalClose(); + }, [name, currentPath, dispatch, onChange, onModalClose]); + + const handleFetchPaths = useCallback( + (path: string) => { + dispatch( + fetchPaths({ + path, + allowFoldersWithoutTrailingSlashes: true, + includeFiles, + }) + ); + }, + [includeFiles, dispatch] + ); + + useEffect(() => { + if (value !== previousValue && value !== currentPath) { + setCurrentPath(value); + } + }, [value, previousValue, currentPath, setCurrentPath]); + + useEffect( + () => { + dispatch( + fetchPaths({ + path: currentPath, + allowFoldersWithoutTrailingSlashes: true, + includeFiles, + }) + ); + + return () => { + dispatch(clearPaths()); + }; + }, + // This should only run once when the component mounts, + // so we don't need to include the other dependencies. + // eslint-disable-next-line react-hooks/exhaustive-deps + [dispatch] + ); + + return ( + + {translate('FileBrowser')} + + + {isWindowsService ? ( + + + + ) : null} + + + + + {error ?
{translate('ErrorLoadingContents')}
: null} + + {isPopulated && !error ? ( + + + {emptyParent ? ( + + ) : null} + + {!emptyParent && parent ? ( + + ) : null} + + {directories.map((directory) => { + return ( + + ); + })} + + {files.map((file) => { + return ( + + ); + })} + +
+ ) : null} +
+
+ + + {isFetching ? ( + + ) : null} + + + + + +
+ ); +} + +export default FileBrowserModalContent; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js deleted file mode 100644 index 1a3a41ef0af..00000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js +++ /dev/null @@ -1,119 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { clearPaths, fetchPaths } from 'Store/Actions/pathActions'; -import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; -import FileBrowserModalContent from './FileBrowserModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.paths, - createSystemStatusSelector(), - (paths, systemStatus) => { - const { - isFetching, - isPopulated, - error, - parent, - currentPath, - directories, - files - } = paths; - - const filteredPaths = _.filter([...directories, ...files], ({ path }) => { - return path.toLowerCase().startsWith(currentPath.toLowerCase()); - }); - - return { - isFetching, - isPopulated, - error, - parent, - currentPath, - directories, - files, - paths: filteredPaths, - isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service' - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchPaths: fetchPaths, - dispatchClearPaths: clearPaths -}; - -class FileBrowserModalContentConnector extends Component { - - // Lifecycle - - componentDidMount() { - const { - value, - includeFiles, - dispatchFetchPaths - } = this.props; - - dispatchFetchPaths({ - path: value, - allowFoldersWithoutTrailingSlashes: true, - includeFiles - }); - } - - // - // Listeners - - onFetchPaths = (path) => { - const { - includeFiles, - dispatchFetchPaths - } = this.props; - - dispatchFetchPaths({ - path, - allowFoldersWithoutTrailingSlashes: true, - includeFiles - }); - }; - - onClearPaths = () => { - // this.props.dispatchClearPaths(); - }; - - onModalClose = () => { - this.props.dispatchClearPaths(); - this.props.onModalClose(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -FileBrowserModalContentConnector.propTypes = { - value: PropTypes.string, - includeFiles: PropTypes.bool.isRequired, - dispatchFetchPaths: PropTypes.func.isRequired, - dispatchClearPaths: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -FileBrowserModalContentConnector.defaultProps = { - includeFiles: false -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector); diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.css.d.ts b/frontend/src/Components/FileBrowser/FileBrowserRow.css.d.ts new file mode 100644 index 00000000000..127d009287d --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserRow.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'type': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.js b/frontend/src/Components/FileBrowser/FileBrowserRow.js deleted file mode 100644 index 06bb3029dd4..00000000000 --- a/frontend/src/Components/FileBrowser/FileBrowserRow.js +++ /dev/null @@ -1,62 +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 styles from './FileBrowserRow.css'; - -function getIconName(type) { - switch (type) { - case 'computer': - return icons.COMPUTER; - case 'drive': - return icons.DRIVE; - case 'file': - return icons.FILE; - case 'parent': - return icons.PARENT; - default: - return icons.FOLDER; - } -} - -class FileBrowserRow extends Component { - - // - // Listeners - - onPress = () => { - this.props.onPress(this.props.path); - }; - - // - // Render - - render() { - const { - type, - name - } = this.props; - - return ( - - - - - - {name} - - ); - } - -} - -FileBrowserRow.propTypes = { - type: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - path: PropTypes.string.isRequired, - onPress: PropTypes.func.isRequired -}; - -export default FileBrowserRow; diff --git a/frontend/src/Components/FileBrowser/FileBrowserRow.tsx b/frontend/src/Components/FileBrowser/FileBrowserRow.tsx new file mode 100644 index 00000000000..fe47f1664fe --- /dev/null +++ b/frontend/src/Components/FileBrowser/FileBrowserRow.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react'; +import { PathType } from 'App/State/PathsAppState'; +import Icon from 'Components/Icon'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableRowButton from 'Components/Table/TableRowButton'; +import { icons } from 'Helpers/Props'; +import styles from './FileBrowserRow.css'; + +function getIconName(type: PathType) { + switch (type) { + case 'computer': + return icons.COMPUTER; + case 'drive': + return icons.DRIVE; + case 'file': + return icons.FILE; + case 'parent': + return icons.PARENT; + default: + return icons.FOLDER; + } +} + +interface FileBrowserRowProps { + type: PathType; + name: string; + path: string; + onPress: (path: string) => void; +} + +function FileBrowserRow(props: FileBrowserRowProps) { + const { type, name, path, onPress } = props; + + const handlePress = useCallback(() => { + onPress(path); + }, [path, onPress]); + + return ( + + + + + + {name} + + ); +} + +export default FileBrowserRow; diff --git a/frontend/src/Components/FileBrowser/createPathsSelector.ts b/frontend/src/Components/FileBrowser/createPathsSelector.ts new file mode 100644 index 00000000000..5da830bd5e6 --- /dev/null +++ b/frontend/src/Components/FileBrowser/createPathsSelector.ts @@ -0,0 +1,36 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +function createPathsSelector() { + return createSelector( + (state: AppState) => state.paths, + (paths) => { + const { + isFetching, + isPopulated, + error, + parent, + currentPath, + directories, + files, + } = paths; + + const filteredPaths = [...directories, ...files].filter(({ path }) => { + return path.toLowerCase().startsWith(currentPath.toLowerCase()); + }); + + return { + isFetching, + isPopulated, + error, + parent, + currentPath, + directories, + files, + paths: filteredPaths, + }; + } + ); +} + +export default createPathsSelector; diff --git a/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css.d.ts b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css.d.ts new file mode 100644 index 00000000000..d391a1f3092 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/DateFilterBuilderRowValue.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'container': string; + 'numberInput': string; + 'selectInput': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css.d.ts b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css.d.ts new file mode 100644 index 00000000000..033d2edca56 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'label': string; + 'labelContainer': string; + 'labelInputContainer': string; + 'rows': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js index d718aab0cf5..0c4a31657b2 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'; @@ -8,6 +9,7 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; import FilterBuilderRow from './FilterBuilderRow'; import styles from './FilterBuilderModalContent.css'; @@ -49,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 }); } @@ -107,7 +109,7 @@ class FilterBuilderModalContent extends Component { this.setState({ labelErrors: [ { - message: 'Label is required' + message: translate('LabelIsRequired') } ] }); @@ -145,13 +147,13 @@ class FilterBuilderModalContent extends Component { return ( - Custom Filter + {translate('CustomFilter')}
- Label + {translate('Label')}
@@ -165,7 +167,9 @@ class FilterBuilderModalContent extends Component {
-
Filters
+
+ {translate('Filters')} +
{ @@ -192,7 +196,7 @@ class FilterBuilderModalContent extends Component { - Save + {translate('Save')} diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.css.d.ts b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css.d.ts new file mode 100644 index 00000000000..aba698af493 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'actionsContainer': string; + 'filterRow': string; + 'inputContainer': string; + 'valueInputContainer': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js index 491829434fd..0b00c0f03e7 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRow.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRow.js @@ -3,13 +3,19 @@ import React, { Component } from 'react'; import SelectInput from 'Components/Form/SelectInput'; import IconButton from 'Components/Link/IconButton'; import { filterBuilderTypes, filterBuilderValueTypes, icons } from 'Helpers/Props'; +import sortByProp from 'Utilities/Array/sortByProp'; import BoolFilterBuilderRowValue from './BoolFilterBuilderRowValue'; import DateFilterBuilderRowValue from './DateFilterBuilderRowValue'; import FilterBuilderRowValueConnector from './FilterBuilderRowValueConnector'; +import HistoryEventTypeFilterBuilderRowValue from './HistoryEventTypeFilterBuilderRowValue'; import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValueConnector'; +import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue'; import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue'; import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector'; -import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector'; +import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue'; +import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue'; +import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue'; +import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue'; import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue'; import SeriesTypeFilterBuilderRowValue from './SeriesTypeFilterBuilderRowValue'; import TagFilterBuilderRowValueConnector from './TagFilterBuilderRowValueConnector'; @@ -57,9 +63,15 @@ function getRowValueConnector(selectedFilterBuilderProp) { case filterBuilderValueTypes.DATE: return DateFilterBuilderRowValue; + case filterBuilderValueTypes.HISTORY_EVENT_TYPE: + return HistoryEventTypeFilterBuilderRowValue; + case filterBuilderValueTypes.INDEXER: return IndexerFilterBuilderRowValueConnector; + case filterBuilderValueTypes.LANGUAGE: + return LanguageFilterBuilderRowValue; + case filterBuilderValueTypes.PROTOCOL: return ProtocolFilterBuilderRowValue; @@ -67,7 +79,16 @@ function getRowValueConnector(selectedFilterBuilderProp) { return QualityFilterBuilderRowValueConnector; case filterBuilderValueTypes.QUALITY_PROFILE: - return QualityProfileFilterBuilderRowValueConnector; + return QualityProfileFilterBuilderRowValue; + + case filterBuilderValueTypes.QUEUE_STATUS: + return QueueStatusFilterBuilderRowValue; + + case filterBuilderValueTypes.SEASONS_MONITORED_STATUS: + return SeasonsMonitoredStatusFilterBuilderRowValue; + + case filterBuilderValueTypes.SERIES: + return SeriesFilterBuilderRowValue; case filterBuilderValueTypes.SERIES_STATUS: return SeriesStatusFilterBuilderRowValue; @@ -206,11 +227,13 @@ class FilterBuilderRow extends Component { const selectedFilterBuilderProp = this.selectedFilterBuilderProp; const keyOptions = filterBuilderProps.map((availablePropFilter) => { + const { name, label } = availablePropFilter; + return { - key: availablePropFilter.name, - value: availablePropFilter.label + 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/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js index 68fa5c557e0..217626c90a1 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import TagInput from 'Components/Form/TagInput'; +import TagInput from 'Components/Form/Tag/TagInput'; import { filterBuilderTypes, filterBuilderValueTypes, kinds } from 'Helpers/Props'; import tagShape from 'Helpers/Props/Shapes/tagShape'; import convertToBytes from 'Utilities/Number/convertToBytes'; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueConnector.js index a7aed80b6be..d1419327a23 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 00000000000..5bf9e57851d --- /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/FilterBuilderRowValueTag.css.d.ts b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css.d.ts new file mode 100644 index 00000000000..80bcf146406 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'isLastTag': string; + 'label': string; + 'or': string; + 'tag': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js index 6b5846594cf..063a973466c 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValueTag.js @@ -1,7 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; -import TagInputTag from 'Components/Form/TagInputTag'; +import TagInputTag from 'Components/Form/Tag/TagInputTag'; import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; import styles from './FilterBuilderRowValueTag.css'; function FilterBuilderRowValueTag(props) { @@ -18,7 +19,7 @@ function FilterBuilderRowValueTag(props) { props.isLastTag ? null :
- or + {translate('Or')}
}
diff --git a/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx new file mode 100644 index 00000000000..4ecddf64627 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/HistoryEventTypeFilterBuilderRowValue.tsx @@ -0,0 +1,51 @@ +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('Imported'); + }, + }, + { + id: 4, + get name() { + return translate('Failed'); + }, + }, + { + id: 5, + get name() { + return translate('Deleted'); + }, + }, + { + id: 6, + get name() { + return translate('Renamed'); + }, + }, + { + id: 7, + get name() { + return translate('Ignored'); + }, + }, +]; + +function HistoryEventTypeFilterBuilderRowValue( + props: FilterBuilderRowValueProps +) { + return ; +} + +export default HistoryEventTypeFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/LanguageFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/LanguageFilterBuilderRowValue.tsx new file mode 100644 index 00000000000..e828fd8483a --- /dev/null +++ b/frontend/src/Components/Filter/Builder/LanguageFilterBuilderRowValue.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import createLanguagesSelector from 'Store/Selectors/createLanguagesSelector'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; +import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; + +function LanguageFilterBuilderRowValue(props: FilterBuilderRowValueProps) { + const { items } = useSelector(createLanguagesSelector()); + + return ; +} + +export default LanguageFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx new file mode 100644 index 00000000000..50036cb90c2 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValue.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps'; +import sortByProp from 'Utilities/Array/sortByProp'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +function createQualityProfilesSelector() { + return createSelector( + (state: AppState) => state.settings.qualityProfiles.items, + (qualityProfiles) => { + return qualityProfiles; + } + ); +} + +function QualityProfileFilterBuilderRowValue( + props: FilterBuilderRowValueProps +) { + const qualityProfiles = useSelector(createQualityProfilesSelector()); + + const tagList = qualityProfiles + .map(({ id, name }) => ({ id, name })) + .sort(sortByProp('name')); + + return ; +} + +export default QualityProfileFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js deleted file mode 100644 index 4a8b82283d1..00000000000 --- a/frontend/src/Components/Filter/Builder/QualityProfileFilterBuilderRowValueConnector.js +++ /dev/null @@ -1,28 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import FilterBuilderRowValue from './FilterBuilderRowValue'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.qualityProfiles, - (qualityProfiles) => { - const tagList = qualityProfiles.items.map((qualityProfile) => { - const { - id, - name - } = qualityProfile; - - return { - id, - name - }; - }); - - return { - tagList - }; - } - ); -} - -export default connect(createMapStateToProps)(FilterBuilderRowValue); diff --git a/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx new file mode 100644 index 00000000000..1127493a5c3 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/QueueStatusFilterBuilderRowValue.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import translate from 'Utilities/String/translate'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; +import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; + +const statusTagList = [ + { + id: 'queued', + get name() { + return translate('Queued'); + }, + }, + { + id: 'paused', + get name() { + return translate('Paused'); + }, + }, + { + id: 'downloading', + get name() { + return translate('Downloading'); + }, + }, + { + id: 'completed', + get name() { + return translate('Completed'); + }, + }, + { + id: 'failed', + get name() { + return translate('Failed'); + }, + }, + { + id: 'warning', + get name() { + return translate('Warning'); + }, + }, + { + id: 'delay', + get name() { + return translate('Delay'); + }, + }, + { + id: 'downloadClientUnavailable', + get name() { + return translate('DownloadClientUnavailable'); + }, + }, + { + id: 'fallback', + get name() { + return translate('Fallback'); + }, + }, +]; + +function QueueStatusFilterBuilderRowValue(props: FilterBuilderRowValueProps) { + return ; +} + +export default QueueStatusFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js new file mode 100644 index 00000000000..b84260e3c9f --- /dev/null +++ b/frontend/src/Components/Filter/Builder/SeasonsMonitoredStatusFilterBuilderRowValue.js @@ -0,0 +1,35 @@ +import React from 'react'; +import translate from 'Utilities/String/translate'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; + +const seasonsMonitoredStatusList = [ + { + id: 'all', + get name() { + return translate('SeasonsMonitoredAll'); + } + }, + { + id: 'partial', + get name() { + return translate('SeasonsMonitoredPartial'); + } + }, + { + id: 'none', + get name() { + return translate('SeasonsMonitoredNone'); + } + } +]; + +function SeasonsMonitoredStatusFilterBuilderRowValue(props) { + return ( + + ); +} + +export default SeasonsMonitoredStatusFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx b/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx new file mode 100644 index 00000000000..88b34509ad7 --- /dev/null +++ b/frontend/src/Components/Filter/Builder/SeriesFilterBuilderRowValue.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import Series from 'Series/Series'; +import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector'; +import sortByProp from 'Utilities/Array/sortByProp'; +import FilterBuilderRowValue from './FilterBuilderRowValue'; +import FilterBuilderRowValueProps from './FilterBuilderRowValueProps'; + +function SeriesFilterBuilderRowValue(props: FilterBuilderRowValueProps) { + const allSeries: Series[] = useSelector(createAllSeriesSelector()); + + const tagList = allSeries + .map((series) => ({ id: series.id, name: series.title })) + .sort(sortByProp('name')); + + return ; +} + +export default SeriesFilterBuilderRowValue; diff --git a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js index b52cb489972..e017f72e745 100644 --- a/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js +++ b/frontend/src/Components/Filter/Builder/SeriesStatusFilterBuilderRowValue.js @@ -1,16 +1,38 @@ import React from 'react'; +import translate from 'Utilities/String/translate'; import FilterBuilderRowValue from './FilterBuilderRowValue'; -const seriesStatusList = [ - { id: 'continuing', name: 'Continuing' }, - { id: 'upcoming', name: 'Upcoming' }, - { id: 'ended', name: 'Ended' } +const statusTagList = [ + { + id: 'continuing', + get name() { + return translate('Continuing'); + } + }, + { + id: 'upcoming', + get name() { + return translate('Upcoming'); + } + }, + { + id: 'ended', + get name() { + return translate('Ended'); + } + }, + { + id: 'deleted', + get name() { + return translate('Deleted'); + } + } ]; function SeriesStatusFilterBuilderRowValue(props) { return ( ); diff --git a/frontend/src/Components/Filter/Builder/SeriesTypeFilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/SeriesTypeFilterBuilderRowValue.js index 263c9e9daf6..2e62e558d1c 100644 --- a/frontend/src/Components/Filter/Builder/SeriesTypeFilterBuilderRowValue.js +++ b/frontend/src/Components/Filter/Builder/SeriesTypeFilterBuilderRowValue.js @@ -1,10 +1,26 @@ import React from 'react'; +import translate from 'Utilities/String/translate'; import FilterBuilderRowValue from './FilterBuilderRowValue'; const seriesTypeList = [ - { id: 'anime', name: 'Anime' }, - { id: 'daily', name: 'Daily' }, - { id: 'standard', name: 'Standard' } + { + id: 'anime', + get name() { + return translate('Anime'); + } + }, + { + id: 'daily', + get name() { + return translate('Daily'); + } + }, + { + id: 'standard', + get name() { + return translate('Standard'); + } + } ]; function SeriesTypeFilterBuilderRowValue(props) { diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.css.d.ts b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css.d.ts new file mode 100644 index 00000000000..af5bfa9677e --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'actions': string; + 'customFilter': string; + 'label': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js index e87d088b364..9f378d5a2aa 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import IconButton from 'Components/Link/IconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; import styles from './CustomFilter.css'; class CustomFilter extends Component { @@ -36,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' }); @@ -89,7 +90,7 @@ class CustomFilter extends Component { /> - Custom Filters + {translate('CustomFilters')} { - customFilters.map((customFilter) => { - return ( - - ); - }) + customFilters + .sort((a, b) => sortByProp(a, b, 'label')) + .map((customFilter) => { + return ( + + ); + }) }
@@ -58,7 +62,7 @@ function CustomFiltersModalContent(props) {
diff --git a/frontend/src/Components/Form/AutoCompleteInput.js b/frontend/src/Components/Form/AutoCompleteInput.js deleted file mode 100644 index d35969c4c37..00000000000 --- a/frontend/src/Components/Form/AutoCompleteInput.js +++ /dev/null @@ -1,98 +0,0 @@ -import jdu from 'jdu'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import AutoSuggestInput from './AutoSuggestInput'; - -class AutoCompleteInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - suggestions: [] - }; - } - - // - // Control - - getSuggestionValue(item) { - return item; - } - - renderSuggestion(item) { - return item; - } - - // - // Listeners - - onInputChange = (event, { newValue }) => { - this.props.onChange({ - name: this.props.name, - value: newValue - }); - }; - - onInputBlur = () => { - this.setState({ suggestions: [] }); - }; - - onSuggestionsFetchRequested = ({ value }) => { - const { values } = this.props; - const lowerCaseValue = jdu.replace(value).toLowerCase(); - - const filteredValues = values.filter((v) => { - return jdu.replace(v).toLowerCase().contains(lowerCaseValue); - }); - - this.setState({ suggestions: filteredValues }); - }; - - onSuggestionsClearRequested = () => { - this.setState({ suggestions: [] }); - }; - - // - // Render - - render() { - const { - name, - value, - ...otherProps - } = this.props; - - const { suggestions } = this.state; - - return ( - - ); - } -} - -AutoCompleteInput.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.string, - values: PropTypes.arrayOf(PropTypes.string).isRequired, - onChange: PropTypes.func.isRequired -}; - -AutoCompleteInput.defaultProps = { - value: '' -}; - -export default AutoCompleteInput; diff --git a/frontend/src/Components/Form/AutoCompleteInput.tsx b/frontend/src/Components/Form/AutoCompleteInput.tsx new file mode 100644 index 00000000000..7ba11412545 --- /dev/null +++ b/frontend/src/Components/Form/AutoCompleteInput.tsx @@ -0,0 +1,81 @@ +import jdu from 'jdu'; +import React, { SyntheticEvent, useCallback, useState } from 'react'; +import { + ChangeEvent, + SuggestionsFetchRequestedParams, +} from 'react-autosuggest'; +import { InputChanged } from 'typings/inputs'; +import AutoSuggestInput from './AutoSuggestInput'; + +interface AutoCompleteInputProps { + name: string; + value?: string; + values: string[]; + onChange: (change: InputChanged) => unknown; +} + +function AutoCompleteInput({ + name, + value = '', + values, + onChange, + ...otherProps +}: AutoCompleteInputProps) { + const [suggestions, setSuggestions] = useState([]); + + const getSuggestionValue = useCallback((item: string) => { + return item; + }, []); + + const renderSuggestion = useCallback((item: string) => { + return item; + }, []); + + const handleInputChange = useCallback( + (_event: SyntheticEvent, { newValue }: ChangeEvent) => { + onChange({ + name, + value: newValue, + }); + }, + [name, onChange] + ); + + const handleInputBlur = useCallback(() => { + setSuggestions([]); + }, [setSuggestions]); + + const handleSuggestionsFetchRequested = useCallback( + ({ value: newValue }: SuggestionsFetchRequestedParams) => { + const lowerCaseValue = jdu.replace(newValue).toLowerCase(); + + const filteredValues = values.filter((v) => { + return jdu.replace(v).toLowerCase().includes(lowerCaseValue); + }); + + setSuggestions(filteredValues); + }, + [values, setSuggestions] + ); + + const handleSuggestionsClearRequested = useCallback(() => { + setSuggestions([]); + }, [setSuggestions]); + + return ( + + ); +} + +export default AutoCompleteInput; diff --git a/frontend/src/Components/Form/AutoSuggestInput.css.d.ts b/frontend/src/Components/Form/AutoSuggestInput.css.d.ts new file mode 100644 index 00000000000..2b8f51924e5 --- /dev/null +++ b/frontend/src/Components/Form/AutoSuggestInput.css.d.ts @@ -0,0 +1,15 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'hasError': string; + 'hasWarning': string; + 'input': string; + 'inputContainer': string; + 'suggestion': string; + 'suggestionHighlighted': string; + 'suggestionsContainer': string; + 'suggestionsContainerOpen': string; + 'suggestionsList': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Form/AutoSuggestInput.js b/frontend/src/Components/Form/AutoSuggestInput.js deleted file mode 100644 index 34ec7530bc1..00000000000 --- a/frontend/src/Components/Form/AutoSuggestInput.js +++ /dev/null @@ -1,257 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Autosuggest from 'react-autosuggest'; -import { Manager, Popper, Reference } from 'react-popper'; -import Portal from 'Components/Portal'; -import styles from './AutoSuggestInput.css'; - -class AutoSuggestInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scheduleUpdate = null; - } - - componentDidUpdate(prevProps) { - if ( - this._scheduleUpdate && - prevProps.suggestions !== this.props.suggestions - ) { - this._scheduleUpdate(); - } - } - - // - // Control - - renderInputComponent = (inputProps) => { - const { renderInputComponent } = this.props; - - return ( - - {({ ref }) => { - if (renderInputComponent) { - return renderInputComponent(inputProps, ref); - } - - return ( -
- -
- ); - }} -
- ); - }; - - renderSuggestionsContainer = ({ containerProps, children }) => { - return ( - - - {({ ref: popperRef, style, scheduleUpdate }) => { - this._scheduleUpdate = scheduleUpdate; - - return ( -
-
- {children} -
-
- ); - }} -
-
- ); - }; - - // - // Listeners - - onComputeMaxHeight = (data) => { - const { - top, - bottom, - width - } = data.offsets.reference; - - const windowHeight = window.innerHeight; - - if ((/^botton/).test(data.placement)) { - data.styles.maxHeight = windowHeight - bottom; - } else { - data.styles.maxHeight = top; - } - - data.styles.width = width; - - return data; - }; - - onInputChange = (event, { newValue }) => { - this.props.onChange({ - name: this.props.name, - value: newValue - }); - }; - - onInputKeyDown = (event) => { - const { - name, - value, - suggestions, - onChange - } = this.props; - - if ( - event.key === 'Tab' && - suggestions.length && - suggestions[0] !== this.props.value - ) { - event.preventDefault(); - - if (value) { - onChange({ - name, - value: suggestions[0] - }); - } - } - }; - - // - // Render - - render() { - const { - forwardedRef, - className, - inputContainerClassName, - name, - value, - placeholder, - suggestions, - hasError, - hasWarning, - getSuggestionValue, - renderSuggestion, - onInputChange, - onInputKeyDown, - onInputFocus, - onInputBlur, - onSuggestionsFetchRequested, - onSuggestionsClearRequested, - onSuggestionSelected, - ...otherProps - } = this.props; - - const inputProps = { - className: classNames( - className, - hasError && styles.hasError, - hasWarning && styles.hasWarning - ), - name, - value, - placeholder, - autoComplete: 'off', - spellCheck: false, - onChange: onInputChange || this.onInputChange, - onKeyDown: onInputKeyDown || this.onInputKeyDown, - onFocus: onInputFocus, - onBlur: onInputBlur - }; - - const theme = { - container: inputContainerClassName, - containerOpen: styles.suggestionsContainerOpen, - suggestionsContainer: styles.suggestionsContainer, - suggestionsList: styles.suggestionsList, - suggestion: styles.suggestion, - suggestionHighlighted: styles.suggestionHighlighted - }; - - return ( - - - - ); - } -} - -AutoSuggestInput.propTypes = { - forwardedRef: PropTypes.func, - className: PropTypes.string.isRequired, - inputContainerClassName: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), - placeholder: PropTypes.string, - suggestions: PropTypes.array.isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - enforceMaxHeight: PropTypes.bool.isRequired, - minHeight: PropTypes.number.isRequired, - maxHeight: PropTypes.number.isRequired, - getSuggestionValue: PropTypes.func.isRequired, - renderInputComponent: PropTypes.elementType, - renderSuggestion: PropTypes.func.isRequired, - onInputChange: PropTypes.func, - onInputKeyDown: PropTypes.func, - onInputFocus: PropTypes.func, - onInputBlur: PropTypes.func.isRequired, - onSuggestionsFetchRequested: PropTypes.func.isRequired, - onSuggestionsClearRequested: PropTypes.func.isRequired, - onSuggestionSelected: PropTypes.func, - onChange: PropTypes.func.isRequired -}; - -AutoSuggestInput.defaultProps = { - className: styles.input, - inputContainerClassName: styles.inputContainer, - enforceMaxHeight: true, - minHeight: 50, - maxHeight: 200 -}; - -export default AutoSuggestInput; diff --git a/frontend/src/Components/Form/AutoSuggestInput.tsx b/frontend/src/Components/Form/AutoSuggestInput.tsx new file mode 100644 index 00000000000..b3a7c31b0f4 --- /dev/null +++ b/frontend/src/Components/Form/AutoSuggestInput.tsx @@ -0,0 +1,259 @@ +import classNames from 'classnames'; +import React, { + FocusEvent, + FormEvent, + KeyboardEvent, + KeyboardEventHandler, + MutableRefObject, + ReactNode, + Ref, + SyntheticEvent, + useCallback, + useEffect, + useRef, +} from 'react'; +import Autosuggest, { + AutosuggestPropsBase, + BlurEvent, + ChangeEvent, + RenderInputComponentProps, + RenderSuggestionsContainerParams, +} from 'react-autosuggest'; +import { Manager, Popper, Reference } from 'react-popper'; +import Portal from 'Components/Portal'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { InputChanged } from 'typings/inputs'; +import styles from './AutoSuggestInput.css'; + +interface AutoSuggestInputProps + extends Omit, 'renderInputComponent' | 'inputProps'> { + forwardedRef?: MutableRefObject | null>; + className?: string; + inputContainerClassName?: string; + name: string; + value?: string; + placeholder?: string; + suggestions: T[]; + hasError?: boolean; + hasWarning?: boolean; + enforceMaxHeight?: boolean; + minHeight?: number; + maxHeight?: number; + renderInputComponent?: ( + inputProps: RenderInputComponentProps, + ref: Ref + ) => ReactNode; + onInputChange: ( + event: FormEvent, + params: ChangeEvent + ) => unknown; + onInputKeyDown?: KeyboardEventHandler; + onInputFocus?: (event: SyntheticEvent) => unknown; + onInputBlur: ( + event: FocusEvent, + params?: BlurEvent + ) => unknown; + onChange?: (change: InputChanged) => unknown; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function AutoSuggestInput(props: AutoSuggestInputProps) { + const { + // TODO: forwaredRef should be replaces with React.forwardRef + forwardedRef, + className = styles.input, + inputContainerClassName = styles.inputContainer, + name, + value = '', + placeholder, + suggestions, + enforceMaxHeight = true, + hasError, + hasWarning, + minHeight = 50, + maxHeight = 200, + getSuggestionValue, + renderSuggestion, + renderInputComponent, + onInputChange, + onInputKeyDown, + onInputFocus, + onInputBlur, + onSuggestionsFetchRequested, + onSuggestionsClearRequested, + onSuggestionSelected, + onChange, + ...otherProps + } = props; + + const updater = useRef<(() => void) | null>(null); + const previousSuggestions = usePrevious(suggestions); + + const handleComputeMaxHeight = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (data: any) => { + const { top, bottom, width } = data.offsets.reference; + + if (enforceMaxHeight) { + data.styles.maxHeight = maxHeight; + } else { + const windowHeight = window.innerHeight; + + if (/^botton/.test(data.placement)) { + data.styles.maxHeight = windowHeight - bottom; + } else { + data.styles.maxHeight = top; + } + } + + data.styles.width = width; + + return data; + }, + [enforceMaxHeight, maxHeight] + ); + + const createRenderInputComponent = useCallback( + (inputProps: RenderInputComponentProps) => { + return ( + + {({ ref }) => { + if (renderInputComponent) { + return renderInputComponent(inputProps, ref); + } + + return ( +
+ +
+ ); + }} +
+ ); + }, + [renderInputComponent] + ); + + const renderSuggestionsContainer = useCallback( + ({ containerProps, children }: RenderSuggestionsContainerParams) => { + return ( + + + {({ ref: popperRef, style, scheduleUpdate }) => { + updater.current = scheduleUpdate; + + return ( +
+
+ {children} +
+
+ ); + }} +
+
+ ); + }, + [minHeight, handleComputeMaxHeight] + ); + + const handleInputKeyDown = useCallback( + (event: KeyboardEvent) => { + if ( + event.key === 'Tab' && + suggestions.length && + suggestions[0] !== value + ) { + event.preventDefault(); + + if (value) { + onSuggestionSelected?.(event, { + suggestion: suggestions[0], + suggestionValue: value, + suggestionIndex: 0, + sectionIndex: null, + method: 'enter', + }); + } + } + }, + [value, suggestions, onSuggestionSelected] + ); + + const inputProps = { + className: classNames( + className, + hasError && styles.hasError, + hasWarning && styles.hasWarning + ), + name, + value, + placeholder, + autoComplete: 'off', + spellCheck: false, + onChange: onInputChange, + onKeyDown: onInputKeyDown || handleInputKeyDown, + onFocus: onInputFocus, + onBlur: onInputBlur, + }; + + const theme = { + container: inputContainerClassName, + containerOpen: styles.suggestionsContainerOpen, + suggestionsContainer: styles.suggestionsContainer, + suggestionsList: styles.suggestionsList, + suggestion: styles.suggestion, + suggestionHighlighted: styles.suggestionHighlighted, + }; + + useEffect(() => { + if (updater.current && suggestions !== previousSuggestions) { + updater.current(); + } + }, [suggestions, previousSuggestions]); + + return ( + + + + ); +} + +export default AutoSuggestInput; diff --git a/frontend/src/Components/Form/CaptchaInput.css.d.ts b/frontend/src/Components/Form/CaptchaInput.css.d.ts new file mode 100644 index 00000000000..b6844144e95 --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInput.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'captchaInputWrapper': string; + 'hasButton': string; + 'hasError': string; + 'hasWarning': string; + 'input': string; + 'recaptchaWrapper': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Form/CaptchaInput.js b/frontend/src/Components/Form/CaptchaInput.js deleted file mode 100644 index b422198b5ac..00000000000 --- a/frontend/src/Components/Form/CaptchaInput.js +++ /dev/null @@ -1,84 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ReCAPTCHA from 'react-google-recaptcha'; -import Icon from 'Components/Icon'; -import { icons } from 'Helpers/Props'; -import FormInputButton from './FormInputButton'; -import TextInput from './TextInput'; -import styles from './CaptchaInput.css'; - -function CaptchaInput(props) { - const { - className, - name, - value, - hasError, - hasWarning, - refreshing, - siteKey, - secretToken, - onChange, - onRefreshPress, - onCaptchaChange - } = props; - - return ( -
-
- - - - - -
- - { - !!siteKey && !!secretToken && -
- -
- } -
- ); -} - -CaptchaInput.propTypes = { - className: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - refreshing: PropTypes.bool.isRequired, - siteKey: PropTypes.string, - secretToken: PropTypes.string, - onChange: PropTypes.func.isRequired, - onRefreshPress: PropTypes.func.isRequired, - onCaptchaChange: PropTypes.func.isRequired -}; - -CaptchaInput.defaultProps = { - className: styles.input, - value: '' -}; - -export default CaptchaInput; diff --git a/frontend/src/Components/Form/CaptchaInput.tsx b/frontend/src/Components/Form/CaptchaInput.tsx new file mode 100644 index 00000000000..d5a3f11f7ce --- /dev/null +++ b/frontend/src/Components/Form/CaptchaInput.tsx @@ -0,0 +1,118 @@ +import classNames from 'classnames'; +import React, { useCallback, useEffect } from 'react'; +import ReCAPTCHA from 'react-google-recaptcha'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Icon from 'Components/Icon'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { icons } from 'Helpers/Props'; +import { + getCaptchaCookie, + refreshCaptcha, + resetCaptcha, +} from 'Store/Actions/captchaActions'; +import { InputChanged } from 'typings/inputs'; +import FormInputButton from './FormInputButton'; +import TextInput from './TextInput'; +import styles from './CaptchaInput.css'; + +interface CaptchaInputProps { + className?: string; + name: string; + value?: string; + provider: string; + providerData: object; + hasError?: boolean; + hasWarning?: boolean; + refreshing: boolean; + siteKey?: string; + secretToken?: string; + onChange: (change: InputChanged) => unknown; +} + +function CaptchaInput({ + className = styles.input, + name, + value = '', + provider, + providerData, + hasError, + hasWarning, + refreshing, + siteKey, + secretToken, + onChange, +}: CaptchaInputProps) { + const { token } = useSelector((state: AppState) => state.captcha); + const dispatch = useDispatch(); + const previousToken = usePrevious(token); + + const handleCaptchaChange = useCallback( + (token: string | null) => { + // If the captcha has expired `captchaResponse` will be null. + // In the event it's null don't try to get the captchaCookie. + // TODO: Should we clear the cookie? or reset the captcha? + + if (!token) { + return; + } + + dispatch( + getCaptchaCookie({ + provider, + providerData, + captchaResponse: token, + }) + ); + }, + [provider, providerData, dispatch] + ); + + const handleRefreshPress = useCallback(() => { + dispatch(refreshCaptcha({ provider, providerData })); + }, [provider, providerData, dispatch]); + + useEffect(() => { + if (token && token !== previousToken) { + onChange({ name, value: token }); + } + }, [name, token, previousToken, onChange]); + + useEffect(() => { + dispatch(resetCaptcha()); + }, [dispatch]); + + return ( +
+
+ + + + + +
+ + {siteKey && secretToken ? ( +
+ +
+ ) : null} +
+ ); +} + +export default CaptchaInput; diff --git a/frontend/src/Components/Form/CaptchaInputConnector.js b/frontend/src/Components/Form/CaptchaInputConnector.js deleted file mode 100644 index ad83bf02fb9..00000000000 --- a/frontend/src/Components/Form/CaptchaInputConnector.js +++ /dev/null @@ -1,98 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { getCaptchaCookie, refreshCaptcha, resetCaptcha } from 'Store/Actions/captchaActions'; -import CaptchaInput from './CaptchaInput'; - -function createMapStateToProps() { - return createSelector( - (state) => state.captcha, - (captcha) => { - return captcha; - } - ); -} - -const mapDispatchToProps = { - refreshCaptcha, - getCaptchaCookie, - resetCaptcha -}; - -class CaptchaInputConnector extends Component { - - // - // Lifecycle - - componentDidUpdate(prevProps) { - const { - name, - token, - onChange - } = this.props; - - if (token && token !== prevProps.token) { - onChange({ name, value: token }); - } - } - - componentWillUnmount = () => { - this.props.resetCaptcha(); - }; - - // - // Listeners - - onRefreshPress = () => { - const { - provider, - providerData - } = this.props; - - this.props.refreshCaptcha({ provider, providerData }); - }; - - onCaptchaChange = (captchaResponse) => { - // If the captcha has expired `captchaResponse` will be null. - // In the event it's null don't try to get the captchaCookie. - // TODO: Should we clear the cookie? or reset the captcha? - - if (!captchaResponse) { - return; - } - - const { - provider, - providerData - } = this.props; - - this.props.getCaptchaCookie({ provider, providerData, captchaResponse }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -CaptchaInputConnector.propTypes = { - provider: PropTypes.string.isRequired, - providerData: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, - token: PropTypes.string, - onChange: PropTypes.func.isRequired, - refreshCaptcha: PropTypes.func.isRequired, - getCaptchaCookie: PropTypes.func.isRequired, - resetCaptcha: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector); diff --git a/frontend/src/Components/Form/CheckInput.css.d.ts b/frontend/src/Components/Form/CheckInput.css.d.ts new file mode 100644 index 00000000000..bba6b63bbff --- /dev/null +++ b/frontend/src/Components/Form/CheckInput.css.d.ts @@ -0,0 +1,18 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'checkbox': string; + 'container': string; + 'dangerIsChecked': string; + 'helpText': string; + 'input': string; + 'isDisabled': string; + 'isIndeterminate': string; + 'isNotChecked': string; + 'label': string; + 'primaryIsChecked': string; + 'successIsChecked': string; + 'warningIsChecked': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Form/CheckInput.js b/frontend/src/Components/Form/CheckInput.js deleted file mode 100644 index 26d9158803e..00000000000 --- a/frontend/src/Components/Form/CheckInput.js +++ /dev/null @@ -1,191 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import { icons, kinds } from 'Helpers/Props'; -import FormInputHelpText from './FormInputHelpText'; -import styles from './CheckInput.css'; - -class CheckInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._checkbox = null; - } - - componentDidMount() { - this.setIndeterminate(); - } - - componentDidUpdate() { - this.setIndeterminate(); - } - - // - // Control - - setIndeterminate() { - if (!this._checkbox) { - return; - } - - const { - value, - uncheckedValue, - checkedValue - } = this.props; - - this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue; - } - - toggleChecked = (checked, shiftKey) => { - const { - name, - value, - checkedValue, - uncheckedValue - } = this.props; - - const newValue = checked ? checkedValue : uncheckedValue; - - if (value !== newValue) { - this.props.onChange({ - name, - value: newValue, - shiftKey - }); - } - }; - - // - // Listeners - - setRef = (ref) => { - this._checkbox = ref; - }; - - onClick = (event) => { - if (this.props.isDisabled) { - return; - } - - const shiftKey = event.nativeEvent.shiftKey; - const checked = !this._checkbox.checked; - - event.preventDefault(); - this.toggleChecked(checked, shiftKey); - }; - - onChange = (event) => { - const checked = event.target.checked; - const shiftKey = event.nativeEvent.shiftKey; - - this.toggleChecked(checked, shiftKey); - }; - - // - // Render - - render() { - const { - className, - containerClassName, - name, - value, - checkedValue, - uncheckedValue, - helpText, - helpTextWarning, - isDisabled, - kind - } = this.props; - - const isChecked = value === checkedValue; - const isUnchecked = value === uncheckedValue; - const isIndeterminate = !isChecked && !isUnchecked; - const isCheckClass = `${kind}IsChecked`; - - return ( -
- -
- ); - } -} - -CheckInput.propTypes = { - className: PropTypes.string.isRequired, - containerClassName: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - checkedValue: PropTypes.bool, - uncheckedValue: PropTypes.bool, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - helpText: PropTypes.string, - helpTextWarning: PropTypes.string, - isDisabled: PropTypes.bool, - kind: PropTypes.oneOf(kinds.all).isRequired, - onChange: PropTypes.func.isRequired -}; - -CheckInput.defaultProps = { - className: styles.input, - containerClassName: styles.container, - checkedValue: true, - uncheckedValue: false, - kind: kinds.PRIMARY -}; - -export default CheckInput; diff --git a/frontend/src/Components/Form/CheckInput.tsx b/frontend/src/Components/Form/CheckInput.tsx new file mode 100644 index 00000000000..b7080cfdd2b --- /dev/null +++ b/frontend/src/Components/Form/CheckInput.tsx @@ -0,0 +1,141 @@ +import classNames from 'classnames'; +import React, { SyntheticEvent, useCallback, useEffect, useRef } from 'react'; +import Icon from 'Components/Icon'; +import { icons } from 'Helpers/Props'; +import { Kind } from 'Helpers/Props/kinds'; +import { CheckInputChanged } from 'typings/inputs'; +import FormInputHelpText from './FormInputHelpText'; +import styles from './CheckInput.css'; + +interface ChangeEvent extends SyntheticEvent { + target: EventTarget & T; +} + +interface CheckInputProps { + className?: string; + containerClassName?: string; + name: string; + checkedValue?: boolean; + uncheckedValue?: boolean; + value?: string | boolean; + helpText?: string; + helpTextWarning?: string; + isDisabled?: boolean; + kind?: Extract; + onChange: (changes: CheckInputChanged) => void; +} + +function CheckInput(props: CheckInputProps) { + const { + className = styles.input, + containerClassName = styles.container, + name, + value, + checkedValue = true, + uncheckedValue = false, + helpText, + helpTextWarning, + isDisabled, + kind = 'primary', + onChange, + } = props; + + const inputRef = useRef(null); + + const isChecked = value === checkedValue; + const isUnchecked = value === uncheckedValue; + const isIndeterminate = !isChecked && !isUnchecked; + const isCheckClass: keyof typeof styles = `${kind}IsChecked`; + + const toggleChecked = useCallback( + (checked: boolean, shiftKey: boolean) => { + const newValue = checked ? checkedValue : uncheckedValue; + + if (value !== newValue) { + onChange({ + name, + value: newValue, + shiftKey, + }); + } + }, + [name, value, checkedValue, uncheckedValue, onChange] + ); + + const handleClick = useCallback( + (event: SyntheticEvent) => { + if (isDisabled) { + return; + } + + const shiftKey = event.nativeEvent.shiftKey; + const checked = !(inputRef.current?.checked ?? false); + + event.preventDefault(); + toggleChecked(checked, shiftKey); + }, + [isDisabled, toggleChecked] + ); + + const handleChange = useCallback( + (event: ChangeEvent) => { + const checked = event.target.checked; + const shiftKey = event.nativeEvent.shiftKey; + + toggleChecked(checked, shiftKey); + }, + [toggleChecked] + ); + + useEffect(() => { + if (!inputRef.current) { + return; + } + + inputRef.current.indeterminate = + value !== uncheckedValue && value !== checkedValue; + }, [value, uncheckedValue, checkedValue]); + + return ( +
+ +
+ ); +} + +export default CheckInput; diff --git a/frontend/src/Components/Form/DeviceInput.css b/frontend/src/Components/Form/DeviceInput.css deleted file mode 100644 index 7abe83db503..00000000000 --- a/frontend/src/Components/Form/DeviceInput.css +++ /dev/null @@ -1,8 +0,0 @@ -.deviceInputWrapper { - display: flex; -} - -.input { - composes: input from '~./TagInput.css'; - composes: hasButton from '~Components/Form/Input.css'; -} diff --git a/frontend/src/Components/Form/DeviceInput.js b/frontend/src/Components/Form/DeviceInput.js deleted file mode 100644 index 55c239cb825..00000000000 --- a/frontend/src/Components/Form/DeviceInput.js +++ /dev/null @@ -1,106 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import { icons } from 'Helpers/Props'; -import tagShape from 'Helpers/Props/Shapes/tagShape'; -import FormInputButton from './FormInputButton'; -import TagInput from './TagInput'; -import styles from './DeviceInput.css'; - -class DeviceInput extends Component { - - onTagAdd = (device) => { - const { - name, - value, - onChange - } = this.props; - - // New tags won't have an ID, only a name. - const deviceId = device.id || device.name; - - onChange({ - name, - value: [...value, deviceId] - }); - }; - - onTagDelete = ({ index }) => { - const { - name, - value, - onChange - } = this.props; - - const newValue = value.slice(); - newValue.splice(index, 1); - - onChange({ - name, - value: newValue - }); - }; - - // - // Render - - render() { - const { - className, - name, - items, - selectedDevices, - hasError, - hasWarning, - isFetching, - onRefreshPress - } = this.props; - - return ( -
- - - - - -
- ); - } -} - -DeviceInput.propTypes = { - className: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired, - items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, - selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - onRefreshPress: PropTypes.func.isRequired -}; - -DeviceInput.defaultProps = { - className: styles.deviceInputWrapper, - inputClassName: styles.input -}; - -export default DeviceInput; diff --git a/frontend/src/Components/Form/DeviceInputConnector.js b/frontend/src/Components/Form/DeviceInputConnector.js deleted file mode 100644 index 2af9a79f6ab..00000000000 --- a/frontend/src/Components/Form/DeviceInputConnector.js +++ /dev/null @@ -1,104 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions'; -import DeviceInput from './DeviceInput'; - -function createMapStateToProps() { - return createSelector( - (state, { value }) => value, - (state) => state.providerOptions.devices || defaultState, - (value, devices) => { - - return { - ...devices, - selectedDevices: value.map((valueDevice) => { - // Disable equality ESLint rule so we don't need to worry about - // a type mismatch between the value items and the device ID. - // eslint-disable-next-line eqeqeq - const device = devices.items.find((d) => d.id == valueDevice); - - if (device) { - return { - id: device.id, - name: `${device.name} (${device.id})` - }; - } - - return { - id: valueDevice, - name: `Unknown (${valueDevice})` - }; - }) - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchOptions: fetchOptions, - dispatchClearOptions: clearOptions -}; - -class DeviceInputConnector extends Component { - - // - // Lifecycle - - componentDidMount = () => { - this._populate(); - }; - - componentWillUnmount = () => { - this.props.dispatchClearOptions({ section: 'devices' }); - }; - - // - // Control - - _populate() { - const { - provider, - providerData, - dispatchFetchOptions - } = this.props; - - dispatchFetchOptions({ - section: 'devices', - action: 'getDevices', - provider, - providerData - }); - } - - // - // Listeners - - onRefreshPress = () => { - this._populate(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -DeviceInputConnector.propTypes = { - provider: PropTypes.string.isRequired, - providerData: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - dispatchFetchOptions: PropTypes.func.isRequired, - dispatchClearOptions: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector); diff --git a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js b/frontend/src/Components/Form/DownloadClientSelectInputConnector.js deleted file mode 100644 index c8901686952..00000000000 --- a/frontend/src/Components/Form/DownloadClientSelectInputConnector.js +++ /dev/null @@ -1,100 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { fetchDownloadClients } from 'Store/Actions/settingsActions'; -import sortByName from 'Utilities/Array/sortByName'; -import EnhancedSelectInput from './EnhancedSelectInput'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.downloadClients, - (state, { includeAny }) => includeAny, - (state, { protocol }) => protocol, - (downloadClients, includeAny, protocolFilter) => { - const { - isFetching, - isPopulated, - error, - items - } = downloadClients; - - const filteredItems = items.filter((item) => item.protocol === protocolFilter); - - const values = _.map(filteredItems.sort(sortByName), (downloadClient) => { - return { - key: downloadClient.id, - value: downloadClient.name - }; - }); - - if (includeAny) { - values.unshift({ - key: 0, - value: '(Any)' - }); - } - - return { - isFetching, - isPopulated, - error, - values - }; - } - ); -} - -const mapDispatchToProps = { - dispatchFetchDownloadClients: fetchDownloadClients -}; - -class DownloadClientSelectInputConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - if (!this.props.isPopulated) { - this.props.dispatchFetchDownloadClients(); - } - } - - // - // Listeners - - onChange = ({ name, value }) => { - this.props.onChange({ name, value: parseInt(value) }); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -DownloadClientSelectInputConnector.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - includeAny: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - dispatchFetchDownloadClients: PropTypes.func.isRequired -}; - -DownloadClientSelectInputConnector.defaultProps = { - includeAny: false, - protocol: 'torrent' -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSelectInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js deleted file mode 100644 index 4df54092cda..00000000000 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ /dev/null @@ -1,608 +0,0 @@ -import classNames from 'classnames'; -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Manager, Popper, Reference } from 'react-popper'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Measure from 'Components/Measure'; -import Modal from 'Components/Modal/Modal'; -import ModalBody from 'Components/Modal/ModalBody'; -import Portal from 'Components/Portal'; -import Scroller from 'Components/Scroller/Scroller'; -import { icons, scrollDirections, sizes } from 'Helpers/Props'; -import { isMobile as isMobileUtil } from 'Utilities/browser'; -import * as keyCodes from 'Utilities/Constants/keyCodes'; -import getUniqueElememtId from 'Utilities/getUniqueElementId'; -import HintedSelectInputOption from './HintedSelectInputOption'; -import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue'; -import TextInput from './TextInput'; -import styles from './EnhancedSelectInput.css'; - -function isArrowKey(keyCode) { - return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW; -} - -function getSelectedOption(selectedIndex, values) { - return values[selectedIndex]; -} - -function findIndex(startingIndex, direction, values) { - let indexToTest = startingIndex + direction; - - while (indexToTest !== startingIndex) { - if (indexToTest < 0) { - indexToTest = values.length - 1; - } else if (indexToTest >= values.length) { - indexToTest = 0; - } - - if (getSelectedOption(indexToTest, values).isDisabled) { - indexToTest = indexToTest + direction; - } else { - return indexToTest; - } - } -} - -function previousIndex(selectedIndex, values) { - return findIndex(selectedIndex, -1, values); -} - -function nextIndex(selectedIndex, values) { - return findIndex(selectedIndex, 1, values); -} - -function getSelectedIndex(props) { - const { - value, - values - } = props; - - if (Array.isArray(value)) { - return values.findIndex((v) => { - return value.size && v.key === value[0]; - }); - } - - return values.findIndex((v) => { - return v.key === value; - }); -} - -function isSelectedItem(index, props) { - const { - value, - values - } = props; - - if (Array.isArray(value)) { - return value.includes(values[index].key); - } - - return values[index].key === value; -} - -function getKey(selectedIndex, values) { - return values[selectedIndex].key; -} - -class EnhancedSelectInput extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scheduleUpdate = null; - this._buttonId = getUniqueElememtId(); - this._optionsId = getUniqueElememtId(); - - this.state = { - isOpen: false, - selectedIndex: getSelectedIndex(props), - width: 0, - isMobile: isMobileUtil() - }; - } - - componentDidUpdate(prevProps) { - if (this._scheduleUpdate) { - this._scheduleUpdate(); - } - - if (!Array.isArray(this.props.value)) { - if (prevProps.value !== this.props.value || prevProps.values !== this.props.values) { - this.setState({ - selectedIndex: getSelectedIndex(this.props) - }); - } - } - } - - // - // Control - - _addListener() { - window.addEventListener('click', this.onWindowClick); - } - - _removeListener() { - window.removeEventListener('click', this.onWindowClick); - } - - // - // 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; - } - - return data; - }; - - onWindowClick = (event) => { - const button = document.getElementById(this._buttonId); - const options = document.getElementById(this._optionsId); - - if (!button || !event.target.isConnected || this.state.isMobile) { - return; - } - - if ( - !button.contains(event.target) && - options && - !options.contains(event.target) && - this.state.isOpen - ) { - this.setState({ isOpen: false }); - this._removeListener(); - } - }; - - onFocus = () => { - if (this.state.isOpen) { - this._removeListener(); - this.setState({ isOpen: false }); - } - }; - - onBlur = () => { - if (!this.props.isEditable) { - // Calling setState without this check prevents the click event from being properly handled on Chrome (it is on firefox) - const origIndex = getSelectedIndex(this.props); - - if (origIndex !== this.state.selectedIndex) { - this.setState({ selectedIndex: origIndex }); - } - } - }; - - onKeyDown = (event) => { - const { - values - } = this.props; - - const { - isOpen, - selectedIndex - } = this.state; - - const keyCode = event.keyCode; - const newState = {}; - - if (!isOpen) { - if (isArrowKey(keyCode)) { - event.preventDefault(); - newState.isOpen = true; - } - - if ( - selectedIndex == null || selectedIndex === -1 || - getSelectedOption(selectedIndex, values).isDisabled - ) { - if (keyCode === keyCodes.UP_ARROW) { - newState.selectedIndex = previousIndex(0, values); - } else if (keyCode === keyCodes.DOWN_ARROW) { - newState.selectedIndex = nextIndex(values.length - 1, values); - } - } - - this.setState(newState); - return; - } - - if (keyCode === keyCodes.UP_ARROW) { - event.preventDefault(); - newState.selectedIndex = previousIndex(selectedIndex, values); - } - - if (keyCode === keyCodes.DOWN_ARROW) { - event.preventDefault(); - newState.selectedIndex = nextIndex(selectedIndex, values); - } - - if (keyCode === keyCodes.ENTER) { - event.preventDefault(); - newState.isOpen = false; - this.onSelect(getKey(selectedIndex, values)); - } - - if (keyCode === keyCodes.TAB) { - newState.isOpen = false; - this.onSelect(getKey(selectedIndex, values)); - } - - if (keyCode === keyCodes.ESCAPE) { - event.preventDefault(); - event.stopPropagation(); - newState.isOpen = false; - newState.selectedIndex = getSelectedIndex(this.props); - } - - if (!_.isEmpty(newState)) { - this.setState(newState); - } - }; - - onPress = () => { - if (this.state.isOpen) { - this._removeListener(); - } else { - this._addListener(); - } - - if (!this.state.isOpen && this.props.onOpen) { - this.props.onOpen(); - } - - this.setState({ isOpen: !this.state.isOpen }); - }; - - onSelect = (value) => { - if (Array.isArray(this.props.value)) { - let newValue = null; - const index = this.props.value.indexOf(value); - if (index === -1) { - newValue = this.props.values.map((v) => v.key).filter((v) => (v === value) || this.props.value.includes(v)); - } else { - newValue = [...this.props.value]; - newValue.splice(index, 1); - } - this.props.onChange({ - name: this.props.name, - value: newValue - }); - } else { - this.setState({ isOpen: false }); - - this.props.onChange({ - name: this.props.name, - value - }); - } - }; - - onMeasure = ({ width }) => { - this.setState({ width }); - }; - - onOptionsModalClose = () => { - this.setState({ isOpen: false }); - }; - - // - // Render - - render() { - const { - className, - disabledClassName, - name, - value, - values, - isDisabled, - isEditable, - isFetching, - hasError, - hasWarning, - valueOptions, - selectedValueOptions, - selectedValueComponent: SelectedValueComponent, - optionComponent: OptionComponent, - onChange - } = this.props; - - const { - selectedIndex, - width, - isOpen, - isMobile - } = this.state; - - const isMultiSelect = Array.isArray(value); - const selectedOption = getSelectedOption(selectedIndex, values); - let selectedValue = value; - - if (!values.length) { - selectedValue = isMultiSelect ? [] : ''; - } - - return ( -
- - - {({ ref }) => ( -
- - { - isEditable ? -
- - - { - isFetching ? - : - null - } - - { - isFetching ? - null : - - } - -
: - - - {selectedOption ? selectedOption.value : null} - - -
- - { - isFetching ? - : - null - } - - { - isFetching ? - null : - - } -
- - } -
-
- )} -
- - - {({ ref, style, scheduleUpdate }) => { - this._scheduleUpdate = scheduleUpdate; - - return ( -
- { - isOpen && !isMobile ? - - { - values.map((v, index) => { - const hasParent = v.parentKey !== undefined; - const depth = hasParent ? 1 : 0; - const parentSelected = hasParent && value.includes(v.parentKey); - return ( - - {v.value} - - ); - }) - } - : - null - } -
- ); - } - } -
-
-
- - { - isMobile ? - - - -
- - - -
- - { - values.map((v, index) => { - const hasParent = v.parentKey !== undefined; - const depth = hasParent ? 1 : 0; - const parentSelected = hasParent && value.includes(v.parentKey); - return ( - - {v.value} - - ); - }) - } -
-
-
: - null - } -
- ); - } -} - -EnhancedSelectInput.propTypes = { - className: PropTypes.string, - disabledClassName: PropTypes.string, - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.number)]).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - isDisabled: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - isEditable: PropTypes.bool.isRequired, - hasError: PropTypes.bool, - hasWarning: PropTypes.bool, - valueOptions: PropTypes.object.isRequired, - selectedValueOptions: PropTypes.object.isRequired, - selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, - optionComponent: PropTypes.elementType, - onOpen: PropTypes.func, - onChange: PropTypes.func.isRequired -}; - -EnhancedSelectInput.defaultProps = { - className: styles.enhancedSelect, - disabledClassName: styles.isDisabled, - isDisabled: false, - isFetching: false, - isEditable: false, - valueOptions: {}, - selectedValueOptions: {}, - selectedValueComponent: HintedSelectInputSelectedValue, - optionComponent: HintedSelectInputOption -}; - -export default EnhancedSelectInput; diff --git a/frontend/src/Components/Form/EnhancedSelectInputConnector.js b/frontend/src/Components/Form/EnhancedSelectInputConnector.js deleted file mode 100644 index f2af4a58550..00000000000 --- a/frontend/src/Components/Form/EnhancedSelectInputConnector.js +++ /dev/null @@ -1,159 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions'; -import EnhancedSelectInput from './EnhancedSelectInput'; - -const importantFieldNames = [ - 'baseUrl', - 'apiPath', - 'apiKey' -]; - -function getProviderDataKey(providerData) { - if (!providerData || !providerData.fields) { - return null; - } - - const fields = providerData.fields - .filter((f) => importantFieldNames.includes(f.name)) - .map((f) => f.value); - - return fields; -} - -function getSelectOptions(items) { - if (!items) { - return []; - } - - return items.map((option) => { - return { - key: option.value, - value: option.name, - hint: option.hint, - parentKey: option.parentValue - }; - }); -} - -function createMapStateToProps() { - return createSelector( - (state, { selectOptionsProviderAction }) => state.providerOptions[selectOptionsProviderAction] || defaultState, - (options) => { - if (options) { - return { - isFetching: options.isFetching, - values: getSelectOptions(options.items) - }; - } - } - ); -} - -const mapDispatchToProps = { - dispatchFetchOptions: fetchOptions, - dispatchClearOptions: clearOptions -}; - -class EnhancedSelectInputConnector extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - refetchRequired: false - }; - } - - componentDidMount = () => { - this._populate(); - }; - - componentDidUpdate = (prevProps) => { - const prevKey = getProviderDataKey(prevProps.providerData); - const nextKey = getProviderDataKey(this.props.providerData); - - if (!_.isEqual(prevKey, nextKey)) { - this.setState({ refetchRequired: true }); - } - }; - - componentWillUnmount = () => { - this._cleanup(); - }; - - // - // Listeners - - onOpen = () => { - if (this.state.refetchRequired) { - this._populate(); - } - }; - - // - // Control - - _populate() { - const { - provider, - providerData, - selectOptionsProviderAction, - dispatchFetchOptions - } = this.props; - - if (selectOptionsProviderAction) { - this.setState({ refetchRequired: false }); - dispatchFetchOptions({ - section: selectOptionsProviderAction, - action: selectOptionsProviderAction, - provider, - providerData - }); - } - } - - _cleanup() { - const { - selectOptionsProviderAction, - dispatchClearOptions - } = this.props; - - if (selectOptionsProviderAction) { - dispatchClearOptions({ section: selectOptionsProviderAction }); - } - } - - // - // Render - - render() { - return ( - - ); - } -} - -EnhancedSelectInputConnector.propTypes = { - provider: PropTypes.string.isRequired, - providerData: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, - value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired, - values: PropTypes.arrayOf(PropTypes.object).isRequired, - selectOptionsProviderAction: PropTypes.string, - onChange: PropTypes.func.isRequired, - isFetching: PropTypes.bool.isRequired, - dispatchFetchOptions: PropTypes.func.isRequired, - dispatchClearOptions: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(EnhancedSelectInputConnector); diff --git a/frontend/src/Components/Form/EnhancedSelectInputOption.js b/frontend/src/Components/Form/EnhancedSelectInputOption.js deleted file mode 100644 index b2783dbaad6..00000000000 --- a/frontend/src/Components/Form/EnhancedSelectInputOption.js +++ /dev/null @@ -1,113 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import { icons } from 'Helpers/Props'; -import CheckInput from './CheckInput'; -import styles from './EnhancedSelectInputOption.css'; - -class EnhancedSelectInputOption extends Component { - - // - // Listeners - - onPress = (e) => { - e.preventDefault(); - - const { - id, - onSelect - } = this.props; - - onSelect(id); - }; - - onCheckPress = () => { - // CheckInput requires a handler. Swallow the change event because onPress will already handle it via event propagation. - }; - - // - // Render - - render() { - const { - className, - id, - depth, - isSelected, - isDisabled, - isHidden, - isMultiSelect, - isMobile, - children - } = this.props; - - return ( - - - { - depth !== 0 && -
- } - - { - isMultiSelect && - - } - - {children} - - { - isMobile && -
- -
- } - - ); - } -} - -EnhancedSelectInputOption.propTypes = { - className: PropTypes.string.isRequired, - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - depth: PropTypes.number.isRequired, - isSelected: PropTypes.bool.isRequired, - isDisabled: PropTypes.bool.isRequired, - isHidden: PropTypes.bool.isRequired, - isMultiSelect: PropTypes.bool.isRequired, - isMobile: PropTypes.bool.isRequired, - children: PropTypes.node.isRequired, - onSelect: PropTypes.func.isRequired -}; - -EnhancedSelectInputOption.defaultProps = { - className: styles.option, - depth: 0, - isDisabled: false, - isHidden: false, - isMultiSelect: false -}; - -export default EnhancedSelectInputOption; diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js deleted file mode 100644 index 21ddebb0278..00000000000 --- a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js +++ /dev/null @@ -1,35 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './EnhancedSelectInputSelectedValue.css'; - -function EnhancedSelectInputSelectedValue(props) { - const { - className, - children, - isDisabled - } = props; - - return ( -
- {children} -
- ); -} - -EnhancedSelectInputSelectedValue.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.node, - isDisabled: PropTypes.bool.isRequired -}; - -EnhancedSelectInputSelectedValue.defaultProps = { - className: styles.selectedValue, - isDisabled: false -}; - -export default EnhancedSelectInputSelectedValue; diff --git a/frontend/src/Components/Form/Form.css.d.ts b/frontend/src/Components/Form/Form.css.d.ts new file mode 100644 index 00000000000..178f2fec174 --- /dev/null +++ b/frontend/src/Components/Form/Form.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'validationFailures': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Form/Form.js b/frontend/src/Components/Form/Form.js deleted file mode 100644 index 859911a8b62..00000000000 --- a/frontend/src/Components/Form/Form.js +++ /dev/null @@ -1,58 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Alert from 'Components/Alert'; -import { kinds } from 'Helpers/Props'; -import styles from './Form.css'; - -function Form({ children, validationErrors, validationWarnings, ...otherProps }) { - return ( -
- { - validationErrors.length || validationWarnings.length ? -
- { - validationErrors.map((error, index) => { - return ( - - {error.errorMessage} - - ); - }) - } - - { - validationWarnings.map((warning, index) => { - return ( - - {warning.errorMessage} - - ); - }) - } -
: - null - } - - {children} -
- ); -} - -Form.propTypes = { - children: PropTypes.node.isRequired, - validationErrors: PropTypes.arrayOf(PropTypes.object).isRequired, - validationWarnings: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -Form.defaultProps = { - validationErrors: [], - validationWarnings: [] -}; - -export default Form; diff --git a/frontend/src/Components/Form/Form.tsx b/frontend/src/Components/Form/Form.tsx new file mode 100644 index 00000000000..d522019e7bd --- /dev/null +++ b/frontend/src/Components/Form/Form.tsx @@ -0,0 +1,45 @@ +import React, { ReactNode } from 'react'; +import Alert from 'Components/Alert'; +import { kinds } from 'Helpers/Props'; +import { ValidationError, ValidationWarning } from 'typings/pending'; +import styles from './Form.css'; + +export interface FormProps { + children: ReactNode; + validationErrors?: ValidationError[]; + validationWarnings?: ValidationWarning[]; +} + +function Form({ + children, + validationErrors = [], + validationWarnings = [], +}: FormProps) { + return ( +
+ {validationErrors.length || validationWarnings.length ? ( +
+ {validationErrors.map((error, index) => { + return ( + + {error.errorMessage} + + ); + })} + + {validationWarnings.map((warning, index) => { + return ( + + {warning.errorMessage} + + ); + })} +
+ ) : null} + + {children} +
+ ); +} + +export default Form; diff --git a/frontend/src/Components/Form/FormGroup.css.d.ts b/frontend/src/Components/Form/FormGroup.css.d.ts new file mode 100644 index 00000000000..86145f643d3 --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.css.d.ts @@ -0,0 +1,11 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'extraSmall': string; + 'group': string; + 'large': string; + 'medium': string; + 'small': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Form/FormGroup.js b/frontend/src/Components/Form/FormGroup.js deleted file mode 100644 index f538daa2f17..00000000000 --- a/frontend/src/Components/Form/FormGroup.js +++ /dev/null @@ -1,56 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { map } from 'Helpers/elementChildren'; -import { sizes } from 'Helpers/Props'; -import styles from './FormGroup.css'; - -function FormGroup(props) { - const { - className, - children, - size, - advancedSettings, - isAdvanced, - ...otherProps - } = props; - - if (!advancedSettings && isAdvanced) { - return null; - } - - const childProps = isAdvanced ? { isAdvanced } : {}; - - return ( -
- { - map(children, (child) => { - return React.cloneElement(child, childProps); - }) - } -
- ); -} - -FormGroup.propTypes = { - className: PropTypes.string.isRequired, - children: PropTypes.node.isRequired, - size: PropTypes.oneOf(sizes.all).isRequired, - advancedSettings: PropTypes.bool.isRequired, - isAdvanced: PropTypes.bool.isRequired -}; - -FormGroup.defaultProps = { - className: styles.group, - size: sizes.SMALL, - advancedSettings: false, - isAdvanced: false -}; - -export default FormGroup; diff --git a/frontend/src/Components/Form/FormGroup.tsx b/frontend/src/Components/Form/FormGroup.tsx new file mode 100644 index 00000000000..1dd879897af --- /dev/null +++ b/frontend/src/Components/Form/FormGroup.tsx @@ -0,0 +1,43 @@ +import classNames from 'classnames'; +import React, { Children, ComponentPropsWithoutRef, ReactNode } from 'react'; +import { Size } from 'Helpers/Props/sizes'; +import styles from './FormGroup.css'; + +interface FormGroupProps extends ComponentPropsWithoutRef<'div'> { + className?: string; + children: ReactNode; + size?: Extract; + advancedSettings?: boolean; + isAdvanced?: boolean; +} + +function FormGroup(props: FormGroupProps) { + const { + className = styles.group, + children, + size = 'small', + advancedSettings = false, + isAdvanced = false, + ...otherProps + } = props; + + if (!advancedSettings && isAdvanced) { + return null; + } + + const childProps = isAdvanced ? { isAdvanced } : {}; + + return ( +
+ {Children.map(children, (child) => { + if (!React.isValidElement(child)) { + return child; + } + + return React.cloneElement(child, childProps); + })} +
+ ); +} + +export default FormGroup; diff --git a/frontend/src/Components/Form/FormInputButton.css.d.ts b/frontend/src/Components/Form/FormInputButton.css.d.ts new file mode 100644 index 00000000000..d469cdfe3be --- /dev/null +++ b/frontend/src/Components/Form/FormInputButton.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'button': string; + 'middleButton': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Components/Form/FormInputButton.js b/frontend/src/Components/Form/FormInputButton.js deleted file mode 100644 index a7145363af0..00000000000 --- 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 ( -