mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-18 21:35:27 -04:00
Compare commits
287 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 550fe77398 | |||
| 1449b81525 | |||
| 2b3f0d837d | |||
| b0fac1529d | |||
| 113d0c4749 | |||
| 536d292838 | |||
| 5e5f1835f5 | |||
| a1260fa5f4 | |||
| c07dbef2c4 | |||
| dfd5e4ba37 | |||
| 9a0e23a93f | |||
| be4a564456 | |||
| 01fcc160dd | |||
| 0a76d7789e | |||
| caeb31df28 | |||
| e4173077dc | |||
| 5bde924239 | |||
| 6147a7bcaa | |||
| 0953e9c198 | |||
| 3fec81ad44 | |||
| d7ffa030be | |||
| 15c6fa8f0e | |||
| cd1aeefc4f | |||
| 494f446b05 | |||
| fa69c485e9 | |||
| 526ef5428d | |||
| fbb70519b1 | |||
| 7a455dd0f8 | |||
| 5b79ee6d11 | |||
| d7769866c7 | |||
| 209087f205 | |||
| b135e5a2a4 | |||
| c64f4adfc4 | |||
| e56dd15928 | |||
| 0e5ccbebc7 | |||
| 01c7d06da4 | |||
| f7c7f8db8c | |||
| f9f71d3f2a | |||
| 0723459122 | |||
| 7f971d47ac | |||
| db9ef92a80 | |||
| 147f11dece | |||
| f91ebd4c07 | |||
| d99f8b5685 | |||
| 6c329e8a6f | |||
| 2a5667e634 | |||
| da6340e421 | |||
| 965b6144e3 | |||
| 7add5aafad | |||
| d543427012 | |||
| 1c805bded0 | |||
| c4c0ec25ac | |||
| bcceb22512 | |||
| 6764cf1c22 | |||
| dd6533c18a | |||
| ac1c74105f | |||
| 33fb0a4e88 | |||
| f93bc57426 | |||
| eda676f9a2 | |||
| c8f15ae198 | |||
| fcfdac99d5 | |||
| 5bac016f0c | |||
| 236978a9b1 | |||
| d8698d1c28 | |||
| b94cf9b3b9 | |||
| 2335657fe4 | |||
| 8fa16e3542 | |||
| 93713c3827 | |||
| 39573ea17b | |||
| 944e33f24b | |||
| 06aba5fe18 | |||
| 49f6117d54 | |||
| 9c61a5c286 | |||
| fdda9abcbb | |||
| dfbd54308f | |||
| bbb4c6714c | |||
| 1d8da79172 | |||
| 3e22ad59c3 | |||
| f2fc1e9332 | |||
| e8020b7289 | |||
| 1033849d39 | |||
| 70678510ee | |||
| 54dafdb8d3 | |||
| c0a565861e | |||
| 8b8cd14834 | |||
| ee83b81be9 | |||
| 302b0f356e | |||
| 15fb999597 | |||
| b88b7e15c1 | |||
| da6985b40c | |||
| 0d521c078b | |||
| 52ce33a97a | |||
| 1ef034af35 | |||
| 7ea20335ed | |||
| 41b0ecb08c | |||
| 5d29c16bf3 | |||
| 210c20f8e9 | |||
| 0ed53874c6 | |||
| 97d3a4940d | |||
| 98fbe694e4 | |||
| 677c588a3b | |||
| 0ddc2a34e5 | |||
| f3b39ba4f7 | |||
| c409ee81bd | |||
| 6cd1ab3764 | |||
| 5d8d2d66a4 | |||
| 164441965c | |||
| 5487aa74f5 | |||
| bee7e4325f | |||
| 7b0db46c25 | |||
| b16743bdda | |||
| 399ca1661f | |||
| 28300ffb2b | |||
| ae13ce4ac6 | |||
| 276e67b5fa | |||
| e20b4c4f5d | |||
| 6d49b41dd2 | |||
| 0d80c093ff | |||
| 06c6062531 | |||
| 3e8a85ad26 | |||
| 107e474f9c | |||
| 0149886b68 | |||
| 77612810d8 | |||
| 12ed8cdc26 | |||
| 63431917fe | |||
| e06c6ba649 | |||
| e747ec8f5c | |||
| 6a6639105e | |||
| 8f5f3070ac | |||
| 1839aa7907 | |||
| a79234de48 | |||
| ac5b9b14ee | |||
| e0abca8360 | |||
| b2f2c21a61 | |||
| 4bf02221e6 | |||
| c70de927a5 | |||
| c60a978eb8 | |||
| cf593b1f5d | |||
| 243a3057ae | |||
| 8b438c2197 | |||
| 21ca65a015 | |||
| f4b9b30978 | |||
| 4713615b17 | |||
| f963a0d972 | |||
| 3977d8766c | |||
| 91f1b672c5 | |||
| c3706c3c92 | |||
| 8fcab2d321 | |||
| 1114cc7f7a | |||
| 74e6ce4305 | |||
| e9011011ed | |||
| 7e70238005 | |||
| ad57cf4b5d | |||
| 25fb4c4d7a | |||
| 2d071eca9b | |||
| a466a94d4d | |||
| 763c9c838f | |||
| 3f098c601b | |||
| c5ff89c69b | |||
| cd7adba17c | |||
| 5f8297da6c | |||
| ee875ae654 | |||
| d70dcfed56 | |||
| 47fa1f3ae6 | |||
| 6bb694a6f7 | |||
| 3c77c4b989 | |||
| fe61545716 | |||
| dbb841f027 | |||
| 8ee10adbd4 | |||
| 03cb2e7f63 | |||
| 65cd80a9d5 | |||
| 869269ddf3 | |||
| ff36fb017a | |||
| 1043e7f43f | |||
| ce8a5d8a6b | |||
| ec44e1c513 | |||
| 8da611ea58 | |||
| 4c13a01c8e | |||
| 10c0e18a42 | |||
| bc099f27cb | |||
| f94b3d8560 | |||
| 6b479a5a10 | |||
| 5eb18fe274 | |||
| 7d59b466e7 | |||
| 21083d9041 | |||
| 2453de80aa | |||
| f722e51377 | |||
| db95d7e42f | |||
| 9b7f2a5adf | |||
| bb090a7c42 | |||
| dec6f4b5f2 | |||
| dd12b9e076 | |||
| 227db9ef39 | |||
| 5f9f8fddad | |||
| cbade29c7a | |||
| 87892a1d0c | |||
| 4e52b3c93e | |||
| aee6158ec5 | |||
| 878f879c40 | |||
| 66efb904f2 | |||
| e73712bd8f | |||
| 5542187f17 | |||
| de0f1d461d | |||
| c749e2d1a3 | |||
| b612888ed4 | |||
| 0ade5b4c22 | |||
| 3c0fa4a3af | |||
| 0521a6c390 | |||
| 49db4a1d76 | |||
| 1df3b116c1 | |||
| 5d655f98f2 | |||
| 5f846ab51e | |||
| bef2986357 | |||
| 6f287cb1de | |||
| 1178c98341 | |||
| a97f2c016b | |||
| 4959ef4321 | |||
| d252fa8ed6 | |||
| 7d2e01d516 | |||
| 91b242902d | |||
| 2f119fefd1 | |||
| 1ca148a7a9 | |||
| 9db17883df | |||
| 4c556eacc1 | |||
| e4298d0135 | |||
| 8f95849e9b | |||
| 9b756df4bf | |||
| 8e537cb626 | |||
| f76ae1cce9 | |||
| 7a5157df29 | |||
| 449caa12e3 | |||
| dd84bcd919 | |||
| 26934a5d81 | |||
| 84fbab6ab4 | |||
| a047053fae | |||
| 6629ebc6e0 | |||
| 44fc1e0e85 | |||
| 9cdf1bf721 | |||
| d2107e92f3 | |||
| 7284898c7d | |||
| d4f14246f1 | |||
| ae18ad61bd | |||
| aac4760d30 | |||
| 21592f3d69 | |||
| e1cfc5f550 | |||
| 0809a72ce5 | |||
| 20ad1b4410 | |||
| e89d58985a | |||
| 4071278183 | |||
| 5a702dec12 | |||
| 317cdf1558 | |||
| f104268256 | |||
| 263f4839ab | |||
| 19a65d672f | |||
| 2098303e25 | |||
| ccb7f07c26 | |||
| 6a3e1278a5 | |||
| 5867cd5f47 | |||
| c295e24fc6 | |||
| 29170f17d2 | |||
| 3091f40ca8 | |||
| 565f967f7a | |||
| 7960bb8c7d | |||
| b5967425f1 | |||
| 910b85f37d | |||
| 08f0a5a960 | |||
| 0df0212b3a | |||
| b64cc65579 | |||
| 871ae9555b | |||
| 2bc30037bd | |||
| 0552a81180 | |||
| 9bc9d6d400 | |||
| beadc687ae | |||
| 4f149257c0 | |||
| 255f1c37c3 | |||
| 0ff644e842 | |||
| 8718f28fbd | |||
| b803635b6c | |||
| dacd469627 | |||
| 49c52c2e1a | |||
| fc0c26c2b3 | |||
| 4f7425086e | |||
| a85419df15 | |||
| 7ef3b6bd0a | |||
| 79f1d2e38c | |||
| 52ba6f4593 | |||
| 81c6f3ac75 |
@@ -2,7 +2,7 @@
|
|||||||
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
|
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
|
||||||
{
|
{
|
||||||
"name": "Sonarr",
|
"name": "Sonarr",
|
||||||
"image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0",
|
"image": "mcr.microsoft.com/devcontainers/dotnet:1-10.0",
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/node:1": {
|
"ghcr.io/devcontainers/features/node:1": {
|
||||||
"nodeGypDependencies": true,
|
"nodeGypDependencies": true,
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ runs:
|
|||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v5
|
uses: actions/setup-dotnet@v5
|
||||||
|
|
||||||
|
- name: Setup NuGet registry source
|
||||||
|
shell: bash
|
||||||
|
if: ${{ startsWith(inputs.runtime, 'freebsd') }}
|
||||||
|
run:
|
||||||
|
dotnet nuget add source --configfile src/NuGet.Config --name gh-openur https://nuget.pkg.github.com/openur/index.json --username ${{ github.repository_owner }} --password ${{ github.token }} --store-password-in-clear-text
|
||||||
|
|
||||||
- name: Setup Environment Variables
|
- name: Setup Environment Variables
|
||||||
id: variables
|
id: variables
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -86,7 +92,7 @@ runs:
|
|||||||
|
|
||||||
echo "Building Sonarr for $runtime, Platform: $platform"
|
echo "Building Sonarr for $runtime, Platform: $platform"
|
||||||
|
|
||||||
dotnet msbuild -restore $slnFile -p:SelfContained=True -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$runtime -p:EnableWindowsTargeting=true -t:PublishAllRids
|
dotnet msbuild -restore $slnFile -p:SelfContained=true -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$runtime -p:EnableWindowsTargeting=true -t:PublishAllRids
|
||||||
|
|
||||||
- name: Package
|
- name: Package
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -182,7 +188,7 @@ runs:
|
|||||||
runtime: ${{ inputs.runtime }}
|
runtime: ${{ inputs.runtime }}
|
||||||
|
|
||||||
- name: Upload Artifacts
|
- name: Upload Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: build-${{ inputs.runtime }}
|
name: build-${{ inputs.runtime }}
|
||||||
path: _artifacts/**/*
|
path: _artifacts/**/*
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ runs:
|
|||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Download Artifact
|
- name: Download Artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: ${{ inputs.artifact }}
|
name: ${{ inputs.artifact }}
|
||||||
path: _output
|
path: _output
|
||||||
|
|
||||||
- name: Download UI Artifact
|
- name: Download UI Artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
name: build_ui
|
name: build_ui
|
||||||
path: _output/UI
|
path: _output/UI
|
||||||
@@ -67,7 +67,7 @@ runs:
|
|||||||
build.bat
|
build.bat
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: release-${{ inputs.runtime }}
|
name: release-${{ inputs.runtime }}
|
||||||
compression-level: 0
|
compression-level: 0
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
outputFolder=_output
|
outputFolder=_output
|
||||||
artifactsFolder=_artifacts
|
artifactsFolder=_artifacts
|
||||||
uiFolder="$outputFolder/UI"
|
uiFolder="$outputFolder/UI"
|
||||||
framework="${FRAMEWORK:=net8.0}"
|
framework="${FRAMEWORK:=net10.0}"
|
||||||
|
|
||||||
rm -rf $artifactsFolder
|
rm -rf $artifactsFolder
|
||||||
mkdir $artifactsFolder
|
mkdir $artifactsFolder
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ inputs:
|
|||||||
runs:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: tests-${{ inputs.runtime }}
|
name: tests-${{ inputs.runtime }}
|
||||||
path: _tests/${{ inputs.framework }}/${{ inputs.runtime }}/publish/**/*
|
path: _tests/${{ inputs.framework }}/${{ inputs.runtime }}/publish/**/*
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ runs:
|
|||||||
|
|
||||||
- name: Setup Postgres
|
- name: Setup Postgres
|
||||||
if: ${{ inputs.use_postgres }}
|
if: ${{ inputs.use_postgres }}
|
||||||
uses: ikalnytskyi/action-setup-postgres@v7
|
uses: ikalnytskyi/action-setup-postgres@v8
|
||||||
with:
|
with:
|
||||||
postgres-version: ${{ inputs.postgres-version }}
|
postgres-version: ${{ inputs.postgres-version }}
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ runs:
|
|||||||
|
|
||||||
- name: Upload Test Results
|
- name: Upload Test Results
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: results-${{ env.RESULTS_NAME }}
|
name: results-${{ env.RESULTS_NAME }}
|
||||||
path: TestResults/*.trx
|
path: TestResults/*.trx
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup dotnet
|
- name: Setup dotnet
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v4
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FRAMEWORK: net8.0
|
FRAMEWORK: net10.0
|
||||||
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||||
SONARR_MAJOR_VERSION: 5
|
SONARR_MAJOR_VERSION: 5
|
||||||
VERSION: 5.0.0
|
VERSION: 5.0.0
|
||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
uses: ./.github/actions/build
|
uses: ./.github/actions/build
|
||||||
@@ -97,7 +97,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Volta
|
- name: Volta
|
||||||
uses: volta-cli/action@v4
|
uses: volta-cli/action@v4
|
||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
run: yarn build --env production
|
run: yarn build --env production
|
||||||
|
|
||||||
- name: Publish UI Artifact
|
- name: Publish UI Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: build_ui
|
name: build_ui
|
||||||
path: _output/UI/**/*
|
path: _output/UI/**/*
|
||||||
@@ -139,7 +139,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
uses: ./.github/actions/test
|
uses: ./.github/actions/test
|
||||||
@@ -155,10 +155,10 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
postgres-version: [16, 17]
|
postgres-version: [16, 17, 18]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
uses: ./.github/actions/test
|
uses: ./.github/actions/test
|
||||||
@@ -195,7 +195,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
uses: ./.github/actions/test
|
uses: ./.github/actions/test
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Close issues without labels
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close-issue:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
sparse-checkout: |
|
||||||
|
.github
|
||||||
|
- name: Close issue if no labels found
|
||||||
|
if: join(github.event.issue.labels) == ''
|
||||||
|
run: |
|
||||||
|
gh issue comment ${{ github.event.issue.number }} --body ":wave: @${{ github.event.issue.user.login }}, this issue was closed automatically because it was created without following an issue template. Please update the issue following the correct template for this issue. Once updated please reply to this issue so we can review and re-open. In the future, use the [issue templates](https://github.com/${{ github.repository }}/issues/new/choose) instead of creating your own."
|
||||||
|
gh issue close ${{ github.event.issue.number }} --reason "not planned"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Package
|
- name: Package
|
||||||
uses: ./.github/actions/package
|
uses: ./.github/actions/package
|
||||||
@@ -71,10 +71,10 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download release artifacts
|
- name: Download release artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
path: _artifacts
|
path: _artifacts
|
||||||
pattern: release-*
|
pattern: release-*
|
||||||
|
|||||||
Vendored
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "build dotnet",
|
"preLaunchTask": "build dotnet",
|
||||||
// If you have changed target frameworks, make sure to update the program path.
|
// If you have changed target frameworks, make sure to update the program path.
|
||||||
"program": "${workspaceFolder}/_output/net8.0/Sonarr",
|
"program": "${workspaceFolder}/_output/net10.0/Sonarr",
|
||||||
"args": [],
|
"args": [],
|
||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
|
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import { setQueueOptions } from 'Activity/Queue/queueOptionsStore';
|
import { setQueueOptions } from 'Activity/Queue/queueOptionsStore';
|
||||||
import { SelectProvider } from 'App/SelectContext';
|
import { SelectProvider, useSelect } from 'App/Select/SelectContext';
|
||||||
import * as commandNames from 'Commands/commandNames';
|
import CommandNames from 'Commands/CommandNames';
|
||||||
|
import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands';
|
||||||
import Alert from 'Components/Alert';
|
import Alert from 'Components/Alert';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||||
@@ -16,23 +16,21 @@ import Table from 'Components/Table/Table';
|
|||||||
import TableBody from 'Components/Table/TableBody';
|
import TableBody from 'Components/Table/TableBody';
|
||||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||||
import TablePager from 'Components/Table/TablePager';
|
import TablePager from 'Components/Table/TablePager';
|
||||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||||
import { align, icons, kinds } from 'Helpers/Props';
|
import { align, icons, kinds } from 'Helpers/Props';
|
||||||
import { executeCommand } from 'Store/Actions/commandActions';
|
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
import BlockListModel from 'typings/Blocklist';
|
||||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
|
||||||
import { CheckInputChanged } from 'typings/inputs';
|
import { CheckInputChanged } from 'typings/inputs';
|
||||||
import { SelectStateInputProps } from 'typings/props';
|
|
||||||
import { TableOptionsChangePayload } from 'typings/Table';
|
import { TableOptionsChangePayload } from 'typings/Table';
|
||||||
import {
|
import {
|
||||||
registerPagePopulator,
|
registerPagePopulator,
|
||||||
unregisterPagePopulator,
|
unregisterPagePopulator,
|
||||||
} from 'Utilities/pagePopulator';
|
} from 'Utilities/pagePopulator';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
|
||||||
import BlocklistFilterModal from './BlocklistFilterModal';
|
import BlocklistFilterModal from './BlocklistFilterModal';
|
||||||
import {
|
import {
|
||||||
setBlocklistOption,
|
setBlocklistOption,
|
||||||
|
setBlocklistSort,
|
||||||
useBlocklistOptions,
|
useBlocklistOptions,
|
||||||
} from './blocklistOptionsStore';
|
} from './blocklistOptionsStore';
|
||||||
import BlocklistRow from './BlocklistRow';
|
import BlocklistRow from './BlocklistRow';
|
||||||
@@ -41,7 +39,7 @@ import useBlocklist, {
|
|||||||
useRemoveBlocklistItems,
|
useRemoveBlocklistItems,
|
||||||
} from './useBlocklist';
|
} from './useBlocklist';
|
||||||
|
|
||||||
function Blocklist() {
|
function BlocklistContent() {
|
||||||
const {
|
const {
|
||||||
records,
|
records,
|
||||||
totalPages,
|
totalPages,
|
||||||
@@ -61,44 +59,34 @@ function Blocklist() {
|
|||||||
const filters = useFilters();
|
const filters = useFilters();
|
||||||
const { isRemoving, removeBlocklistItems } = useRemoveBlocklistItems();
|
const { isRemoving, removeBlocklistItems } = useRemoveBlocklistItems();
|
||||||
|
|
||||||
const customFilters = useSelector(createCustomFiltersSelector('blocklist'));
|
const customFilters = useCustomFiltersList('blocklist');
|
||||||
const isClearingBlocklistExecuting = useSelector(
|
const executeCommand = useExecuteCommand();
|
||||||
createCommandExecutingSelector(commandNames.CLEAR_BLOCKLIST)
|
const isClearingBlocklistExecuting = useCommandExecuting(
|
||||||
|
CommandNames.ClearBlocklist
|
||||||
);
|
);
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
|
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [isConfirmClearModalOpen, setIsConfirmClearModalOpen] = useState(false);
|
const [isConfirmClearModalOpen, setIsConfirmClearModalOpen] = useState(false);
|
||||||
|
|
||||||
const [selectState, setSelectState] = useSelectState();
|
const {
|
||||||
const { allSelected, allUnselected, selectedState } = selectState;
|
allSelected,
|
||||||
|
allUnselected,
|
||||||
const selectedIds = useMemo(() => {
|
anySelected,
|
||||||
return getSelectedIds(selectedState);
|
getSelectedIds,
|
||||||
}, [selectedState]);
|
selectAll,
|
||||||
|
unselectAll,
|
||||||
|
} = useSelect<BlockListModel>();
|
||||||
|
|
||||||
const handleSelectAllChange = useCallback(
|
const handleSelectAllChange = useCallback(
|
||||||
({ value }: CheckInputChanged) => {
|
({ value }: CheckInputChanged) => {
|
||||||
setSelectState({
|
if (value) {
|
||||||
type: value ? 'selectAll' : 'unselectAll',
|
selectAll();
|
||||||
items: records,
|
} else {
|
||||||
});
|
unselectAll();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[records, setSelectState]
|
[selectAll, unselectAll]
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectedChange = useCallback(
|
|
||||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
|
||||||
setSelectState({
|
|
||||||
type: 'toggleSelected',
|
|
||||||
items: records,
|
|
||||||
id,
|
|
||||||
isSelected: value,
|
|
||||||
shiftKey,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[records, setSelectState]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveSelectedPress = useCallback(() => {
|
const handleRemoveSelectedPress = useCallback(() => {
|
||||||
@@ -106,9 +94,9 @@ function Blocklist() {
|
|||||||
}, [setIsConfirmRemoveModalOpen]);
|
}, [setIsConfirmRemoveModalOpen]);
|
||||||
|
|
||||||
const handleRemoveSelectedConfirmed = useCallback(() => {
|
const handleRemoveSelectedConfirmed = useCallback(() => {
|
||||||
removeBlocklistItems({ ids: selectedIds });
|
removeBlocklistItems({ ids: getSelectedIds() });
|
||||||
setIsConfirmRemoveModalOpen(false);
|
setIsConfirmRemoveModalOpen(false);
|
||||||
}, [selectedIds, setIsConfirmRemoveModalOpen, removeBlocklistItems]);
|
}, [getSelectedIds, setIsConfirmRemoveModalOpen, removeBlocklistItems]);
|
||||||
|
|
||||||
const handleConfirmRemoveModalClose = useCallback(() => {
|
const handleConfirmRemoveModalClose = useCallback(() => {
|
||||||
setIsConfirmRemoveModalOpen(false);
|
setIsConfirmRemoveModalOpen(false);
|
||||||
@@ -119,16 +107,11 @@ function Blocklist() {
|
|||||||
}, [setIsConfirmClearModalOpen]);
|
}, [setIsConfirmClearModalOpen]);
|
||||||
|
|
||||||
const handleClearBlocklistConfirmed = useCallback(() => {
|
const handleClearBlocklistConfirmed = useCallback(() => {
|
||||||
dispatch(
|
executeCommand({ name: CommandNames.ClearBlocklist }, () => {
|
||||||
executeCommand({
|
goToPage(1);
|
||||||
name: commandNames.CLEAR_BLOCKLIST,
|
});
|
||||||
commandFinished: () => {
|
|
||||||
goToPage(1);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setIsConfirmClearModalOpen(false);
|
setIsConfirmClearModalOpen(false);
|
||||||
}, [setIsConfirmClearModalOpen, goToPage, dispatch]);
|
}, [setIsConfirmClearModalOpen, goToPage, executeCommand]);
|
||||||
|
|
||||||
const handleConfirmClearModalClose = useCallback(() => {
|
const handleConfirmClearModalClose = useCallback(() => {
|
||||||
setIsConfirmClearModalOpen(false);
|
setIsConfirmClearModalOpen(false);
|
||||||
@@ -141,9 +124,15 @@ function Blocklist() {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSortPress = useCallback((sortKey: string) => {
|
const handleSortPress = useCallback(
|
||||||
setBlocklistOption('sortKey', sortKey);
|
(sortKey: string, sortDirection?: SortDirection) => {
|
||||||
}, []);
|
setBlocklistSort({
|
||||||
|
sortKey,
|
||||||
|
sortDirection,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTableOptionChange = useCallback(
|
const handleTableOptionChange = useCallback(
|
||||||
(payload: TableOptionsChangePayload) => {
|
(payload: TableOptionsChangePayload) => {
|
||||||
@@ -169,124 +158,126 @@ function Blocklist() {
|
|||||||
}, [refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectProvider items={records}>
|
<PageContent title={translate('Blocklist')}>
|
||||||
<PageContent title={translate('Blocklist')}>
|
<PageToolbar>
|
||||||
<PageToolbar>
|
<PageToolbarSection>
|
||||||
<PageToolbarSection>
|
<PageToolbarButton
|
||||||
<PageToolbarButton
|
label={translate('RemoveSelected')}
|
||||||
label={translate('RemoveSelected')}
|
iconName={icons.REMOVE}
|
||||||
iconName={icons.REMOVE}
|
isDisabled={!anySelected}
|
||||||
isDisabled={!selectedIds.length}
|
isSpinning={isRemoving}
|
||||||
isSpinning={isRemoving}
|
onPress={handleRemoveSelectedPress}
|
||||||
onPress={handleRemoveSelectedPress}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<PageToolbarButton
|
<PageToolbarButton
|
||||||
label={translate('Clear')}
|
label={translate('Clear')}
|
||||||
iconName={icons.CLEAR}
|
iconName={icons.CLEAR}
|
||||||
isDisabled={!records.length}
|
isDisabled={!records.length}
|
||||||
isSpinning={isClearingBlocklistExecuting}
|
isSpinning={isClearingBlocklistExecuting}
|
||||||
onPress={handleClearBlocklistPress}
|
onPress={handleClearBlocklistPress}
|
||||||
/>
|
/>
|
||||||
</PageToolbarSection>
|
</PageToolbarSection>
|
||||||
|
|
||||||
<PageToolbarSection alignContent={align.RIGHT}>
|
<PageToolbarSection alignContent={align.RIGHT}>
|
||||||
<TableOptionsModalWrapper
|
<TableOptionsModalWrapper
|
||||||
|
columns={columns}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onTableOptionChange={handleTableOptionChange}
|
||||||
|
>
|
||||||
|
<PageToolbarButton
|
||||||
|
label={translate('Options')}
|
||||||
|
iconName={icons.TABLE}
|
||||||
|
/>
|
||||||
|
</TableOptionsModalWrapper>
|
||||||
|
|
||||||
|
<FilterMenu
|
||||||
|
alignMenu={align.RIGHT}
|
||||||
|
selectedFilterKey={selectedFilterKey}
|
||||||
|
filters={filters}
|
||||||
|
customFilters={customFilters}
|
||||||
|
filterModalConnectorComponent={BlocklistFilterModal}
|
||||||
|
onFilterSelect={handleFilterSelect}
|
||||||
|
/>
|
||||||
|
</PageToolbarSection>
|
||||||
|
</PageToolbar>
|
||||||
|
|
||||||
|
<PageContentBody>
|
||||||
|
{isLoading && !isFetched ? <LoadingIndicator /> : null}
|
||||||
|
|
||||||
|
{!isLoading && !!error ? (
|
||||||
|
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isFetched && !error && !records.length ? (
|
||||||
|
<Alert kind={kinds.INFO}>
|
||||||
|
{selectedFilterKey === 'all'
|
||||||
|
? translate('NoBlocklistItems')
|
||||||
|
: translate('BlocklistFilterHasNoItems')}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isFetched && !error && !!records.length ? (
|
||||||
|
<div>
|
||||||
|
<Table
|
||||||
|
selectAll={true}
|
||||||
|
allSelected={allSelected}
|
||||||
|
allUnselected={allUnselected}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDirection={sortDirection}
|
||||||
onTableOptionChange={handleTableOptionChange}
|
onTableOptionChange={handleTableOptionChange}
|
||||||
|
onSelectAllChange={handleSelectAllChange}
|
||||||
|
onSortPress={handleSortPress}
|
||||||
>
|
>
|
||||||
<PageToolbarButton
|
<TableBody>
|
||||||
label={translate('Options')}
|
{records.map((item) => {
|
||||||
iconName={icons.TABLE}
|
return (
|
||||||
/>
|
<BlocklistRow key={item.id} columns={columns} {...item} />
|
||||||
</TableOptionsModalWrapper>
|
);
|
||||||
|
})}
|
||||||
<FilterMenu
|
</TableBody>
|
||||||
alignMenu={align.RIGHT}
|
</Table>
|
||||||
selectedFilterKey={selectedFilterKey}
|
<TablePager
|
||||||
filters={filters}
|
page={page}
|
||||||
customFilters={customFilters}
|
totalPages={totalPages}
|
||||||
filterModalConnectorComponent={BlocklistFilterModal}
|
totalRecords={totalRecords}
|
||||||
onFilterSelect={handleFilterSelect}
|
isFetching={isFetching}
|
||||||
|
onPageSelect={goToPage}
|
||||||
/>
|
/>
|
||||||
</PageToolbarSection>
|
</div>
|
||||||
</PageToolbar>
|
) : null}
|
||||||
|
</PageContentBody>
|
||||||
|
|
||||||
<PageContentBody>
|
<ConfirmModal
|
||||||
{isLoading && !isFetched ? <LoadingIndicator /> : null}
|
isOpen={isConfirmRemoveModalOpen}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
title={translate('RemoveSelected')}
|
||||||
|
message={translate('RemoveSelectedBlocklistMessageText')}
|
||||||
|
confirmLabel={translate('RemoveSelected')}
|
||||||
|
onConfirm={handleRemoveSelectedConfirmed}
|
||||||
|
onCancel={handleConfirmRemoveModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
{!isLoading && !!error ? (
|
<ConfirmModal
|
||||||
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
|
isOpen={isConfirmClearModalOpen}
|
||||||
) : null}
|
kind={kinds.DANGER}
|
||||||
|
title={translate('ClearBlocklist')}
|
||||||
|
message={translate('ClearBlocklistMessageText')}
|
||||||
|
confirmLabel={translate('Clear')}
|
||||||
|
onConfirm={handleClearBlocklistConfirmed}
|
||||||
|
onCancel={handleConfirmClearModalClose}
|
||||||
|
/>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{isFetched && !error && !records.length ? (
|
function Blocklist() {
|
||||||
<Alert kind={kinds.INFO}>
|
const { records } = useBlocklist();
|
||||||
{selectedFilterKey === 'all'
|
|
||||||
? translate('NoBlocklistItems')
|
|
||||||
: translate('BlocklistFilterHasNoItems')}
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isFetched && !error && !!records.length ? (
|
return (
|
||||||
<div>
|
<SelectProvider<BlockListModel> items={records}>
|
||||||
<Table
|
<BlocklistContent />
|
||||||
selectAll={true}
|
|
||||||
allSelected={allSelected}
|
|
||||||
allUnselected={allUnselected}
|
|
||||||
columns={columns}
|
|
||||||
pageSize={pageSize}
|
|
||||||
sortKey={sortKey}
|
|
||||||
sortDirection={sortDirection}
|
|
||||||
onTableOptionChange={handleTableOptionChange}
|
|
||||||
onSelectAllChange={handleSelectAllChange}
|
|
||||||
onSortPress={handleSortPress}
|
|
||||||
>
|
|
||||||
<TableBody>
|
|
||||||
{records.map((item) => {
|
|
||||||
return (
|
|
||||||
<BlocklistRow
|
|
||||||
key={item.id}
|
|
||||||
isSelected={selectedState[item.id] || false}
|
|
||||||
columns={columns}
|
|
||||||
{...item}
|
|
||||||
onSelectedChange={handleSelectedChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
<TablePager
|
|
||||||
page={page}
|
|
||||||
totalPages={totalPages}
|
|
||||||
totalRecords={totalRecords}
|
|
||||||
isFetching={isFetching}
|
|
||||||
onPageSelect={goToPage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</PageContentBody>
|
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={isConfirmRemoveModalOpen}
|
|
||||||
kind={kinds.DANGER}
|
|
||||||
title={translate('RemoveSelected')}
|
|
||||||
message={translate('RemoveSelectedBlocklistMessageText')}
|
|
||||||
confirmLabel={translate('RemoveSelected')}
|
|
||||||
onConfirm={handleRemoveSelectedConfirmed}
|
|
||||||
onCancel={handleConfirmRemoveModalClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmModal
|
|
||||||
isOpen={isConfirmClearModalOpen}
|
|
||||||
kind={kinds.DANGER}
|
|
||||||
title={translate('ClearBlocklist')}
|
|
||||||
message={translate('ClearBlocklistMessageText')}
|
|
||||||
confirmLabel={translate('Clear')}
|
|
||||||
onConfirm={handleClearBlocklistConfirmed}
|
|
||||||
onCancel={handleConfirmClearModalClose}
|
|
||||||
/>
|
|
||||||
</PageContent>
|
|
||||||
</SelectProvider>
|
</SelectProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { useSelect } from 'App/Select/SelectContext';
|
||||||
import IconButton from 'Components/Link/IconButton';
|
import IconButton from 'Components/Link/IconButton';
|
||||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||||
@@ -10,7 +11,7 @@ import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
|||||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||||
import useSeries from 'Series/useSeries';
|
import { useSingleSeries } from 'Series/useSeries';
|
||||||
import Blocklist from 'typings/Blocklist';
|
import Blocklist from 'typings/Blocklist';
|
||||||
import { SelectStateInputProps } from 'typings/props';
|
import { SelectStateInputProps } from 'typings/props';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
@@ -19,9 +20,7 @@ import { useRemoveBlocklistItem } from './useBlocklist';
|
|||||||
import styles from './BlocklistRow.css';
|
import styles from './BlocklistRow.css';
|
||||||
|
|
||||||
interface BlocklistRowProps extends Blocklist {
|
interface BlocklistRowProps extends Blocklist {
|
||||||
isSelected: boolean;
|
|
||||||
columns: Column[];
|
columns: Column[];
|
||||||
onSelectedChange: (options: SelectStateInputProps) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlocklistRow({
|
function BlocklistRow({
|
||||||
@@ -36,13 +35,20 @@ function BlocklistRow({
|
|||||||
indexer,
|
indexer,
|
||||||
message,
|
message,
|
||||||
source,
|
source,
|
||||||
isSelected,
|
|
||||||
columns,
|
columns,
|
||||||
onSelectedChange,
|
|
||||||
}: BlocklistRowProps) {
|
}: BlocklistRowProps) {
|
||||||
const series = useSeries(seriesId);
|
const series = useSingleSeries(seriesId);
|
||||||
const { isRemoving, removeBlocklistItem } = useRemoveBlocklistItem(id);
|
const { isRemoving, removeBlocklistItem } = useRemoveBlocklistItem(id);
|
||||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||||
|
const { toggleSelected, useIsSelected } = useSelect<Blocklist>();
|
||||||
|
const isSelected = useIsSelected(id);
|
||||||
|
|
||||||
|
const handleSelectedChange = useCallback(
|
||||||
|
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||||
|
toggleSelected({ id, isSelected: value, shiftKey });
|
||||||
|
},
|
||||||
|
[toggleSelected]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDetailsPress = useCallback(() => {
|
const handleDetailsPress = useCallback(() => {
|
||||||
setIsDetailsModalOpen(true);
|
setIsDetailsModalOpen(true);
|
||||||
@@ -65,7 +71,7 @@ function BlocklistRow({
|
|||||||
<TableSelectCell
|
<TableSelectCell
|
||||||
id={id}
|
id={id}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onSelectedChange={onSelectedChange}
|
onSelectedChange={handleSelectedChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import translate from 'Utilities/String/translate';
|
|||||||
|
|
||||||
export type BlocklistOptions = PageableOptions;
|
export type BlocklistOptions = PageableOptions;
|
||||||
|
|
||||||
const { useOptions, useOption, setOptions, setOption } =
|
const { useOptions, useOption, setOptions, setOption, setSort } =
|
||||||
createOptionsStore<BlocklistOptions>('blocklist_options', () => {
|
createOptionsStore<BlocklistOptions>('blocklist_options', () => {
|
||||||
return {
|
return {
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
@@ -69,3 +69,4 @@ export const useBlocklistOptions = useOptions;
|
|||||||
export const setBlocklistOptions = setOptions;
|
export const setBlocklistOptions = setOptions;
|
||||||
export const useBlocklistOption = useOption;
|
export const useBlocklistOption = useOption;
|
||||||
export const setBlocklistOption = setOption;
|
export const setBlocklistOption = setOption;
|
||||||
|
export const setBlocklistSort = setSort;
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { Filter, FilterBuilderProp } from 'Filters/Filter';
|
||||||
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
|
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||||
import usePage from 'Helpers/Hooks/usePage';
|
import usePage from 'Helpers/Hooks/usePage';
|
||||||
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||||
import { filterBuilderValueTypes } from 'Helpers/Props';
|
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
|
||||||
import Blocklist from 'typings/Blocklist';
|
import Blocklist from 'typings/Blocklist';
|
||||||
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
@@ -43,9 +42,7 @@ const useBlocklist = () => {
|
|||||||
const { page, goToPage } = usePage('blocklist');
|
const { page, goToPage } = usePage('blocklist');
|
||||||
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
|
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
|
||||||
useBlocklistOptions();
|
useBlocklistOptions();
|
||||||
const customFilters = useSelector(
|
const customFilters = useCustomFiltersList('blocklist');
|
||||||
createCustomFiltersSelector('blocklist')
|
|
||||||
) as CustomFilter[];
|
|
||||||
|
|
||||||
const filters = useMemo(() => {
|
const filters = useMemo(() => {
|
||||||
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||||
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
|
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
|
||||||
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
|
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
|
||||||
import Link from 'Components/Link/Link';
|
import Link from 'Components/Link/Link';
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
|
||||||
import {
|
import {
|
||||||
DownloadFailedHistory,
|
DownloadFailedHistory,
|
||||||
DownloadFolderImportedHistory,
|
DownloadFolderImportedHistory,
|
||||||
@@ -33,9 +32,7 @@ interface HistoryDetailsProps {
|
|||||||
function HistoryDetails(props: HistoryDetailsProps) {
|
function HistoryDetails(props: HistoryDetailsProps) {
|
||||||
const { eventType, sourceTitle, data, downloadId } = props;
|
const { eventType, sourceTitle, data, downloadId } = props;
|
||||||
|
|
||||||
const { shortDateFormat, timeFormat } = useSelector(
|
const { shortDateFormat, timeFormat } = useUiSettingsValues();
|
||||||
createUISettingsSelector()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (eventType === 'grabbed') {
|
if (eventType === 'grabbed') {
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import {
|
|
||||||
setQueueOption,
|
|
||||||
setQueueOptions,
|
|
||||||
} from 'Activity/Queue/queueOptionsStore';
|
|
||||||
import Alert from 'Components/Alert';
|
import Alert from 'Components/Alert';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||||
@@ -16,12 +11,10 @@ import Table from 'Components/Table/Table';
|
|||||||
import TableBody from 'Components/Table/TableBody';
|
import TableBody from 'Components/Table/TableBody';
|
||||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||||
import TablePager from 'Components/Table/TablePager';
|
import TablePager from 'Components/Table/TablePager';
|
||||||
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
import useEpisodes from 'Episode/useEpisodes';
|
||||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||||
import { align, icons, kinds } from 'Helpers/Props';
|
import { align, icons, kinds } from 'Helpers/Props';
|
||||||
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||||
import { clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
|
|
||||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
|
||||||
import HistoryItem from 'typings/History';
|
import HistoryItem from 'typings/History';
|
||||||
import { TableOptionsChangePayload } from 'typings/Table';
|
import { TableOptionsChangePayload } from 'typings/Table';
|
||||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||||
@@ -31,7 +24,12 @@ import {
|
|||||||
} from 'Utilities/pagePopulator';
|
} from 'Utilities/pagePopulator';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import HistoryFilterModal from './HistoryFilterModal';
|
import HistoryFilterModal from './HistoryFilterModal';
|
||||||
import { useHistoryOptions } from './historyOptionsStore';
|
import {
|
||||||
|
setHistoryOption,
|
||||||
|
setHistoryOptions,
|
||||||
|
setHistorySort,
|
||||||
|
useHistoryOptions,
|
||||||
|
} from './historyOptionsStore';
|
||||||
import HistoryRow from './HistoryRow';
|
import HistoryRow from './HistoryRow';
|
||||||
import useHistory, { useFilters } from './useHistory';
|
import useHistory, { useFilters } from './useHistory';
|
||||||
|
|
||||||
@@ -52,33 +50,44 @@ function History() {
|
|||||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||||
useHistoryOptions();
|
useHistoryOptions();
|
||||||
|
|
||||||
|
const episodeIds = useMemo(() => {
|
||||||
|
return selectUniqueIds<HistoryItem, number>(records, 'episodeId');
|
||||||
|
}, [records]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isFetching: isEpisodesFetching,
|
||||||
|
isFetched: isEpisodesFetched,
|
||||||
|
error: episodesError,
|
||||||
|
} = useEpisodes({ episodeIds });
|
||||||
|
|
||||||
const filters = useFilters();
|
const filters = useFilters();
|
||||||
|
|
||||||
const requestCurrentPage = useCurrentPage();
|
const customFilters = useCustomFiltersList('history');
|
||||||
|
|
||||||
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
|
||||||
useSelector(createEpisodesFetchingSelector());
|
|
||||||
const customFilters = useSelector(createCustomFiltersSelector('history'));
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const isFetchingAny = isLoading || isEpisodesFetching;
|
const isFetchingAny = isLoading || isEpisodesFetching;
|
||||||
const isAllPopulated = isFetched && (isEpisodesPopulated || !records.length);
|
const isAllPopulated = isFetched && (isEpisodesFetched || !records.length);
|
||||||
const hasError = error || episodesError;
|
const hasError = error || episodesError;
|
||||||
|
|
||||||
const handleFilterSelect = useCallback(
|
const handleFilterSelect = useCallback(
|
||||||
(selectedFilterKey: string | number) => {
|
(selectedFilterKey: string | number) => {
|
||||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
setHistoryOption('selectedFilterKey', selectedFilterKey);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSortPress = useCallback((sortKey: string) => {
|
const handleSortPress = useCallback(
|
||||||
setQueueOption('sortKey', sortKey);
|
(sortKey: string, sortDirection?: SortDirection) => {
|
||||||
}, []);
|
setHistorySort({
|
||||||
|
sortKey,
|
||||||
|
sortDirection,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTableOptionChange = useCallback(
|
const handleTableOptionChange = useCallback(
|
||||||
(payload: TableOptionsChangePayload) => {
|
(payload: TableOptionsChangePayload) => {
|
||||||
setQueueOptions(payload);
|
setHistoryOptions(payload);
|
||||||
|
|
||||||
if (payload.pageSize) {
|
if (payload.pageSize) {
|
||||||
goToPage(1);
|
goToPage(1);
|
||||||
@@ -92,26 +101,6 @@ function History() {
|
|||||||
refetch();
|
refetch();
|
||||||
}, [goToPage, refetch]);
|
}, [goToPage, refetch]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
dispatch(clearEpisodes());
|
|
||||||
dispatch(clearEpisodeFiles());
|
|
||||||
};
|
|
||||||
}, [requestCurrentPage, dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const episodeIds = selectUniqueIds<HistoryItem, number>(
|
|
||||||
records,
|
|
||||||
'episodeId'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (episodeIds.length) {
|
|
||||||
dispatch(fetchEpisodes({ episodeIds }));
|
|
||||||
} else {
|
|
||||||
dispatch(clearEpisodes());
|
|
||||||
}
|
|
||||||
}, [records, dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const repopulate = () => {
|
const repopulate = () => {
|
||||||
refetch();
|
refetch();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { icons, tooltipPositions } from 'Helpers/Props';
|
|||||||
import Language from 'Language/Language';
|
import Language from 'Language/Language';
|
||||||
import { QualityModel } from 'Quality/Quality';
|
import { QualityModel } from 'Quality/Quality';
|
||||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||||
import useSeries from 'Series/useSeries';
|
import { useSingleSeries } from 'Series/useSeries';
|
||||||
import CustomFormat from 'typings/CustomFormat';
|
import CustomFormat from 'typings/CustomFormat';
|
||||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||||
@@ -61,7 +61,7 @@ function HistoryRow(props: HistoryRowProps) {
|
|||||||
columns,
|
columns,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const series = useSeries(seriesId);
|
const series = useSingleSeries(seriesId);
|
||||||
const episode = useEpisode(episodeId, 'episodes');
|
const episode = useEpisode(episodeId, 'episodes');
|
||||||
|
|
||||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ import translate from 'Utilities/String/translate';
|
|||||||
|
|
||||||
export type HistoryOptions = PageableOptions;
|
export type HistoryOptions = PageableOptions;
|
||||||
|
|
||||||
const { useOptions, useOption, setOptions, setOption } =
|
const { useOptions, useOption, setOptions, setOption, setSort } =
|
||||||
createOptionsStore<HistoryOptions>('history_options', () => {
|
createOptionsStore<HistoryOptions>('history_options', () => {
|
||||||
return {
|
return {
|
||||||
includeUnknownSeriesItems: true,
|
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
selectedFilterKey: 'all',
|
selectedFilterKey: 'all',
|
||||||
sortKey: 'time',
|
sortKey: 'time',
|
||||||
@@ -107,3 +106,4 @@ export const useHistoryOptions = useOptions;
|
|||||||
export const setHistoryOptions = setOptions;
|
export const setHistoryOptions = setOptions;
|
||||||
export const useHistoryOption = useOption;
|
export const useHistoryOption = useOption;
|
||||||
export const setHistoryOption = setOption;
|
export const setHistoryOption = setOption;
|
||||||
|
export const setHistorySort = setSort;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||||
|
import History from 'typings/History';
|
||||||
|
|
||||||
|
const DEFAULT_HISTORY: History[] = [];
|
||||||
|
|
||||||
|
const useEpisodeHistory = (episodeId: number) => {
|
||||||
|
const { data, ...result } = useApiQuery<History[]>({
|
||||||
|
path: '/history/episode',
|
||||||
|
queryParams: {
|
||||||
|
episodeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data ?? DEFAULT_HISTORY,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useEpisodeHistory;
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { Filter, FilterBuilderProp } from 'Filters/Filter';
|
||||||
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
|
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||||
import usePage from 'Helpers/Hooks/usePage';
|
import usePage from 'Helpers/Hooks/usePage';
|
||||||
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||||
import { filterBuilderValueTypes } from 'Helpers/Props';
|
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
|
||||||
import History from 'typings/History';
|
import History from 'typings/History';
|
||||||
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
@@ -113,13 +112,18 @@ export const FILTER_BUILDER: FilterBuilderProp<History>[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type HistoryType = 'episode' | 'series';
|
||||||
|
|
||||||
|
const MARK_AS_FAILED_QUERY_KEYS: Record<HistoryType, string> = {
|
||||||
|
episode: '/history/episode',
|
||||||
|
series: '/history/series',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const useHistory = () => {
|
const useHistory = () => {
|
||||||
const { page, goToPage } = usePage('history');
|
const { page, goToPage } = usePage('history');
|
||||||
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
|
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
|
||||||
useHistoryOptions();
|
useHistoryOptions();
|
||||||
const customFilters = useSelector(
|
const customFilters = useCustomFiltersList('history');
|
||||||
createCustomFiltersSelector('history')
|
|
||||||
) as CustomFilter[];
|
|
||||||
|
|
||||||
const filters = useMemo(() => {
|
const filters = useMemo(() => {
|
||||||
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
||||||
@@ -158,7 +162,7 @@ export const useFilters = () => {
|
|||||||
return FILTERS;
|
return FILTERS;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMarkAsFailed = (id: number) => {
|
export const useMarkAsFailed = (id: number, type?: HistoryType) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -170,7 +174,9 @@ export const useMarkAsFailed = (id: number) => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['/history'] });
|
const queryKey = type ? MARK_AS_FAILED_QUERY_KEYS[type] : '/history';
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: [queryKey] });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setError('Error marking history item as failed');
|
setError('Error marking history item as failed');
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||||
|
import History from 'typings/History';
|
||||||
|
|
||||||
|
const DEFAULT_HISTORY: History[] = [];
|
||||||
|
|
||||||
|
const useSeriesHistory = (
|
||||||
|
seriesId: number,
|
||||||
|
seasonNumber: number | undefined
|
||||||
|
) => {
|
||||||
|
const { data, ...result } = useApiQuery<History[]>({
|
||||||
|
path: '/history/series',
|
||||||
|
queryParams: {
|
||||||
|
seriesId,
|
||||||
|
seasonNumber,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data ?? DEFAULT_HISTORY,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSeriesHistory;
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
|
import React, {
|
||||||
|
createContext,
|
||||||
|
PropsWithChildren,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
} from 'react';
|
||||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||||
import Queue from 'typings/Queue';
|
import Queue from 'typings/Queue';
|
||||||
|
|
||||||
@@ -16,16 +21,12 @@ interface AllDetails {
|
|||||||
|
|
||||||
type QueueDetailsFilter = AllDetails | EpisodeDetails | SeriesDetails;
|
type QueueDetailsFilter = AllDetails | EpisodeDetails | SeriesDetails;
|
||||||
|
|
||||||
interface QueueDetailsProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QueueDetailsContext = createContext<Queue[] | undefined>(undefined);
|
const QueueDetailsContext = createContext<Queue[] | undefined>(undefined);
|
||||||
|
|
||||||
export default function QueueDetailsProvider({
|
export default function QueueDetailsProvider({
|
||||||
children,
|
children,
|
||||||
...filter
|
...filter
|
||||||
}: QueueDetailsProps & QueueDetailsFilter) {
|
}: PropsWithChildren<QueueDetailsFilter>) {
|
||||||
const { data } = useApiQuery<Queue[]>({
|
const { data } = useApiQuery<Queue[]>({
|
||||||
path: '/queue/details',
|
path: '/queue/details',
|
||||||
queryParams: { ...filter },
|
queryParams: { ...filter },
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { SelectProvider, useSelect } from 'App/Select/SelectContext';
|
||||||
import * as commandNames from 'Commands/commandNames';
|
import CommandNames from 'Commands/CommandNames';
|
||||||
|
import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands';
|
||||||
import Alert from 'Components/Alert';
|
import Alert from 'Components/Alert';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||||
@@ -21,15 +22,13 @@ import Table from 'Components/Table/Table';
|
|||||||
import TableBody from 'Components/Table/TableBody';
|
import TableBody from 'Components/Table/TableBody';
|
||||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||||
import TablePager from 'Components/Table/TablePager';
|
import TablePager from 'Components/Table/TablePager';
|
||||||
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
import useEpisodes from 'Episode/useEpisodes';
|
||||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||||
import { align, icons, kinds } from 'Helpers/Props';
|
import { align, icons, kinds } from 'Helpers/Props';
|
||||||
import { executeCommand } from 'Store/Actions/commandActions';
|
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||||
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
|
||||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
|
||||||
import { CheckInputChanged } from 'typings/inputs';
|
import { CheckInputChanged } from 'typings/inputs';
|
||||||
import { SelectStateInputProps } from 'typings/props';
|
import QueueModel from 'typings/Queue';
|
||||||
import { TableOptionsChangePayload } from 'typings/Table';
|
import { TableOptionsChangePayload } from 'typings/Table';
|
||||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||||
import {
|
import {
|
||||||
@@ -37,12 +36,11 @@ import {
|
|||||||
unregisterPagePopulator,
|
unregisterPagePopulator,
|
||||||
} from 'Utilities/pagePopulator';
|
} from 'Utilities/pagePopulator';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
|
||||||
import QueueFilterModal from './QueueFilterModal';
|
import QueueFilterModal from './QueueFilterModal';
|
||||||
import QueueOptions from './QueueOptions';
|
|
||||||
import {
|
import {
|
||||||
setQueueOption,
|
setQueueOption,
|
||||||
setQueueOptions,
|
setQueueOptions,
|
||||||
|
setQueueSort,
|
||||||
useQueueOptions,
|
useQueueOptions,
|
||||||
} from './queueOptionsStore';
|
} from './queueOptionsStore';
|
||||||
import QueueRow from './QueueRow';
|
import QueueRow from './QueueRow';
|
||||||
@@ -54,8 +52,8 @@ import useQueue, {
|
|||||||
useRemoveQueueItems,
|
useRemoveQueueItems,
|
||||||
} from './useQueue';
|
} from './useQueue';
|
||||||
|
|
||||||
function Queue() {
|
function QueueContent() {
|
||||||
const dispatch = useDispatch();
|
const executeCommand = useExecuteCommand();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
records,
|
records,
|
||||||
@@ -78,24 +76,30 @@ function Queue() {
|
|||||||
const { isGrabbing, grabQueueItems } = useGrabQueueItems();
|
const { isGrabbing, grabQueueItems } = useGrabQueueItems();
|
||||||
|
|
||||||
const { count } = useQueueStatus();
|
const { count } = useQueueStatus();
|
||||||
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
|
||||||
useSelector(createEpisodesFetchingSelector());
|
|
||||||
const customFilters = useSelector(createCustomFiltersSelector('queue'));
|
|
||||||
|
|
||||||
const isRefreshMonitoredDownloadsExecuting = useSelector(
|
const episodeIds = useMemo(() => {
|
||||||
createCommandExecutingSelector(commandNames.REFRESH_MONITORED_DOWNLOADS)
|
return selectUniqueIds<QueueModel, number>(records, 'episodeIds');
|
||||||
|
}, [records]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isFetching: isEpisodesFetching,
|
||||||
|
isFetched: isEpisodesFetched,
|
||||||
|
error: episodesError,
|
||||||
|
} = useEpisodes({ episodeIds });
|
||||||
|
|
||||||
|
const customFilters = useCustomFiltersList('queue');
|
||||||
|
|
||||||
|
const isRefreshMonitoredDownloadsExecuting = useCommandExecuting(
|
||||||
|
CommandNames.RefreshMonitoredDownloads
|
||||||
);
|
);
|
||||||
|
|
||||||
const shouldBlockRefresh = useRef(false);
|
const shouldBlockRefresh = useRef(false);
|
||||||
const currentQueue = useRef<ReactElement | null>(null);
|
const currentQueue = useRef<ReactElement | null>(null);
|
||||||
|
|
||||||
const [selectState, setSelectState] = useSelectState();
|
const { allSelected, allUnselected, selectAll, unselectAll, useSelectedIds } =
|
||||||
const { allSelected, allUnselected, selectedState } = selectState;
|
useSelect<QueueModel>();
|
||||||
|
|
||||||
const selectedIds = useMemo(() => {
|
|
||||||
return getSelectedIds(selectedState);
|
|
||||||
}, [selectedState]);
|
|
||||||
|
|
||||||
|
const selectedIds = useSelectedIds();
|
||||||
const isPendingSelected = useMemo(() => {
|
const isPendingSelected = useMemo(() => {
|
||||||
return records.some((item) => {
|
return records.some((item) => {
|
||||||
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
|
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
|
||||||
@@ -105,13 +109,16 @@ function Queue() {
|
|||||||
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
|
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
|
const [isInteractiveImportDownloadIds, setIsInteractiveImportDownloadIds] =
|
||||||
|
useState<string[]>(() => []);
|
||||||
|
|
||||||
const isRefreshing =
|
const isRefreshing =
|
||||||
isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
||||||
|
|
||||||
// Use isLoading over isFetched to avoid losing the table UI when switching pages
|
// Use isLoading over isFetched to avoid losing the table UI when switching pages
|
||||||
const isAllPopulated =
|
const isAllPopulated =
|
||||||
!isLoading &&
|
!isLoading &&
|
||||||
(isEpisodesPopulated ||
|
(isEpisodesFetched ||
|
||||||
!records.length ||
|
!records.length ||
|
||||||
records.every((e) => !e.episodeIds?.length));
|
records.every((e) => !e.episodeIds?.length));
|
||||||
const hasError = error || episodesError;
|
const hasError = error || episodesError;
|
||||||
@@ -120,34 +127,20 @@ function Queue() {
|
|||||||
|
|
||||||
const handleSelectAllChange = useCallback(
|
const handleSelectAllChange = useCallback(
|
||||||
({ value }: CheckInputChanged) => {
|
({ value }: CheckInputChanged) => {
|
||||||
setSelectState({
|
if (value) {
|
||||||
type: value ? 'selectAll' : 'unselectAll',
|
selectAll();
|
||||||
items: records,
|
} else {
|
||||||
});
|
unselectAll();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[records, setSelectState]
|
[selectAll, unselectAll]
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectedChange = useCallback(
|
|
||||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
|
||||||
setSelectState({
|
|
||||||
type: 'toggleSelected',
|
|
||||||
items: records,
|
|
||||||
id,
|
|
||||||
isSelected: value,
|
|
||||||
shiftKey,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[records, setSelectState]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRefreshPress = useCallback(() => {
|
const handleRefreshPress = useCallback(() => {
|
||||||
dispatch(
|
executeCommand({
|
||||||
executeCommand({
|
name: CommandNames.RefreshMonitoredDownloads,
|
||||||
name: commandNames.REFRESH_MONITORED_DOWNLOADS,
|
});
|
||||||
})
|
}, [executeCommand]);
|
||||||
);
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const handleQueueRowModalOpenOrClose = useCallback((isOpen: boolean) => {
|
const handleQueueRowModalOpenOrClose = useCallback((isOpen: boolean) => {
|
||||||
shouldBlockRefresh.current = isOpen;
|
shouldBlockRefresh.current = isOpen;
|
||||||
@@ -166,12 +159,30 @@ function Queue() {
|
|||||||
shouldBlockRefresh.current = false;
|
shouldBlockRefresh.current = false;
|
||||||
removeQueueItems({ ids: selectedIds });
|
removeQueueItems({ ids: selectedIds });
|
||||||
setIsConfirmRemoveModalOpen(false);
|
setIsConfirmRemoveModalOpen(false);
|
||||||
}, [selectedIds, setIsConfirmRemoveModalOpen, removeQueueItems]);
|
}, [selectedIds, removeQueueItems]);
|
||||||
|
|
||||||
const handleConfirmRemoveModalClose = useCallback(() => {
|
const handleConfirmRemoveModalClose = useCallback(() => {
|
||||||
shouldBlockRefresh.current = false;
|
shouldBlockRefresh.current = false;
|
||||||
setIsConfirmRemoveModalOpen(false);
|
setIsConfirmRemoveModalOpen(false);
|
||||||
}, [setIsConfirmRemoveModalOpen]);
|
}, []);
|
||||||
|
|
||||||
|
const handleImportSelectedPress = useCallback(() => {
|
||||||
|
shouldBlockRefresh.current = true;
|
||||||
|
setIsInteractiveImportDownloadIds(
|
||||||
|
selectedIds
|
||||||
|
.map((id) => {
|
||||||
|
const item = records.find((i) => i.id === id);
|
||||||
|
|
||||||
|
return item?.downloadId;
|
||||||
|
})
|
||||||
|
.filter((id): id is string => !!id)
|
||||||
|
);
|
||||||
|
}, [records, selectedIds]);
|
||||||
|
|
||||||
|
const handleImportSelectedModalClose = useCallback(() => {
|
||||||
|
shouldBlockRefresh.current = false;
|
||||||
|
setIsInteractiveImportDownloadIds([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFilterSelect = useCallback(
|
const handleFilterSelect = useCallback(
|
||||||
(selectedFilterKey: string | number) => {
|
(selectedFilterKey: string | number) => {
|
||||||
@@ -180,9 +191,15 @@ function Queue() {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSortPress = useCallback((sortKey: string) => {
|
const handleSortPress = useCallback(
|
||||||
setQueueOption('sortKey', sortKey);
|
(sortKey: string, sortDirection?: SortDirection) => {
|
||||||
}, []);
|
setQueueSort({
|
||||||
|
sortKey,
|
||||||
|
sortDirection,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleTableOptionChange = useCallback(
|
const handleTableOptionChange = useCallback(
|
||||||
(payload: TableOptionsChangePayload) => {
|
(payload: TableOptionsChangePayload) => {
|
||||||
@@ -195,16 +212,6 @@ function Queue() {
|
|||||||
[goToPage]
|
[goToPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const episodeIds = selectUniqueIds(records, 'episodeIds');
|
|
||||||
|
|
||||||
if (episodeIds.length) {
|
|
||||||
dispatch(fetchEpisodes({ episodeIds }));
|
|
||||||
} else {
|
|
||||||
dispatch(clearEpisodes());
|
|
||||||
}
|
|
||||||
}, [records, dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const repopulate = () => {
|
const repopulate = () => {
|
||||||
refetch();
|
refetch();
|
||||||
@@ -244,7 +251,6 @@ function Queue() {
|
|||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
sortKey={sortKey}
|
sortKey={sortKey}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
optionsComponent={QueueOptions}
|
|
||||||
onTableOptionChange={handleTableOptionChange}
|
onTableOptionChange={handleTableOptionChange}
|
||||||
onSelectAllChange={handleSelectAllChange}
|
onSelectAllChange={handleSelectAllChange}
|
||||||
onSortPress={handleSortPress}
|
onSortPress={handleSortPress}
|
||||||
@@ -254,10 +260,8 @@ function Queue() {
|
|||||||
return (
|
return (
|
||||||
<QueueRow
|
<QueueRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
isSelected={selectedState[item.id]}
|
|
||||||
columns={columns}
|
columns={columns}
|
||||||
{...item}
|
{...item}
|
||||||
onSelectedChange={handleSelectedChange}
|
|
||||||
onQueueRowModalOpenOrClose={
|
onQueueRowModalOpenOrClose={
|
||||||
handleQueueRowModalOpenOrClose
|
handleQueueRowModalOpenOrClose
|
||||||
}
|
}
|
||||||
@@ -308,6 +312,15 @@ function Queue() {
|
|||||||
isSpinning={isRemoving}
|
isSpinning={isRemoving}
|
||||||
onPress={handleRemoveSelectedPress}
|
onPress={handleRemoveSelectedPress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PageToolbarSeparator />
|
||||||
|
|
||||||
|
<PageToolbarButton
|
||||||
|
label={translate('ImportSelected')}
|
||||||
|
iconName={icons.INTERACTIVE}
|
||||||
|
isDisabled={disableSelectedActions}
|
||||||
|
onPress={handleImportSelectedPress}
|
||||||
|
/>
|
||||||
</PageToolbarSection>
|
</PageToolbarSection>
|
||||||
|
|
||||||
<PageToolbarSection alignContent={align.RIGHT}>
|
<PageToolbarSection alignContent={align.RIGHT}>
|
||||||
@@ -315,7 +328,6 @@ function Queue() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
maxPageSize={200}
|
maxPageSize={200}
|
||||||
optionsComponent={QueueOptions}
|
|
||||||
onTableOptionChange={handleTableOptionChange}
|
onTableOptionChange={handleTableOptionChange}
|
||||||
>
|
>
|
||||||
<PageToolbarButton
|
<PageToolbarButton
|
||||||
@@ -342,7 +354,7 @@ function Queue() {
|
|||||||
selectedCount={selectedCount}
|
selectedCount={selectedCount}
|
||||||
canChangeCategory={
|
canChangeCategory={
|
||||||
isConfirmRemoveModalOpen &&
|
isConfirmRemoveModalOpen &&
|
||||||
selectedIds.every((id) => {
|
selectedIds.every((id: number) => {
|
||||||
const item = records.find((i) => i.id === id);
|
const item = records.find((i) => i.id === id);
|
||||||
|
|
||||||
return !!(item && item.downloadClientHasPostImportCategory);
|
return !!(item && item.downloadClientHasPostImportCategory);
|
||||||
@@ -350,7 +362,7 @@ function Queue() {
|
|||||||
}
|
}
|
||||||
canIgnore={
|
canIgnore={
|
||||||
isConfirmRemoveModalOpen &&
|
isConfirmRemoveModalOpen &&
|
||||||
selectedIds.every((id) => {
|
selectedIds.every((id: number) => {
|
||||||
const item = records.find((i) => i.id === id);
|
const item = records.find((i) => i.id === id);
|
||||||
|
|
||||||
return !!(item && item.seriesId && item.episodeId);
|
return !!(item && item.seriesId && item.episodeId);
|
||||||
@@ -358,7 +370,7 @@ function Queue() {
|
|||||||
}
|
}
|
||||||
isPending={
|
isPending={
|
||||||
isConfirmRemoveModalOpen &&
|
isConfirmRemoveModalOpen &&
|
||||||
selectedIds.every((id) => {
|
selectedIds.every((id: number) => {
|
||||||
const item = records.find((i) => i.id === id);
|
const item = records.find((i) => i.id === id);
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
@@ -374,8 +386,25 @@ function Queue() {
|
|||||||
onRemovePress={handleRemoveSelectedConfirmed}
|
onRemovePress={handleRemoveSelectedConfirmed}
|
||||||
onModalClose={handleConfirmRemoveModalClose}
|
onModalClose={handleConfirmRemoveModalClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<InteractiveImportModal
|
||||||
|
isOpen={isInteractiveImportDownloadIds.length > 0}
|
||||||
|
downloadIds={isInteractiveImportDownloadIds}
|
||||||
|
title={translate('InteractiveImportMultipleQueueItems')}
|
||||||
|
onModalClose={handleImportSelectedModalClose}
|
||||||
|
/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Queue() {
|
||||||
|
const { records } = useQueue();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectProvider<QueueModel> items={records}>
|
||||||
|
<QueueContent />
|
||||||
|
</SelectProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default Queue;
|
export default Queue;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
import { SetFilter } from 'Components/Filter/Filter';
|
||||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||||
import { setQueueOption } from './queueOptionsStore';
|
import { setQueueOption } from './queueOptionsStore';
|
||||||
import useQueue, { FILTER_BUILDER } from './useQueue';
|
import useQueue, { FILTER_BUILDER } from './useQueue';
|
||||||
@@ -8,12 +9,9 @@ type QueueFilterModalProps = FilterModalProps<History>;
|
|||||||
export default function QueueFilterModal(props: QueueFilterModalProps) {
|
export default function QueueFilterModal(props: QueueFilterModalProps) {
|
||||||
const { records } = useQueue();
|
const { records } = useQueue();
|
||||||
|
|
||||||
const dispatchSetFilter = useCallback(
|
const dispatchSetFilter = useCallback(({ selectedFilterKey }: SetFilter) => {
|
||||||
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
}, []);
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterModal
|
<FilterModal
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import React, { useCallback } from 'react';
|
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
|
||||||
import FormLabel from 'Components/Form/FormLabel';
|
|
||||||
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
|
|
||||||
import { inputTypes } from 'Helpers/Props';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import {
|
|
||||||
QueueOptions as QueueOptionsType,
|
|
||||||
setQueueOption,
|
|
||||||
useQueueOption,
|
|
||||||
} from './queueOptionsStore';
|
|
||||||
import useQueue from './useQueue';
|
|
||||||
|
|
||||||
function QueueOptions() {
|
|
||||||
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
|
|
||||||
const { goToPage } = useQueue();
|
|
||||||
|
|
||||||
const handleOptionChange = useCallback(
|
|
||||||
({ name, value }: OptionChanged<QueueOptionsType>) => {
|
|
||||||
setQueueOption(name, value);
|
|
||||||
|
|
||||||
if (name === 'includeUnknownSeriesItems') {
|
|
||||||
goToPage(1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[goToPage]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('ShowUnknownSeriesItems')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.CHECK}
|
|
||||||
name="includeUnknownSeriesItems"
|
|
||||||
value={includeUnknownSeriesItems}
|
|
||||||
helpText={translate('ShowUnknownSeriesItemsHelpText')}
|
|
||||||
// @ts-expect-error - The typing for inputs needs more work
|
|
||||||
onChange={handleOptionChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default QueueOptions;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
||||||
|
import { useSelect } from 'App/Select/SelectContext';
|
||||||
import IconButton from 'Components/Link/IconButton';
|
import IconButton from 'Components/Link/IconButton';
|
||||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||||
import ProgressBar from 'Components/ProgressBar';
|
import ProgressBar from 'Components/ProgressBar';
|
||||||
@@ -14,17 +14,17 @@ import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
|||||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||||
import useEpisodes from 'Episode/useEpisodes';
|
import { useEpisodesWithIds } from 'Episode/useEpisode';
|
||||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||||
import Language from 'Language/Language';
|
import Language from 'Language/Language';
|
||||||
import { QualityModel } from 'Quality/Quality';
|
import { QualityModel } from 'Quality/Quality';
|
||||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||||
import useSeries from 'Series/useSeries';
|
import { useSingleSeries } from 'Series/useSeries';
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
|
||||||
import CustomFormat from 'typings/CustomFormat';
|
import CustomFormat from 'typings/CustomFormat';
|
||||||
import { SelectStateInputProps } from 'typings/props';
|
import { SelectStateInputProps } from 'typings/props';
|
||||||
import {
|
import Queue, {
|
||||||
QueueTrackedDownloadState,
|
QueueTrackedDownloadState,
|
||||||
QueueTrackedDownloadStatus,
|
QueueTrackedDownloadStatus,
|
||||||
StatusMessage,
|
StatusMessage,
|
||||||
@@ -44,7 +44,7 @@ interface QueueRowProps {
|
|||||||
id: number;
|
id: number;
|
||||||
seriesId?: number;
|
seriesId?: number;
|
||||||
episodeIds: number[];
|
episodeIds: number[];
|
||||||
downloadId?: string;
|
downloadId: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: string;
|
status: string;
|
||||||
trackedDownloadStatus?: QueueTrackedDownloadStatus;
|
trackedDownloadStatus?: QueueTrackedDownloadStatus;
|
||||||
@@ -68,9 +68,7 @@ interface QueueRowProps {
|
|||||||
size: number;
|
size: number;
|
||||||
sizeLeft: number;
|
sizeLeft: number;
|
||||||
isRemoving?: boolean;
|
isRemoving?: boolean;
|
||||||
isSelected?: boolean;
|
|
||||||
columns: Column[];
|
columns: Column[];
|
||||||
onSelectedChange: (options: SelectStateInputProps) => void;
|
|
||||||
onQueueRowModalOpenOrClose: (isOpen: boolean) => void;
|
onQueueRowModalOpenOrClose: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,19 +100,18 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
timeLeft,
|
timeLeft,
|
||||||
size,
|
size,
|
||||||
sizeLeft,
|
sizeLeft,
|
||||||
isSelected,
|
|
||||||
columns,
|
columns,
|
||||||
onSelectedChange,
|
|
||||||
onQueueRowModalOpenOrClose,
|
onQueueRowModalOpenOrClose,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const series = useSeries(seriesId);
|
const series = useSingleSeries(seriesId);
|
||||||
const episodes = useEpisodes(episodeIds, 'episodes');
|
const episodes = useEpisodesWithIds(episodeIds);
|
||||||
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
|
const { showRelativeDates, shortDateFormat, timeFormat } =
|
||||||
createUISettingsSelector()
|
useUiSettingsValues();
|
||||||
);
|
|
||||||
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
|
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
|
||||||
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
|
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
|
||||||
|
const { toggleSelected, useIsSelected } = useSelect<Queue>();
|
||||||
|
const isSelected = useIsSelected(id);
|
||||||
|
|
||||||
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
|
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -156,6 +153,17 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
setIsRemoveQueueItemModalOpen(false);
|
setIsRemoveQueueItemModalOpen(false);
|
||||||
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
||||||
|
|
||||||
|
const handleSelectedChange = useCallback(
|
||||||
|
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||||
|
toggleSelected({
|
||||||
|
id,
|
||||||
|
isSelected: value,
|
||||||
|
shiftKey,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[toggleSelected]
|
||||||
|
);
|
||||||
|
|
||||||
const progress = 100 - (sizeLeft / size) * 100;
|
const progress = 100 - (sizeLeft / size) * 100;
|
||||||
const showInteractiveImport =
|
const showInteractiveImport =
|
||||||
status === 'completed' && trackedDownloadStatus === 'warning';
|
status === 'completed' && trackedDownloadStatus === 'warning';
|
||||||
@@ -167,7 +175,7 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
<TableSelectCell
|
<TableSelectCell
|
||||||
id={id}
|
id={id}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onSelectedChange={onSelectedChange}
|
onSelectedChange={handleSelectedChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
@@ -389,8 +397,8 @@ function QueueRow(props: QueueRowProps) {
|
|||||||
|
|
||||||
<InteractiveImportModal
|
<InteractiveImportModal
|
||||||
isOpen={isInteractiveImportModalOpen}
|
isOpen={isInteractiveImportModalOpen}
|
||||||
downloadId={downloadId}
|
downloadIds={[downloadId]}
|
||||||
modalTitle={title}
|
title={title}
|
||||||
onModalClose={handleInteractiveImportModalClose}
|
onModalClose={handleInteractiveImportModalClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
|
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
import useQueueStatus from './useQueueStatus';
|
import useQueueStatus from './useQueueStatus';
|
||||||
|
|
||||||
function QueueStatus() {
|
function QueueStatus() {
|
||||||
const { errors, warnings, count } = useQueueStatus();
|
const { errors, warnings, count } = useQueueStatus();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />
|
<PageSidebarStatus
|
||||||
|
aria-label={
|
||||||
|
count === 1
|
||||||
|
? translate('QueueItem')
|
||||||
|
: translate('QueueItems', { count })
|
||||||
|
}
|
||||||
|
count={count}
|
||||||
|
errors={errors}
|
||||||
|
warnings={warnings}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||||
import { useQueueOption } from '../queueOptionsStore';
|
|
||||||
|
|
||||||
export interface QueueStatus {
|
export interface QueueStatus {
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
@@ -12,13 +11,8 @@ export interface QueueStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function useQueueStatus() {
|
export default function useQueueStatus() {
|
||||||
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
|
|
||||||
|
|
||||||
const { data } = useApiQuery<QueueStatus>({
|
const { data } = useApiQuery<QueueStatus>({
|
||||||
path: '/queue/status',
|
path: '/queue/status',
|
||||||
queryParams: {
|
|
||||||
includeUnknownSeriesItems,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -29,26 +23,11 @@ export default function useQueueStatus() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { errors, warnings, unknownErrors, unknownWarnings, totalCount } = data;
|
||||||
errors,
|
|
||||||
warnings,
|
|
||||||
unknownErrors,
|
|
||||||
unknownWarnings,
|
|
||||||
count,
|
|
||||||
totalCount,
|
|
||||||
} = data;
|
|
||||||
|
|
||||||
if (includeUnknownSeriesItems) {
|
|
||||||
return {
|
|
||||||
count: totalCount,
|
|
||||||
errors: errors || unknownErrors,
|
|
||||||
warnings: warnings || unknownWarnings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
count,
|
count: totalCount,
|
||||||
errors,
|
errors: errors || unknownErrors,
|
||||||
warnings,
|
warnings: warnings || unknownWarnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,12 @@ interface QueueRemovalOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface QueueOptions extends PageableOptions {
|
export interface QueueOptions extends PageableOptions {
|
||||||
includeUnknownSeriesItems: boolean;
|
|
||||||
removalOptions: QueueRemovalOptions;
|
removalOptions: QueueRemovalOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { useOptions, useOption, setOptions, setOption } =
|
const { useOptions, useOption, setOptions, setOption, setSort } =
|
||||||
createOptionsStore<QueueOptions>('queue_options', () => {
|
createOptionsStore<QueueOptions>('queue_options', () => {
|
||||||
return {
|
return {
|
||||||
includeUnknownSeriesItems: true,
|
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
selectedFilterKey: 'all',
|
selectedFilterKey: 'all',
|
||||||
sortKey: 'time',
|
sortKey: 'time',
|
||||||
@@ -158,3 +156,4 @@ export const useQueueOptions = useOptions;
|
|||||||
export const setQueueOptions = setOptions;
|
export const setQueueOptions = setOptions;
|
||||||
export const useQueueOption = useOption;
|
export const useQueueOption = useOption;
|
||||||
export const setQueueOption = setOption;
|
export const setQueueOption = setOption;
|
||||||
|
export const setQueueSort = setSort;
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { Filter, FilterBuilderProp } from 'Filters/Filter';
|
||||||
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
|
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||||
import usePage from 'Helpers/Hooks/usePage';
|
import usePage from 'Helpers/Hooks/usePage';
|
||||||
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||||
import { filterBuilderValueTypes } from 'Helpers/Props';
|
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
|
||||||
import Queue from 'typings/Queue';
|
import Queue from 'typings/Queue';
|
||||||
import getQueryString from 'Utilities/Fetch/getQueryString';
|
import getQueryString from 'Utilities/Fetch/getQueryString';
|
||||||
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||||
@@ -23,6 +22,17 @@ export const FILTERS: Filter[] = [
|
|||||||
label: () => translate('All'),
|
label: () => translate('All'),
|
||||||
filters: [],
|
filters: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'excludeUnknownSeriesItems',
|
||||||
|
label: () => translate('ExcludeUnknownSeriesItems'),
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: 'includeUnknownSeriesItems',
|
||||||
|
value: [false],
|
||||||
|
type: 'equal',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FILTER_BUILDER: FilterBuilderProp<Queue>[] = [
|
export const FILTER_BUILDER: FilterBuilderProp<Queue>[] = [
|
||||||
@@ -56,20 +66,19 @@ export const FILTER_BUILDER: FilterBuilderProp<Queue>[] = [
|
|||||||
type: 'equal',
|
type: 'equal',
|
||||||
valueType: filterBuilderValueTypes.QUEUE_STATUS,
|
valueType: filterBuilderValueTypes.QUEUE_STATUS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'includeUnknownSeriesItems',
|
||||||
|
label: () => translate('UnknownSeriesItems'),
|
||||||
|
type: 'equal',
|
||||||
|
valueType: filterBuilderValueTypes.BOOL,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const useQueue = () => {
|
const useQueue = () => {
|
||||||
const { page, goToPage } = usePage('queue');
|
const { page, goToPage } = usePage('queue');
|
||||||
const {
|
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
|
||||||
includeUnknownSeriesItems,
|
useQueueOptions();
|
||||||
pageSize,
|
const customFilters = useCustomFiltersList('queue');
|
||||||
selectedFilterKey,
|
|
||||||
sortKey,
|
|
||||||
sortDirection,
|
|
||||||
} = useQueueOptions();
|
|
||||||
const customFilters = useSelector(
|
|
||||||
createCustomFiltersSelector('queue')
|
|
||||||
) as CustomFilter[];
|
|
||||||
|
|
||||||
const filters = useMemo(() => {
|
const filters = useMemo(() => {
|
||||||
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
||||||
@@ -80,9 +89,6 @@ const useQueue = () => {
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
filters,
|
filters,
|
||||||
queryParams: {
|
|
||||||
includeUnknownSeriesItems,
|
|
||||||
},
|
|
||||||
sortKey,
|
sortKey,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
queryOptions: {
|
queryOptions: {
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import Alert from 'Components/Alert';
|
import Alert from 'Components/Alert';
|
||||||
import TextInput from 'Components/Form/TextInput';
|
import TextInput from 'Components/Form/TextInput';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
@@ -12,6 +10,7 @@ import PageContentBody from 'Components/Page/PageContentBody';
|
|||||||
import useDebounce from 'Helpers/Hooks/useDebounce';
|
import useDebounce from 'Helpers/Hooks/useDebounce';
|
||||||
import useQueryParams from 'Helpers/Hooks/useQueryParams';
|
import useQueryParams from 'Helpers/Hooks/useQueryParams';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
|
import { useHasSeries } from 'Series/useSeries';
|
||||||
import { InputChanged } from 'typings/inputs';
|
import { InputChanged } from 'typings/inputs';
|
||||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
@@ -21,11 +20,7 @@ import styles from './AddNewSeries.css';
|
|||||||
|
|
||||||
function AddNewSeries() {
|
function AddNewSeries() {
|
||||||
const { term: initialTerm = '' } = useQueryParams<{ term: string }>();
|
const { term: initialTerm = '' } = useQueryParams<{ term: string }>();
|
||||||
|
const hasSeries = useHasSeries();
|
||||||
const seriesCount = useSelector(
|
|
||||||
(state: AppState) => state.series.items.length
|
|
||||||
);
|
|
||||||
|
|
||||||
const [term, setTerm] = useState(initialTerm);
|
const [term, setTerm] = useState(initialTerm);
|
||||||
const [isFetching, setIsFetching] = useState(false);
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
const query = useDebounce(term, term ? 300 : 0);
|
const query = useDebounce(term, term ? 300 : 0);
|
||||||
@@ -43,11 +38,7 @@ function AddNewSeries() {
|
|||||||
setIsFetching(false);
|
setIsFetching(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const { isFetching: isFetchingApi, error, data } = useLookupSeries(query);
|
||||||
isFetching: isFetchingApi,
|
|
||||||
error,
|
|
||||||
data = [],
|
|
||||||
} = useLookupSeries(query);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsFetching(isFetchingApi);
|
setIsFetching(isFetchingApi);
|
||||||
@@ -127,7 +118,7 @@ function AddNewSeries() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!term && !seriesCount ? (
|
{!term && !hasSeries ? (
|
||||||
<div className={styles.message}>
|
<div className={styles.message}>
|
||||||
<div className={styles.noSeriesText}>
|
<div className={styles.noSeriesText}>
|
||||||
{translate('NoSeriesHaveBeenAdded')}
|
{translate('NoSeriesHaveBeenAdded')}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import AddSeries from 'AddSeries/AddSeries';
|
import AddSeries from 'AddSeries/AddSeries';
|
||||||
import {
|
import {
|
||||||
AddSeriesOptions,
|
AddSeriesOptions,
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
} from 'AddSeries/addSeriesOptionsStore';
|
} from 'AddSeries/addSeriesOptionsStore';
|
||||||
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
||||||
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
||||||
|
import { useAppDimension } from 'App/appStore';
|
||||||
import CheckInput from 'Components/Form/CheckInput';
|
import CheckInput from 'Components/Form/CheckInput';
|
||||||
import Form from 'Components/Form/Form';
|
import Form from 'Components/Form/Form';
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
@@ -20,12 +20,12 @@ import ModalContent from 'Components/Modal/ModalContent';
|
|||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
import Popover from 'Components/Tooltip/Popover';
|
import Popover from 'Components/Tooltip/Popover';
|
||||||
|
import { getValidationFailures } from 'Helpers/Hooks/useApiMutation';
|
||||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||||
import { SeriesType } from 'Series/Series';
|
import { SeriesType } from 'Series/Series';
|
||||||
import SeriesPoster from 'Series/SeriesPoster';
|
import SeriesPoster from 'Series/SeriesPoster';
|
||||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
|
||||||
import selectSettings from 'Store/Selectors/selectSettings';
|
import selectSettings from 'Store/Selectors/selectSettings';
|
||||||
import useIsWindows from 'System/useIsWindows';
|
import { useIsWindows } from 'System/Status/useSystemStatus';
|
||||||
import { InputChanged } from 'typings/inputs';
|
import { InputChanged } from 'typings/inputs';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import { useAddSeries } from './useAddSeries';
|
import { useAddSeries } from './useAddSeries';
|
||||||
@@ -44,13 +44,16 @@ function AddNewSeriesModalContent({
|
|||||||
}: AddNewSeriesModalContentProps) {
|
}: AddNewSeriesModalContentProps) {
|
||||||
const { title, year, overview, images, folder } = series;
|
const { title, year, overview, images, folder } = series;
|
||||||
const options = useAddSeriesOptions();
|
const options = useAddSeriesOptions();
|
||||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
const isSmallScreen = useAppDimension('isSmallScreen');
|
||||||
const isWindows = useIsWindows();
|
const isWindows = useIsWindows();
|
||||||
|
|
||||||
const { isAdding, addError, addSeries } = useAddSeries();
|
const { isAdding, addError, addSeries } = useAddSeries();
|
||||||
|
|
||||||
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
||||||
return selectSettings(options, {}, addError);
|
return {
|
||||||
|
...selectSettings(options, {}),
|
||||||
|
...getValidationFailures(addError),
|
||||||
|
};
|
||||||
}, [options, addError]);
|
}, [options, addError]);
|
||||||
|
|
||||||
const [seriesType, setSeriesType] = useState<SeriesType>(
|
const [seriesType, setSeriesType] = useState<SeriesType>(
|
||||||
@@ -88,12 +91,14 @@ function AddNewSeriesModalContent({
|
|||||||
addSeries({
|
addSeries({
|
||||||
...series,
|
...series,
|
||||||
rootFolderPath: rootFolderPath.value,
|
rootFolderPath: rootFolderPath.value,
|
||||||
monitor: monitor.value,
|
addOptions: {
|
||||||
|
monitor: monitor.value,
|
||||||
|
searchForMissingEpisodes: searchForMissingEpisodes.value,
|
||||||
|
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
|
||||||
|
},
|
||||||
qualityProfileId: qualityProfileId.value,
|
qualityProfileId: qualityProfileId.value,
|
||||||
seriesType,
|
seriesType,
|
||||||
seasonFolder: seasonFolder.value,
|
seasonFolder: seasonFolder.value,
|
||||||
searchForMissingEpisodes: searchForMissingEpisodes.value,
|
|
||||||
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
|
|
||||||
tags: tags.value,
|
tags: tags.value,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
@@ -131,6 +136,7 @@ function AddNewSeriesModalContent({
|
|||||||
className={styles.poster}
|
className={styles.poster}
|
||||||
images={images}
|
images={images}
|
||||||
size={250}
|
size={250}
|
||||||
|
title={title}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -97,6 +97,12 @@
|
|||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.excludedIcon {
|
||||||
|
margin-left: 10px;
|
||||||
|
color: var(--dangerColor);
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
.overview {
|
.overview {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
interface CssExports {
|
interface CssExports {
|
||||||
'alreadyExistsIcon': string;
|
'alreadyExistsIcon': string;
|
||||||
'content': string;
|
'content': string;
|
||||||
|
'excludedIcon': string;
|
||||||
'genres': string;
|
'genres': string;
|
||||||
'icons': string;
|
'icons': string;
|
||||||
'network': string;
|
'network': string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import AddSeries from 'AddSeries/AddSeries';
|
import AddSeries from 'AddSeries/AddSeries';
|
||||||
|
import { useAppDimension } from 'App/appStore';
|
||||||
import HeartRating from 'Components/HeartRating';
|
import HeartRating from 'Components/HeartRating';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
import Label from 'Components/Label';
|
import Label from 'Components/Label';
|
||||||
@@ -10,8 +10,7 @@ import { icons, kinds, sizes } from 'Helpers/Props';
|
|||||||
import { Statistics } from 'Series/Series';
|
import { Statistics } from 'Series/Series';
|
||||||
import SeriesGenres from 'Series/SeriesGenres';
|
import SeriesGenres from 'Series/SeriesGenres';
|
||||||
import SeriesPoster from 'Series/SeriesPoster';
|
import SeriesPoster from 'Series/SeriesPoster';
|
||||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
import useExistingSeries from 'Series/useExistingSeries';
|
||||||
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import AddNewSeriesModal from './AddNewSeriesModal';
|
import AddNewSeriesModal from './AddNewSeriesModal';
|
||||||
import styles from './AddNewSeriesSearchResult.css';
|
import styles from './AddNewSeriesSearchResult.css';
|
||||||
@@ -35,10 +34,11 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
|
|||||||
overview,
|
overview,
|
||||||
seriesType,
|
seriesType,
|
||||||
images,
|
images,
|
||||||
|
isExcluded,
|
||||||
} = series;
|
} = series;
|
||||||
|
|
||||||
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
|
const isExistingSeries = useExistingSeries(tvdbId);
|
||||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
const isSmallScreen = useAppDimension('isSmallScreen');
|
||||||
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
|
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
|
||||||
|
|
||||||
const seasonCount = statistics.seasonCount;
|
const seasonCount = statistics.seasonCount;
|
||||||
@@ -75,6 +75,7 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
|
|||||||
size={250}
|
size={250}
|
||||||
overflow={true}
|
overflow={true}
|
||||||
lazy={false}
|
lazy={false}
|
||||||
|
title={title}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -100,6 +101,15 @@ function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{isExcluded ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.excludedIcon}
|
||||||
|
name={icons.DANGER}
|
||||||
|
size={36}
|
||||||
|
title={translate('SeriesInImportListExclusions')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
className={styles.tvdbLink}
|
className={styles.tvdbLink}
|
||||||
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
||||||
|
|||||||
@@ -1,44 +1,55 @@
|
|||||||
import { useCallback } from 'react';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
import AddSeries from 'AddSeries/AddSeries';
|
import AddSeries from 'AddSeries/AddSeries';
|
||||||
import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
||||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||||
import Series from 'Series/Series';
|
import Series from 'Series/Series';
|
||||||
import { updateItem } from 'Store/Actions/baseActions';
|
|
||||||
|
|
||||||
type AddSeriesPayload = AddSeries & AddSeriesOptions;
|
interface AddSeriesPayload
|
||||||
|
extends AddSeries,
|
||||||
|
Omit<
|
||||||
|
AddSeriesOptions,
|
||||||
|
'monitor' | 'searchForMissingEpisodes' | 'searchForCutoffUnmetEpisodes'
|
||||||
|
> {}
|
||||||
|
|
||||||
export const useLookupSeries = (query: string) => {
|
const DEFAULT_SERIES: AddSeries[] = [];
|
||||||
return useApiQuery<AddSeries[]>({
|
|
||||||
|
export const useLookupSeries = (query: string, isEnabled = true) => {
|
||||||
|
const result = useApiQuery<AddSeries[]>({
|
||||||
path: '/series/lookup',
|
path: '/series/lookup',
|
||||||
queryParams: {
|
queryParams: {
|
||||||
term: query,
|
term: query,
|
||||||
},
|
},
|
||||||
queryOptions: {
|
queryOptions: {
|
||||||
enabled: !!query,
|
enabled: isEnabled && !!query,
|
||||||
// Disable refetch on window focus to prevent refetching when the user switch tabs
|
// Disable refetch on window focus to prevent refetching when the user switch tabs
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data ?? DEFAULT_SERIES,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAddSeries = () => {
|
export const useAddSeries = () => {
|
||||||
const dispatch = useDispatch();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const onAddSuccess = useCallback(
|
|
||||||
(data: Series) => {
|
|
||||||
dispatch(updateItem({ section: 'series', ...data }));
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
|
const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
|
||||||
{
|
{
|
||||||
path: '/series',
|
path: '/series',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
mutationOptions: {
|
mutationOptions: {
|
||||||
onSuccess: onAddSuccess,
|
onSuccess: (newSeries) => {
|
||||||
|
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
|
||||||
|
if (!oldSeries) {
|
||||||
|
return [newSeries];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...oldSeries, newSeries];
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Series from 'Series/Series';
|
|||||||
|
|
||||||
interface AddSeries extends Series {
|
interface AddSeries extends Series {
|
||||||
folder: string;
|
folder: string;
|
||||||
|
isExcluded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AddSeries;
|
export default AddSeries;
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
import React, { useEffect, useMemo, useRef } from 'react';
|
import React, { useEffect, useMemo, useRef } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import {
|
import {
|
||||||
setAddSeriesOption,
|
setAddSeriesOption,
|
||||||
useAddSeriesOption,
|
useAddSeriesOption,
|
||||||
} from 'AddSeries/addSeriesOptionsStore';
|
} from 'AddSeries/addSeriesOptionsStore';
|
||||||
import { SelectProvider } from 'App/SelectContext';
|
import { SelectProvider } from 'App/Select/SelectContext';
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import Alert from 'Components/Alert';
|
import Alert from 'Components/Alert';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
import PageContent from 'Components/Page/PageContent';
|
import PageContent from 'Components/Page/PageContent';
|
||||||
import PageContentBody from 'Components/Page/PageContentBody';
|
import PageContentBody from 'Components/Page/PageContentBody';
|
||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
import { clearImportSeries } from 'Store/Actions/importSeriesActions';
|
import useRootFolders, { useRootFolder } from 'RootFolder/useRootFolders';
|
||||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
import { useQualityProfilesData } from 'Settings/Profiles/Quality/useQualityProfiles';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import ImportSeriesFooter from './ImportSeriesFooter';
|
import ImportSeriesFooter from './ImportSeriesFooter';
|
||||||
|
import { clearImportSeries } from './importSeriesStore';
|
||||||
import ImportSeriesTable from './ImportSeriesTable';
|
import ImportSeriesTable from './ImportSeriesTable';
|
||||||
|
|
||||||
function ImportSeries() {
|
function ImportSeries() {
|
||||||
const dispatch = useDispatch();
|
|
||||||
const { rootFolderId: rootFolderIdString } = useParams<{
|
const { rootFolderId: rootFolderIdString } = useParams<{
|
||||||
rootFolderId: string;
|
rootFolderId: string;
|
||||||
}>();
|
}>();
|
||||||
@@ -27,10 +25,12 @@ function ImportSeries() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
isFetching: rootFoldersFetching,
|
isFetching: rootFoldersFetching,
|
||||||
isPopulated: rootFoldersPopulated,
|
isFetched: rootFoldersFetched,
|
||||||
error: rootFoldersError,
|
error: rootFoldersError,
|
||||||
items: rootFolders,
|
data: rootFolders,
|
||||||
} = useSelector((state: AppState) => state.rootFolders);
|
} = useRootFolders();
|
||||||
|
|
||||||
|
useRootFolder(rootFolderId, false);
|
||||||
|
|
||||||
const { path, unmappedFolders } = useMemo(() => {
|
const { path, unmappedFolders } = useMemo(() => {
|
||||||
const rootFolder = rootFolders.find((r) => r.id === rootFolderId);
|
const rootFolder = rootFolders.find((r) => r.id === rootFolderId);
|
||||||
@@ -47,9 +47,7 @@ function ImportSeries() {
|
|||||||
};
|
};
|
||||||
}, [rootFolders, rootFolderId]);
|
}, [rootFolders, rootFolderId]);
|
||||||
|
|
||||||
const qualityProfiles = useSelector(
|
const qualityProfiles = useQualityProfilesData();
|
||||||
(state: AppState) => state.settings.qualityProfiles.items
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
|
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
|
||||||
|
|
||||||
@@ -65,12 +63,10 @@ function ImportSeries() {
|
|||||||
}, [unmappedFolders]);
|
}, [unmappedFolders]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchRootFolders({ id: rootFolderId, timeout: false }));
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
dispatch(clearImportSeries());
|
clearImportSeries();
|
||||||
};
|
};
|
||||||
}, [rootFolderId, dispatch]);
|
}, [rootFolderId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -79,13 +75,15 @@ function ImportSeries() {
|
|||||||
) {
|
) {
|
||||||
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
|
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
|
||||||
}
|
}
|
||||||
}, [defaultQualityProfileId, qualityProfiles, dispatch]);
|
}, [defaultQualityProfileId, qualityProfiles]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectProvider items={items}>
|
<SelectProvider items={items}>
|
||||||
<PageContent title={translate('ImportSeries')}>
|
<PageContent title={translate('ImportSeries')}>
|
||||||
<PageContentBody ref={scrollerRef}>
|
<PageContentBody ref={scrollerRef}>
|
||||||
{rootFoldersFetching ? <LoadingIndicator /> : null}
|
{rootFoldersFetching && !rootFoldersFetched ? (
|
||||||
|
<LoadingIndicator />
|
||||||
|
) : null}
|
||||||
|
|
||||||
{!rootFoldersFetching && !!rootFoldersError ? (
|
{!rootFoldersFetching && !!rootFoldersError ? (
|
||||||
<Alert kind={kinds.DANGER}>
|
<Alert kind={kinds.DANGER}>
|
||||||
@@ -95,7 +93,7 @@ function ImportSeries() {
|
|||||||
|
|
||||||
{!rootFoldersError &&
|
{!rootFoldersError &&
|
||||||
!rootFoldersFetching &&
|
!rootFoldersFetching &&
|
||||||
rootFoldersPopulated &&
|
rootFoldersFetched &&
|
||||||
!unmappedFolders.length ? (
|
!unmappedFolders.length ? (
|
||||||
<Alert kind={kinds.INFO}>
|
<Alert kind={kinds.INFO}>
|
||||||
{translate('AllSeriesInRootFolderHaveBeenImported', { path })}
|
{translate('AllSeriesInRootFolderHaveBeenImported', { path })}
|
||||||
@@ -103,20 +101,14 @@ function ImportSeries() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!rootFoldersError &&
|
{!rootFoldersError &&
|
||||||
!rootFoldersFetching &&
|
rootFoldersFetched &&
|
||||||
rootFoldersPopulated &&
|
|
||||||
!!unmappedFolders.length &&
|
!!unmappedFolders.length &&
|
||||||
scrollerRef.current ? (
|
scrollerRef.current ? (
|
||||||
<ImportSeriesTable
|
<ImportSeriesTable items={items} scrollerRef={scrollerRef} />
|
||||||
unmappedFolders={unmappedFolders}
|
|
||||||
scrollerRef={scrollerRef}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
</PageContentBody>
|
</PageContentBody>
|
||||||
|
|
||||||
{!rootFoldersError &&
|
{!rootFoldersError && rootFoldersFetched && !!unmappedFolders.length ? (
|
||||||
!rootFoldersFetching &&
|
|
||||||
!!unmappedFolders.length ? (
|
|
||||||
<ImportSeriesFooter />
|
<ImportSeriesFooter />
|
||||||
) : null}
|
) : null}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import {
|
import {
|
||||||
AddSeriesOptions,
|
AddSeriesOptions,
|
||||||
setAddSeriesOption,
|
setAddSeriesOption,
|
||||||
useAddSeriesOptions,
|
useAddSeriesOptions,
|
||||||
} from 'AddSeries/addSeriesOptionsStore';
|
} from 'AddSeries/addSeriesOptionsStore';
|
||||||
import { useSelect } from 'App/SelectContext';
|
import { useSelect } from 'App/Select/SelectContext';
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import CheckInput from 'Components/Form/CheckInput';
|
import CheckInput from 'Components/Form/CheckInput';
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
@@ -17,21 +15,22 @@ import PageContentFooter from 'Components/Page/PageContentFooter';
|
|||||||
import Popover from 'Components/Tooltip/Popover';
|
import Popover from 'Components/Tooltip/Popover';
|
||||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||||
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||||
import {
|
|
||||||
cancelLookupSeries,
|
|
||||||
importSeries,
|
|
||||||
lookupUnsearchedSeries,
|
|
||||||
setImportSeriesValue,
|
|
||||||
} from 'Store/Actions/importSeriesActions';
|
|
||||||
import { InputChanged } from 'typings/inputs';
|
import { InputChanged } from 'typings/inputs';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
import {
|
||||||
|
ImportSeriesItem,
|
||||||
|
startProcessing,
|
||||||
|
stopProcessing,
|
||||||
|
updateImportSeriesItem,
|
||||||
|
useImportSeriesItems,
|
||||||
|
useLookupQueueHasItems,
|
||||||
|
} from './importSeriesStore';
|
||||||
|
import { useImportSeries } from './useImportSeries';
|
||||||
import styles from './ImportSeriesFooter.css';
|
import styles from './ImportSeriesFooter.css';
|
||||||
|
|
||||||
type MixedType = 'mixed';
|
type MixedType = 'mixed';
|
||||||
|
|
||||||
function ImportSeriesFooter() {
|
function ImportSeriesFooter() {
|
||||||
const dispatch = useDispatch();
|
|
||||||
const {
|
const {
|
||||||
monitor: defaultMonitor,
|
monitor: defaultMonitor,
|
||||||
qualityProfileId: defaultQualityProfileId,
|
qualityProfileId: defaultQualityProfileId,
|
||||||
@@ -39,9 +38,8 @@ function ImportSeriesFooter() {
|
|||||||
seasonFolder: defaultSeasonFolder,
|
seasonFolder: defaultSeasonFolder,
|
||||||
} = useAddSeriesOptions();
|
} = useAddSeriesOptions();
|
||||||
|
|
||||||
const { isLookingUpSeries, isImporting, items, importError } = useSelector(
|
const items = useImportSeriesItems();
|
||||||
(state: AppState) => state.importSeries
|
const isLookingUpSeries = useLookupQueueHasItems();
|
||||||
);
|
|
||||||
|
|
||||||
const [monitor, setMonitor] = useState<SeriesMonitor | MixedType>(
|
const [monitor, setMonitor] = useState<SeriesMonitor | MixedType>(
|
||||||
defaultMonitor
|
defaultMonitor
|
||||||
@@ -56,11 +54,9 @@ function ImportSeriesFooter() {
|
|||||||
defaultSeasonFolder
|
defaultSeasonFolder
|
||||||
);
|
);
|
||||||
|
|
||||||
const [selectState] = useSelect();
|
const { selectedCount, getSelectedIds } = useSelect<ImportSeriesItem>();
|
||||||
|
|
||||||
const selectedIds = useMemo(() => {
|
const { importSeries, isImporting, importError } = useImportSeries();
|
||||||
return getSelectedIds(selectState.selectedState, (id) => id);
|
|
||||||
}, [selectState.selectedState]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
hasUnsearchedItems,
|
hasUnsearchedItems,
|
||||||
@@ -92,7 +88,7 @@ function ImportSeriesFooter() {
|
|||||||
isSeasonFolderMixed = true;
|
isSeasonFolderMixed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.isPopulated) {
|
if (!item.hasSearched) {
|
||||||
hasUnsearchedItems = true;
|
hasUnsearchedItems = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -127,30 +123,27 @@ function ImportSeriesFooter() {
|
|||||||
|
|
||||||
setAddSeriesOption(name as keyof AddSeriesOptions, value);
|
setAddSeriesOption(name as keyof AddSeriesOptions, value);
|
||||||
|
|
||||||
selectedIds.forEach((id) => {
|
getSelectedIds().forEach((id) => {
|
||||||
dispatch(
|
updateImportSeriesItem({
|
||||||
// @ts-expect-error - actions are not typed
|
id,
|
||||||
setImportSeriesValue({
|
[name]: value,
|
||||||
id,
|
});
|
||||||
[name]: value,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[selectedIds, dispatch]
|
[getSelectedIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLookupPress = useCallback(() => {
|
const handleLookupPress = useCallback(() => {
|
||||||
dispatch(lookupUnsearchedSeries());
|
startProcessing();
|
||||||
}, [dispatch]);
|
}, []);
|
||||||
|
|
||||||
const handleCancelLookupPress = useCallback(() => {
|
const handleCancelLookupPress = useCallback(() => {
|
||||||
dispatch(cancelLookupSeries());
|
stopProcessing();
|
||||||
}, [dispatch]);
|
}, []);
|
||||||
|
|
||||||
const handleImportPress = useCallback(() => {
|
const handleImportPress = useCallback(() => {
|
||||||
dispatch(importSeries({ ids: selectedIds }));
|
importSeries(getSelectedIds());
|
||||||
}, [selectedIds, dispatch]);
|
}, [importSeries, getSelectedIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMonitorMixed && monitor !== 'mixed') {
|
if (isMonitorMixed && monitor !== 'mixed') {
|
||||||
@@ -187,8 +180,6 @@ function ImportSeriesFooter() {
|
|||||||
}
|
}
|
||||||
}, [defaultSeasonFolder, isSeasonFolderMixed, seasonFolder]);
|
}, [defaultSeasonFolder, isSeasonFolderMixed, seasonFolder]);
|
||||||
|
|
||||||
const selectedCount = selectedIds.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentFooter>
|
<PageContentFooter>
|
||||||
<div className={styles.inputContainer}>
|
<div className={styles.inputContainer}>
|
||||||
@@ -293,12 +284,12 @@ function ImportSeriesFooter() {
|
|||||||
title={translate('ImportErrors')}
|
title={translate('ImportErrors')}
|
||||||
body={
|
body={
|
||||||
<ul>
|
<ul>
|
||||||
{Array.isArray(importError.responseJSON) ? (
|
{Array.isArray(importError.statusBody) ? (
|
||||||
importError.responseJSON.map((error, index) => {
|
importError.statusBody.map((error, index) => {
|
||||||
return <li key={index}>{error.errorMessage}</li>;
|
return <li key={index}>{error.errorMessage}</li>;
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<li>{JSON.stringify(importError.responseJSON)}</li>
|
<li>{JSON.stringify(importError.statusBody)}</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,29 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useSelect } from 'App/Select/SelectContext';
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { useSelect } from 'App/SelectContext';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import { ImportSeries } from 'App/State/ImportSeriesAppState';
|
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
|
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
|
||||||
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
|
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
|
||||||
import { inputTypes } from 'Helpers/Props';
|
import { inputTypes } from 'Helpers/Props';
|
||||||
import { setImportSeriesValue } from 'Store/Actions/importSeriesActions';
|
import useExistingSeries from 'Series/useExistingSeries';
|
||||||
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
|
||||||
import { InputChanged } from 'typings/inputs';
|
import { InputChanged } from 'typings/inputs';
|
||||||
import { SelectStateInputProps } from 'typings/props';
|
import { SelectStateInputProps } from 'typings/props';
|
||||||
|
import {
|
||||||
|
ImportSeriesItem,
|
||||||
|
UnamppedFolderItem,
|
||||||
|
updateImportSeriesItem,
|
||||||
|
useImportSeriesItem,
|
||||||
|
} from './importSeriesStore';
|
||||||
import ImportSeriesSelectSeries from './SelectSeries/ImportSeriesSelectSeries';
|
import ImportSeriesSelectSeries from './SelectSeries/ImportSeriesSelectSeries';
|
||||||
import styles from './ImportSeriesRow.css';
|
import styles from './ImportSeriesRow.css';
|
||||||
|
|
||||||
function createItemSelector(id: string) {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.importSeries.items,
|
|
||||||
(items) => {
|
|
||||||
return (
|
|
||||||
items.find((item) => {
|
|
||||||
return item.id === id;
|
|
||||||
}) || ({} as ImportSeries)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportSeriesRowProps {
|
interface ImportSeriesRowProps {
|
||||||
id: string;
|
unmappedFolder: UnamppedFolderItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImportSeriesRow({ id }: ImportSeriesRowProps) {
|
function ImportSeriesRow({ unmappedFolder }: ImportSeriesRowProps) {
|
||||||
const dispatch = useDispatch();
|
const id = unmappedFolder.id;
|
||||||
|
|
||||||
|
const item = useImportSeriesItem(unmappedFolder.id);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
relativePath,
|
relativePath,
|
||||||
@@ -42,45 +32,45 @@ function ImportSeriesRow({ id }: ImportSeriesRowProps) {
|
|||||||
seasonFolder,
|
seasonFolder,
|
||||||
seriesType,
|
seriesType,
|
||||||
selectedSeries,
|
selectedSeries,
|
||||||
} = useSelector(createItemSelector(id));
|
} = item ?? {};
|
||||||
|
|
||||||
const isExistingSeries = useSelector(
|
const isExistingSeries = useExistingSeries(selectedSeries?.tvdbId);
|
||||||
createExistingSeriesSelector(selectedSeries?.tvdbId)
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectState, selectDispatch] = useSelect();
|
const { getIsSelected, toggleSelected, toggleDisabled } =
|
||||||
|
useSelect<ImportSeriesItem>();
|
||||||
|
|
||||||
const handleInputChange = useCallback(
|
const handleInputChange = useCallback(
|
||||||
({ name, value }: InputChanged) => {
|
({ name, value }: InputChanged) => {
|
||||||
dispatch(
|
updateImportSeriesItem({ id, [name]: value });
|
||||||
// @ts-expect-error - actions are not typed
|
|
||||||
setImportSeriesValue({
|
|
||||||
id,
|
|
||||||
[name]: value,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[id, dispatch]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectedChange = useCallback(
|
const handleSelectedChange = useCallback(
|
||||||
({ id, value, shiftKey }: SelectStateInputProps) => {
|
({ id, value, shiftKey }: SelectStateInputProps<string>) => {
|
||||||
selectDispatch({
|
toggleSelected({
|
||||||
type: 'toggleSelected',
|
|
||||||
id,
|
id,
|
||||||
isSelected: value,
|
isSelected: value,
|
||||||
shiftKey,
|
shiftKey,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[selectDispatch]
|
[toggleSelected]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
toggleDisabled(id, !selectedSeries || isExistingSeries);
|
||||||
|
}, [id, selectedSeries, isExistingSeries, toggleDisabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
toggleSelected({ id, isSelected: !!selectedSeries, shiftKey: false });
|
||||||
|
}, [id, selectedSeries, toggleSelected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<VirtualTableSelectCell
|
<VirtualTableSelectCell<string>
|
||||||
inputClassName={styles.selectInput}
|
inputClassName={styles.selectInput}
|
||||||
id={id}
|
id={id}
|
||||||
isSelected={selectState.selectedState[id]}
|
isSelected={getIsSelected(id)}
|
||||||
isDisabled={!selectedSeries || isExistingSeries}
|
isDisabled={!selectedSeries || isExistingSeries}
|
||||||
onSelectedChange={handleSelectedChange}
|
onSelectedChange={handleSelectedChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,33 +1,25 @@
|
|||||||
import React, { RefObject, useCallback, useEffect, useRef } from 'react';
|
import React, { RefObject, useCallback, useRef } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import { FixedSizeList, ListChildComponentProps } from 'react-window';
|
import { FixedSizeList, ListChildComponentProps } from 'react-window';
|
||||||
import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
import { useAppDimension } from 'App/appStore';
|
||||||
import { useSelect } from 'App/SelectContext';
|
import { useSelect } from 'App/Select/SelectContext';
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import { ImportSeries } from 'App/State/ImportSeriesAppState';
|
|
||||||
import VirtualTable from 'Components/Table/VirtualTable';
|
import VirtualTable from 'Components/Table/VirtualTable';
|
||||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
|
||||||
import {
|
|
||||||
queueLookupSeries,
|
|
||||||
setImportSeriesValue,
|
|
||||||
} from 'Store/Actions/importSeriesActions';
|
|
||||||
import createAllSeriesSelector from 'Store/Selectors/createAllSeriesSelector';
|
|
||||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
|
||||||
import { CheckInputChanged } from 'typings/inputs';
|
import { CheckInputChanged } from 'typings/inputs';
|
||||||
import { SelectStateInputProps } from 'typings/props';
|
|
||||||
import { UnmappedFolder } from 'typings/RootFolder';
|
|
||||||
import ImportSeriesHeader from './ImportSeriesHeader';
|
import ImportSeriesHeader from './ImportSeriesHeader';
|
||||||
import ImportSeriesRow from './ImportSeriesRow';
|
import ImportSeriesRow from './ImportSeriesRow';
|
||||||
|
import {
|
||||||
|
UnamppedFolderItem,
|
||||||
|
useEnsureImportSeriesItems,
|
||||||
|
} from './importSeriesStore';
|
||||||
import styles from './ImportSeriesTable.css';
|
import styles from './ImportSeriesTable.css';
|
||||||
|
|
||||||
const ROW_HEIGHT = 52;
|
const ROW_HEIGHT = 52;
|
||||||
|
|
||||||
interface RowItemData {
|
interface RowItemData {
|
||||||
items: ImportSeries[];
|
items: UnamppedFolderItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportSeriesTableProps {
|
interface ImportSeriesTableProps {
|
||||||
unmappedFolders: UnmappedFolder[];
|
items: UnamppedFolderItem[];
|
||||||
scrollerRef: RefObject<HTMLElement>;
|
scrollerRef: RefObject<HTMLElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,138 +41,34 @@ function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
|
|||||||
}}
|
}}
|
||||||
className={styles.row}
|
className={styles.row}
|
||||||
>
|
>
|
||||||
<ImportSeriesRow key={item.id} id={item.id} />
|
<ImportSeriesRow key={item.id} unmappedFolder={item} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImportSeriesTable({
|
function ImportSeriesTable({ items, scrollerRef }: ImportSeriesTableProps) {
|
||||||
unmappedFolders,
|
const isSmallScreen = useAppDimension('isSmallScreen');
|
||||||
scrollerRef,
|
const { allSelected, allUnselected, selectAll, unselectAll, useHasItems } =
|
||||||
}: ImportSeriesTableProps) {
|
useSelect();
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const { monitor, qualityProfileId, seriesType, seasonFolder } =
|
|
||||||
useAddSeriesOptions();
|
|
||||||
|
|
||||||
const items = useSelector((state: AppState) => state.importSeries.items);
|
|
||||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
|
||||||
const allSeries = useSelector(createAllSeriesSelector());
|
|
||||||
const [selectState, selectDispatch] = useSelect();
|
|
||||||
|
|
||||||
const defaultValues = useRef({
|
|
||||||
monitor,
|
|
||||||
qualityProfileId,
|
|
||||||
seriesType,
|
|
||||||
seasonFolder,
|
|
||||||
});
|
|
||||||
|
|
||||||
const listRef = useRef<FixedSizeList<RowItemData>>(null);
|
const listRef = useRef<FixedSizeList<RowItemData>>(null);
|
||||||
const initialUnmappedFolders = useRef(unmappedFolders);
|
|
||||||
const previousItems = usePrevious(items);
|
|
||||||
const { allSelected, allUnselected, selectedState } = selectState;
|
|
||||||
|
|
||||||
const handleSelectAllChange = useCallback(
|
const handleSelectAllChange = useCallback(
|
||||||
({ value }: CheckInputChanged) => {
|
({ value }: CheckInputChanged) => {
|
||||||
selectDispatch({
|
if (value) {
|
||||||
type: value ? 'selectAll' : 'unselectAll',
|
selectAll();
|
||||||
});
|
} else {
|
||||||
|
unselectAll();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[selectDispatch]
|
[selectAll, unselectAll]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectedChange = useCallback(
|
const hasSelectItems = useHasItems();
|
||||||
({ id, value, shiftKey }: SelectStateInputProps) => {
|
|
||||||
selectDispatch({
|
|
||||||
type: 'toggleSelected',
|
|
||||||
id,
|
|
||||||
isSelected: value,
|
|
||||||
shiftKey,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[selectDispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRemoveSelectedStateItem = useCallback(
|
useEnsureImportSeriesItems(items);
|
||||||
(id: string) => {
|
|
||||||
selectDispatch({
|
|
||||||
type: 'removeItem',
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[selectDispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (!items.length || !hasSelectItems) {
|
||||||
initialUnmappedFolders.current.forEach(({ name, path, relativePath }) => {
|
|
||||||
dispatch(
|
|
||||||
queueLookupSeries({
|
|
||||||
name,
|
|
||||||
path,
|
|
||||||
relativePath,
|
|
||||||
term: name,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
dispatch(
|
|
||||||
// @ts-expect-error - actions are not typed
|
|
||||||
setImportSeriesValue({
|
|
||||||
id: name,
|
|
||||||
...defaultValues.current,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
previousItems?.forEach((prevItem) => {
|
|
||||||
const { id } = prevItem;
|
|
||||||
|
|
||||||
const item = items.find((i) => i.id === id);
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
handleRemoveSelectedStateItem(id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedSeries = item.selectedSeries;
|
|
||||||
const isSelected = selectedState[id];
|
|
||||||
|
|
||||||
const isExistingSeries =
|
|
||||||
!!selectedSeries &&
|
|
||||||
allSeries.some((s) => s.tvdbId === selectedSeries.tvdbId);
|
|
||||||
|
|
||||||
if (
|
|
||||||
(!selectedSeries && prevItem.selectedSeries) ||
|
|
||||||
(isExistingSeries && !prevItem.selectedSeries)
|
|
||||||
) {
|
|
||||||
handleSelectedChange({ id, value: false, shiftKey: false });
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSelected && (!selectedSeries || isExistingSeries)) {
|
|
||||||
handleSelectedChange({ id, value: false, shiftKey: false });
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedSeries && selectedSeries !== prevItem.selectedSeries) {
|
|
||||||
handleSelectedChange({ id, value: true, shiftKey: false });
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
allSeries,
|
|
||||||
items,
|
|
||||||
previousItems,
|
|
||||||
selectedState,
|
|
||||||
handleRemoveSelectedStateItem,
|
|
||||||
handleSelectedChange,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -1,9 +1,8 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
import Link from 'Components/Link/Link';
|
import Link from 'Components/Link/Link';
|
||||||
import { icons } from 'Helpers/Props';
|
import { icons } from 'Helpers/Props';
|
||||||
import createExistingSeriesSelector from 'Store/Selectors/createExistingSeriesSelector';
|
import useExistingSeries from 'Series/useExistingSeries';
|
||||||
import ImportSeriesTitle from './ImportSeriesTitle';
|
import ImportSeriesTitle from './ImportSeriesTitle';
|
||||||
import styles from './ImportSeriesSearchResult.css';
|
import styles from './ImportSeriesSearchResult.css';
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ function ImportSeriesSearchResult({
|
|||||||
network,
|
network,
|
||||||
onPress,
|
onPress,
|
||||||
}: ImportSeriesSearchResultProps) {
|
}: ImportSeriesSearchResultProps) {
|
||||||
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
|
const isExistingSeries = useExistingSeries(tvdbId);
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
onPress(tvdbId);
|
onPress(tvdbId);
|
||||||
|
|||||||
+56
-67
@@ -7,23 +7,27 @@ import {
|
|||||||
useFloating,
|
useFloating,
|
||||||
useInteractions,
|
useInteractions,
|
||||||
} from '@floating-ui/react';
|
} from '@floating-ui/react';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useLookupSeries } from 'AddSeries/AddNewSeries/useAddSeries';
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import FormInputButton from 'Components/Form/FormInputButton';
|
import FormInputButton from 'Components/Form/FormInputButton';
|
||||||
import TextInput from 'Components/Form/TextInput';
|
import TextInput from 'Components/Form/TextInput';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
import Link from 'Components/Link/Link';
|
import Link from 'Components/Link/Link';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import useDebounce from 'Helpers/Hooks/useDebounce';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import {
|
import useExistingSeries from 'Series/useExistingSeries';
|
||||||
queueLookupSeries,
|
|
||||||
setImportSeriesValue,
|
|
||||||
} from 'Store/Actions/importSeriesActions';
|
|
||||||
import createImportSeriesItemSelector from 'Store/Selectors/createImportSeriesItemSelector';
|
|
||||||
import { InputChanged } from 'typings/inputs';
|
import { InputChanged } from 'typings/inputs';
|
||||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
import {
|
||||||
|
addToLookupQueue,
|
||||||
|
removeFromLookupQueue,
|
||||||
|
updateImportSeriesItem,
|
||||||
|
useImportSeriesItem,
|
||||||
|
useIsCurrentedItemQueued,
|
||||||
|
useIsCurrentLookupQueueItem,
|
||||||
|
} from '../importSeriesStore';
|
||||||
import ImportSeriesSearchResult from './ImportSeriesSearchResult';
|
import ImportSeriesSearchResult from './ImportSeriesSearchResult';
|
||||||
import ImportSeriesTitle from './ImportSeriesTitle';
|
import ImportSeriesTitle from './ImportSeriesTitle';
|
||||||
import styles from './ImportSeriesSelectSeries.css';
|
import styles from './ImportSeriesSelectSeries.css';
|
||||||
@@ -37,29 +41,23 @@ function ImportSeriesSelectSeries({
|
|||||||
id,
|
id,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
}: ImportSeriesSelectSeriesProps) {
|
}: ImportSeriesSelectSeriesProps) {
|
||||||
const dispatch = useDispatch();
|
const importSeriesItem = useImportSeriesItem(id);
|
||||||
const isLookingUpSeries = useSelector(
|
const { selectedSeries, name } = importSeriesItem ?? {};
|
||||||
(state: AppState) => state.importSeries.isLookingUpSeries
|
const isExistingSeries = useExistingSeries(selectedSeries?.tvdbId);
|
||||||
|
|
||||||
|
const [term, setTerm] = useState(name);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const query = useDebounce(term, term ? 300 : 0);
|
||||||
|
const isCurrentLookupQueueItem = useIsCurrentLookupQueueItem(id);
|
||||||
|
const isQueued = useIsCurrentedItemQueued(id);
|
||||||
|
|
||||||
|
const { isFetching, isFetched, error, data, refetch } = useLookupSeries(
|
||||||
|
query,
|
||||||
|
isCurrentLookupQueueItem
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
|
||||||
error,
|
|
||||||
isFetching = true,
|
|
||||||
isPopulated = false,
|
|
||||||
items = [],
|
|
||||||
isQueued = true,
|
|
||||||
selectedSeries,
|
|
||||||
isExistingSeries,
|
|
||||||
term: itemTerm,
|
|
||||||
// @ts-expect-error - ignoring this for now
|
|
||||||
} = useSelector(createImportSeriesItemSelector(id, { id }));
|
|
||||||
|
|
||||||
const seriesLookupTimeout = useRef<ReturnType<typeof setTimeout>>();
|
|
||||||
|
|
||||||
const [term, setTerm] = useState('');
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const errorMessage = getErrorMessage(error);
|
const errorMessage = getErrorMessage(error);
|
||||||
|
const isLookingUpSeries = isFetching || isQueued;
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||||
@@ -67,48 +65,26 @@ function ImportSeriesSelectSeries({
|
|||||||
|
|
||||||
const handleSearchInputChange = useCallback(
|
const handleSearchInputChange = useCallback(
|
||||||
({ value }: InputChanged<string>) => {
|
({ value }: InputChanged<string>) => {
|
||||||
if (seriesLookupTimeout.current) {
|
|
||||||
clearTimeout(seriesLookupTimeout.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTerm(value);
|
setTerm(value);
|
||||||
|
addToLookupQueue(id);
|
||||||
seriesLookupTimeout.current = setTimeout(() => {
|
|
||||||
dispatch(
|
|
||||||
queueLookupSeries({
|
|
||||||
name: id,
|
|
||||||
term: value,
|
|
||||||
topOfQueue: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, 200);
|
|
||||||
},
|
},
|
||||||
[id, dispatch]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRefreshPress = useCallback(() => {
|
const handleRefreshPress = useCallback(() => {
|
||||||
dispatch(
|
refetch();
|
||||||
queueLookupSeries({
|
}, [refetch]);
|
||||||
name: id,
|
|
||||||
term,
|
|
||||||
topOfQueue: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [id, term, dispatch]);
|
|
||||||
|
|
||||||
const handleSeriesSelect = useCallback(
|
const handleSeriesSelect = useCallback(
|
||||||
(tvdbId: number) => {
|
(tvdbId: number) => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
|
||||||
const selectedSeries = items.find((item) => item.tvdbId === tvdbId)!;
|
const selectedSeries = data.find((item) => item.tvdbId === tvdbId)!;
|
||||||
|
|
||||||
dispatch(
|
updateImportSeriesItem({
|
||||||
// @ts-expect-error - actions are not typed
|
id,
|
||||||
setImportSeriesValue({
|
selectedSeries,
|
||||||
id,
|
});
|
||||||
selectedSeries,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selectedSeries.seriesType !== 'standard') {
|
if (selectedSeries.seriesType !== 'standard') {
|
||||||
onInputChange({
|
onInputChange({
|
||||||
@@ -117,12 +93,24 @@ function ImportSeriesSelectSeries({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[id, items, dispatch, onInputChange]
|
[id, data, onInputChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTerm(itemTerm);
|
if (isFetched) {
|
||||||
}, [itemTerm]);
|
updateImportSeriesItem({
|
||||||
|
id,
|
||||||
|
hasSearched: isFetched,
|
||||||
|
selectedSeries: data[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
removeFromLookupQueue(id);
|
||||||
|
}
|
||||||
|
}, [id, isFetched, data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTerm(name);
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
const { refs, context, floatingStyles } = useFloating({
|
const { refs, context, floatingStyles } = useFloating({
|
||||||
middleware: [
|
middleware: [
|
||||||
@@ -149,11 +137,11 @@ function ImportSeriesSelectSeries({
|
|||||||
<>
|
<>
|
||||||
<div ref={refs.setReference} {...getReferenceProps()}>
|
<div ref={refs.setReference} {...getReferenceProps()}>
|
||||||
<Link className={styles.button} component="div" onPress={handlePress}>
|
<Link className={styles.button} component="div" onPress={handlePress}>
|
||||||
{isLookingUpSeries && isQueued && !isPopulated ? (
|
{isLookingUpSeries && isQueued && !isFetched ? (
|
||||||
<LoadingIndicator className={styles.loading} size={20} />
|
<LoadingIndicator className={styles.loading} size={20} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isPopulated && selectedSeries && isExistingSeries ? (
|
{isFetched && selectedSeries && isExistingSeries ? (
|
||||||
<Icon
|
<Icon
|
||||||
className={styles.warningIcon}
|
className={styles.warningIcon}
|
||||||
name={icons.WARNING}
|
name={icons.WARNING}
|
||||||
@@ -161,7 +149,7 @@ function ImportSeriesSelectSeries({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isPopulated && selectedSeries ? (
|
{isFetched && selectedSeries ? (
|
||||||
<ImportSeriesTitle
|
<ImportSeriesTitle
|
||||||
title={selectedSeries.title}
|
title={selectedSeries.title}
|
||||||
year={selectedSeries.year}
|
year={selectedSeries.year}
|
||||||
@@ -170,7 +158,7 @@ function ImportSeriesSelectSeries({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isPopulated && !selectedSeries ? (
|
{isFetched && !selectedSeries ? (
|
||||||
<div>
|
<div>
|
||||||
<Icon
|
<Icon
|
||||||
className={styles.warningIcon}
|
className={styles.warningIcon}
|
||||||
@@ -200,6 +188,7 @@ function ImportSeriesSelectSeries({
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<FloatingPortal id="portal-root">
|
<FloatingPortal id="portal-root">
|
||||||
<div
|
<div
|
||||||
@@ -234,7 +223,7 @@ function ImportSeriesSelectSeries({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.results}>
|
<div className={styles.results}>
|
||||||
{items.map((item) => {
|
{data.map((item) => {
|
||||||
return (
|
return (
|
||||||
<ImportSeriesSearchResult
|
<ImportSeriesSearchResult
|
||||||
key={item.tvdbId}
|
key={item.tvdbId}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
||||||
|
import { UnmappedFolder } from 'RootFolder/useRootFolders';
|
||||||
|
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||||
|
|
||||||
|
export interface UnamppedFolderItem extends UnmappedFolder {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportSeriesItem {
|
||||||
|
id: string;
|
||||||
|
monitor: SeriesMonitor;
|
||||||
|
path: string;
|
||||||
|
qualityProfileId: number;
|
||||||
|
relativePath: string;
|
||||||
|
seasonFolder: boolean;
|
||||||
|
selectedSeries?: Series;
|
||||||
|
seriesType: SeriesType;
|
||||||
|
name: string;
|
||||||
|
hasSearched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportSeriesState {
|
||||||
|
items: Record<string, ImportSeriesItem>;
|
||||||
|
lookupQueue: string[];
|
||||||
|
isProcessing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultState: ImportSeriesState = {
|
||||||
|
items: {},
|
||||||
|
lookupQueue: [],
|
||||||
|
isProcessing: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const importSeriesStore = create<ImportSeriesState>()(() => defaultState);
|
||||||
|
|
||||||
|
export const useEnsureImportSeriesItems = (
|
||||||
|
unmappedFolders: UnamppedFolderItem[]
|
||||||
|
) => {
|
||||||
|
const { monitor, qualityProfileId, seriesType, seasonFolder } =
|
||||||
|
useAddSeriesOptions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
unmappedFolders.forEach((unmappedFolder) => {
|
||||||
|
const existingItem =
|
||||||
|
importSeriesStore.getState().items[unmappedFolder.id];
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItem: ImportSeriesItem = {
|
||||||
|
...unmappedFolder,
|
||||||
|
monitor,
|
||||||
|
qualityProfileId,
|
||||||
|
seriesType,
|
||||||
|
seasonFolder,
|
||||||
|
hasSearched: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
importSeriesStore.setState((state) => ({
|
||||||
|
items: {
|
||||||
|
...state.items,
|
||||||
|
[unmappedFolder.id]: newItem,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}, [unmappedFolders, monitor, qualityProfileId, seriesType, seasonFolder]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateImportSeriesItem = (
|
||||||
|
itemData: Partial<ImportSeriesItem> & Pick<ImportSeriesItem, 'id'>
|
||||||
|
) => {
|
||||||
|
importSeriesStore.setState((state) => {
|
||||||
|
const existingItem = state.items[itemData.id];
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
return {
|
||||||
|
items: {
|
||||||
|
...state.items,
|
||||||
|
[itemData.id]: {
|
||||||
|
...existingItem,
|
||||||
|
...itemData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeImportSeriesItemByPath = (path: string) => {
|
||||||
|
importSeriesStore.setState((state) => {
|
||||||
|
const item = Object.values(state.items).find((i) => i.path === path);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { [item.id]: removed, ...items } = state.items;
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearImportSeries = () => {
|
||||||
|
importSeriesStore.setState(defaultState);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startProcessing = () => {
|
||||||
|
importSeriesStore.setState((state) => {
|
||||||
|
const items = Object.values(state.items).reduce<string[]>((acc, item) => {
|
||||||
|
if (!item.hasSearched) {
|
||||||
|
acc.push(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isProcessing: true, lookupQueue: items };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopProcessing = () => {
|
||||||
|
importSeriesStore.setState({ isProcessing: false, lookupQueue: [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addToLookupQueue = (id: string) => {
|
||||||
|
importSeriesStore.setState((state) => ({
|
||||||
|
lookupQueue: [...state.lookupQueue, id],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeFromLookupQueue = (id: string) => {
|
||||||
|
importSeriesStore.setState((state) => ({
|
||||||
|
lookupQueue: state.lookupQueue.filter((queuedId) => queuedId !== id),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useIsCurrentLookupQueueItem = (id: string) => {
|
||||||
|
return importSeriesStore((state) => state.lookupQueue[0] === id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useIsCurrentedItemQueued = (id: string) => {
|
||||||
|
return importSeriesStore((state) => state.lookupQueue.includes(id));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLookupQueueHasItems = () => {
|
||||||
|
return importSeriesStore((state) => state.lookupQueue.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useImportSeriesItem = (id: string) => {
|
||||||
|
return importSeriesStore((state) => state.items[id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useImportSeriesItems = () => {
|
||||||
|
return importSeriesStore(useShallow((state) => Object.values(state.items)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getImportSeriesItems = (ids: string[]) => {
|
||||||
|
const state = importSeriesStore.getState();
|
||||||
|
|
||||||
|
return ids.reduce<ImportSeriesItem[]>((acc, id) => {
|
||||||
|
const item = state.items[id];
|
||||||
|
|
||||||
|
if (item != null) {
|
||||||
|
acc.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||||
|
import Series from 'Series/Series';
|
||||||
|
import {
|
||||||
|
getImportSeriesItems,
|
||||||
|
removeImportSeriesItemByPath,
|
||||||
|
} from './importSeriesStore';
|
||||||
|
|
||||||
|
export const useImportSeries = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { isPending, error, mutate } = useApiMutation<Series[], Series[]>({
|
||||||
|
path: '/series/import',
|
||||||
|
method: 'POST',
|
||||||
|
mutationOptions: {
|
||||||
|
onSuccess: (data, newSeries) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/rootFolder'] });
|
||||||
|
queryClient.setQueryData<Series[]>(['/series'], (oldSeries) => {
|
||||||
|
if (!oldSeries) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...oldSeries, ...data];
|
||||||
|
});
|
||||||
|
|
||||||
|
newSeries.forEach((series) => {
|
||||||
|
removeImportSeriesItemByPath(series.path);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const importSeries = useCallback(
|
||||||
|
(ids: string[]) => {
|
||||||
|
const items = getImportSeriesItems(ids);
|
||||||
|
const addedIds: string[] = [];
|
||||||
|
|
||||||
|
const allNewSeries = ids.reduce<Series[]>((acc, id) => {
|
||||||
|
const item = items.find((i) => i.id === id);
|
||||||
|
const selectedSeries = item?.selectedSeries;
|
||||||
|
|
||||||
|
// Make sure we have a selected series and the same series hasn't been added yet.
|
||||||
|
if (
|
||||||
|
selectedSeries &&
|
||||||
|
!acc.some((a) => a.tvdbId === selectedSeries.tvdbId)
|
||||||
|
) {
|
||||||
|
const newSeries: Series = {
|
||||||
|
...selectedSeries,
|
||||||
|
monitored: true,
|
||||||
|
monitorNewItems: 'all',
|
||||||
|
qualityProfileId: item.qualityProfileId,
|
||||||
|
path: item.path,
|
||||||
|
seriesType: item.seriesType,
|
||||||
|
seasonFolder: item.seasonFolder,
|
||||||
|
addOptions: {
|
||||||
|
monitor: item.monitor,
|
||||||
|
searchForMissingEpisodes: false,
|
||||||
|
searchForCutoffUnmetEpisodes: false,
|
||||||
|
},
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
newSeries.path = item.path;
|
||||||
|
|
||||||
|
addedIds.push(id);
|
||||||
|
acc.push(newSeries);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (allNewSeries.length > 0) {
|
||||||
|
mutate(allNewSeries);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isImporting: isPending,
|
||||||
|
importError: error,
|
||||||
|
importSeries,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import Alert from 'Components/Alert';
|
import Alert from 'Components/Alert';
|
||||||
import FieldSet from 'Components/FieldSet';
|
import FieldSet from 'Components/FieldSet';
|
||||||
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
||||||
@@ -13,28 +11,24 @@ import PageContentBody from 'Components/Page/PageContentBody';
|
|||||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||||
import RootFolders from 'RootFolder/RootFolders';
|
import RootFolders from 'RootFolder/RootFolders';
|
||||||
import {
|
import useRootFolders, { useAddRootFolder } from 'RootFolder/useRootFolders';
|
||||||
addRootFolder,
|
import { useIsWindows } from 'System/Status/useSystemStatus';
|
||||||
fetchRootFolders,
|
|
||||||
} from 'Store/Actions/rootFolderActions';
|
|
||||||
import useIsWindows from 'System/useIsWindows';
|
|
||||||
import { InputChanged } from 'typings/inputs';
|
import { InputChanged } from 'typings/inputs';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './ImportSeriesSelectFolder.css';
|
import styles from './ImportSeriesSelectFolder.css';
|
||||||
|
|
||||||
function ImportSeriesSelectFolder() {
|
function ImportSeriesSelectFolder() {
|
||||||
const dispatch = useDispatch();
|
const { isFetching, isFetched, error, data } = useRootFolders();
|
||||||
const { isFetching, isPopulated, isSaving, error, saveError, items } =
|
const { addRootFolder, isAdding, addError } = useAddRootFolder();
|
||||||
useSelector((state: AppState) => state.rootFolders);
|
|
||||||
|
|
||||||
const isWindows = useIsWindows();
|
const isWindows = useIsWindows();
|
||||||
|
|
||||||
const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] =
|
const [isAddNewRootFolderModalOpen, setIsAddNewRootFolderModalOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
const wasSaving = usePrevious(isSaving);
|
const wasAdding = usePrevious(isAdding);
|
||||||
|
|
||||||
const hasRootFolders = items.length > 0;
|
const hasRootFolders = data.length > 0;
|
||||||
const goodFolderExample = isWindows ? 'C:\\tv shows' : '/tv shows';
|
const goodFolderExample = isWindows ? 'C:\\tv shows' : '/tv shows';
|
||||||
const badFolderExample = isWindows
|
const badFolderExample = isWindows
|
||||||
? 'C:\\tv shows\\the simpsons'
|
? 'C:\\tv shows\\the simpsons'
|
||||||
@@ -50,18 +44,14 @@ function ImportSeriesSelectFolder() {
|
|||||||
|
|
||||||
const handleNewRootFolderSelect = useCallback(
|
const handleNewRootFolderSelect = useCallback(
|
||||||
({ value }: InputChanged<string>) => {
|
({ value }: InputChanged<string>) => {
|
||||||
dispatch(addRootFolder({ path: value }));
|
addRootFolder({ path: value });
|
||||||
},
|
},
|
||||||
[dispatch]
|
[addRootFolder]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchRootFolders());
|
if (!isAdding && wasAdding && !addError) {
|
||||||
}, [dispatch]);
|
data.reduce((acc, item) => {
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSaving && wasSaving && !saveError) {
|
|
||||||
items.reduce((acc, item) => {
|
|
||||||
if (item.id > acc) {
|
if (item.id > acc) {
|
||||||
return item.id;
|
return item.id;
|
||||||
}
|
}
|
||||||
@@ -69,18 +59,18 @@ function ImportSeriesSelectFolder() {
|
|||||||
return acc;
|
return acc;
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}, [isSaving, wasSaving, saveError, items]);
|
}, [isAdding, wasAdding, addError, data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent title={translate('ImportSeries')}>
|
<PageContent title={translate('ImportSeries')}>
|
||||||
<PageContentBody>
|
<PageContentBody>
|
||||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
{isFetching && !isFetched ? <LoadingIndicator /> : null}
|
||||||
|
|
||||||
{!isFetching && error ? (
|
{!isFetching && error ? (
|
||||||
<Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert>
|
<Alert kind={kinds.DANGER}>{translate('RootFoldersLoadError')}</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!error && isPopulated && (
|
{!error && isFetched && (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
{translate('LibraryImportSeriesHeader')}
|
{translate('LibraryImportSeriesHeader')}
|
||||||
@@ -118,17 +108,17 @@ function ImportSeriesSelectFolder() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isSaving && saveError ? (
|
{!isAdding && addError ? (
|
||||||
<Alert className={styles.addErrorAlert} kind={kinds.DANGER}>
|
<Alert className={styles.addErrorAlert} kind={kinds.DANGER}>
|
||||||
{translate('AddRootFolderError')}
|
{translate('AddRootFolderError')}
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{Array.isArray(saveError.responseJSON) ? (
|
{Array.isArray(addError.statusBody) ? (
|
||||||
saveError.responseJSON.map((e, index) => {
|
addError.statusBody.map((e, index) => {
|
||||||
return <li key={index}>{e.errorMessage}</li>;
|
return <li key={index}>{e.errorMessage}</li>;
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<li>{JSON.stringify(saveError.responseJSON)}</li>
|
<li>{JSON.stringify(addError.statusBody)}</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
|
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import DocumentTitle from 'react-document-title';
|
import DocumentTitle from 'react-document-title';
|
||||||
@@ -7,14 +7,13 @@ import { Store } from 'redux';
|
|||||||
import Page from 'Components/Page/Page';
|
import Page from 'Components/Page/Page';
|
||||||
import ApplyTheme from './ApplyTheme';
|
import ApplyTheme from './ApplyTheme';
|
||||||
import AppRoutes from './AppRoutes';
|
import AppRoutes from './AppRoutes';
|
||||||
|
import { queryClient } from './queryClient';
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
store: Store;
|
store: Store;
|
||||||
history: ConnectedRouterProps['history'];
|
history: ConnectedRouterProps['history'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
|
|
||||||
function App({ store, history }: AppProps) {
|
function App({ store, history }: AppProps) {
|
||||||
return (
|
return (
|
||||||
<DocumentTitle title={window.Sonarr.instanceName}>
|
<DocumentTitle title={window.Sonarr.instanceName}>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import Button from 'Components/Link/Button';
|
import Button from 'Components/Link/Button';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||||
@@ -13,7 +12,7 @@ import UpdateChanges from 'System/Updates/UpdateChanges';
|
|||||||
import useUpdates from 'System/Updates/useUpdates';
|
import useUpdates from 'System/Updates/useUpdates';
|
||||||
import Update from 'typings/Update';
|
import Update from 'typings/Update';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import AppState from './State/AppState';
|
import { useAppValues } from './appStore';
|
||||||
import styles from './AppUpdatedModalContent.css';
|
import styles from './AppUpdatedModalContent.css';
|
||||||
|
|
||||||
function mergeUpdates(items: Update[], version: string, prevVersion?: string) {
|
function mergeUpdates(items: Update[], version: string, prevVersion?: string) {
|
||||||
@@ -63,7 +62,7 @@ interface AppUpdatedModalContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
||||||
const { version, prevVersion } = useSelector((state: AppState) => state.app);
|
const { version, prevVersion } = useAppValues('version', 'prevVersion');
|
||||||
const { isFetched, error, data, refetch } = useUpdates();
|
const { isFetched, error, data, refetch } = useUpdates();
|
||||||
const previousVersion = usePrevious(version);
|
const previousVersion = usePrevious(version);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React, { createContext, PropsWithChildren } from 'react';
|
||||||
|
import useSelectStore, {
|
||||||
|
Id,
|
||||||
|
SelectStoreModel,
|
||||||
|
} from 'App/Select/useSelectStore';
|
||||||
|
|
||||||
|
interface SelectProviderProps<T extends SelectStoreModel<Id>>
|
||||||
|
extends PropsWithChildren {
|
||||||
|
items: Array<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectContext = createContext<
|
||||||
|
ReturnType<typeof useSelectStore> | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
export function SelectProvider<T extends SelectStoreModel<Id>>({
|
||||||
|
items,
|
||||||
|
children,
|
||||||
|
}: SelectProviderProps<T>) {
|
||||||
|
const value = useSelectStore<T>(items);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectContext.Provider value={value}>{children}</SelectContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelect<T extends SelectStoreModel<Id>>() {
|
||||||
|
const context = React.useContext(SelectContext);
|
||||||
|
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useSelect must be used within a SelectProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context as ReturnType<typeof useSelectStore<T>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { create, useStore } from 'zustand';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import getToggledRange from 'Utilities/Table/getToggledRange';
|
||||||
|
|
||||||
|
export type Id = string | number;
|
||||||
|
export type SelectStoreReturnType<T extends SelectStoreModel<Id>> = ReturnType<
|
||||||
|
typeof useSelectStore<T>
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ItemState<T extends SelectStoreModel<Id>> = Map<T['id'], ItemStateValue>;
|
||||||
|
|
||||||
|
interface ItemStateValue {
|
||||||
|
isSelected: boolean;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectStoreModel<TId extends Id> {
|
||||||
|
id: TId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectStore<T extends SelectStoreModel<Id>> {
|
||||||
|
itemState: Map<T['id'], ItemStateValue>;
|
||||||
|
lastToggled: T['id'] | null;
|
||||||
|
items: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemSelectState {
|
||||||
|
allSelected: boolean;
|
||||||
|
allUnselected: boolean;
|
||||||
|
anySelected: boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = <T extends SelectStoreModel<Id>>(
|
||||||
|
items: T[] = []
|
||||||
|
): SelectStore<T> => ({
|
||||||
|
itemState: new Map<T['id'], ItemStateValue>(),
|
||||||
|
lastToggled: null,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleAll<T extends SelectStoreModel<Id>>(
|
||||||
|
itemState: ItemState<T>,
|
||||||
|
isSelected: boolean
|
||||||
|
) {
|
||||||
|
const newItemState = new Map(itemState);
|
||||||
|
|
||||||
|
newItemState.forEach((value, key) => {
|
||||||
|
newItemState.set(key, {
|
||||||
|
isSelected: value.isDisabled ? value.isSelected : isSelected,
|
||||||
|
isDisabled: value.isDisabled,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return newItemState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useSelectStore<T extends SelectStoreModel<Id>>(
|
||||||
|
items: SelectStoreModel<T['id']>[]
|
||||||
|
) {
|
||||||
|
const store = useRef(
|
||||||
|
create<SelectStore<T>>(() => initialState(items as T[]))
|
||||||
|
);
|
||||||
|
|
||||||
|
const [itemSelectState, setItemSelectState] = useState<ItemSelectState>({
|
||||||
|
allSelected: false,
|
||||||
|
allUnselected: true,
|
||||||
|
anySelected: false,
|
||||||
|
selectedCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
store.current.setState(initialState(items as T[]), true);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const selectAll = useCallback(() => {
|
||||||
|
store.current.setState((state) => {
|
||||||
|
const newItemState = toggleAll(state.itemState, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lastToggled: null,
|
||||||
|
itemState: newItemState,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unselectAll = useCallback(() => {
|
||||||
|
store.current.setState((state) => {
|
||||||
|
const newItemState = toggleAll(state.itemState, false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lastToggled: null,
|
||||||
|
itemState: newItemState,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSelected = useCallback(
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
isSelected,
|
||||||
|
shiftKey,
|
||||||
|
}: {
|
||||||
|
id: T['id'];
|
||||||
|
isSelected: boolean | null;
|
||||||
|
shiftKey: boolean;
|
||||||
|
}) => {
|
||||||
|
store.current.setState((state) => {
|
||||||
|
const lastToggled = state.lastToggled;
|
||||||
|
const nextSelectedState = new Map(state.itemState);
|
||||||
|
const currentItemState = nextSelectedState.get(id);
|
||||||
|
|
||||||
|
if (isSelected == null) {
|
||||||
|
nextSelectedState.delete(id);
|
||||||
|
} else if (!currentItemState?.isDisabled) {
|
||||||
|
nextSelectedState.set(id, {
|
||||||
|
isSelected,
|
||||||
|
isDisabled: currentItemState?.isDisabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shiftKey && lastToggled) {
|
||||||
|
const { lower, upper } = getToggledRange(
|
||||||
|
state.items,
|
||||||
|
id,
|
||||||
|
lastToggled
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = lower; i < upper; i++) {
|
||||||
|
if (!nextSelectedState.get(state.items[i].id)?.isDisabled) {
|
||||||
|
nextSelectedState.set(state.items[i].id, {
|
||||||
|
isSelected,
|
||||||
|
isDisabled: currentItemState?.isDisabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
lastToggled: id,
|
||||||
|
itemState: nextSelectedState,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleDisabled = useCallback((id: T['id'], isDisabled: boolean) => {
|
||||||
|
store.current.setState((state) => {
|
||||||
|
const currentItemState = state.itemState.get(id);
|
||||||
|
|
||||||
|
if (currentItemState) {
|
||||||
|
const newItemState = new Map(state.itemState);
|
||||||
|
newItemState.set(id, {
|
||||||
|
...currentItemState,
|
||||||
|
isDisabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemState: newItemState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getSelectedIds = useCallback((): Array<T['id']> => {
|
||||||
|
const iState = store.current.getState().itemState;
|
||||||
|
|
||||||
|
return Array.from(iState.entries()).reduce<T['id'][]>(
|
||||||
|
(acc, [id, value]) => {
|
||||||
|
if (value.isSelected) {
|
||||||
|
acc.push(id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getIsSelected = useCallback((id: T['id']): boolean => {
|
||||||
|
const item = store.current.getState().itemState.get(id);
|
||||||
|
|
||||||
|
return item?.isSelected ?? false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const useIsSelected = (id: T['id']) => {
|
||||||
|
return useStore(
|
||||||
|
store.current,
|
||||||
|
useShallow((state) => {
|
||||||
|
const item = state.itemState.get(id);
|
||||||
|
|
||||||
|
return item?.isSelected ?? false;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSelectedIds = () => {
|
||||||
|
return useStore(
|
||||||
|
store.current,
|
||||||
|
useShallow((state) => {
|
||||||
|
return state.itemState
|
||||||
|
.entries()
|
||||||
|
.reduce<T['id'][]>((acc, [id, value]) => {
|
||||||
|
if (value.isSelected) {
|
||||||
|
acc.push(id);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useHasItems = () => {
|
||||||
|
return useStore(
|
||||||
|
store.current,
|
||||||
|
useShallow((state) => {
|
||||||
|
return state.itemState.size > 0;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = store.current.subscribe((state) => {
|
||||||
|
const itemState = state.itemState;
|
||||||
|
|
||||||
|
const { allSelected, allUnselected, anySelected, selectedCount } =
|
||||||
|
itemState.values().reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
acc.allSelected =
|
||||||
|
acc.allSelected && !!(item.isSelected || item.isDisabled);
|
||||||
|
acc.allUnselected =
|
||||||
|
acc.allUnselected && (!item.isSelected || !!item.isDisabled);
|
||||||
|
acc.anySelected = acc.anySelected || item.isSelected;
|
||||||
|
acc.selectedCount += item.isSelected ? 1 : 0;
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
allSelected:
|
||||||
|
itemState.size > 0 &&
|
||||||
|
itemState.values().some((i) => i.isSelected),
|
||||||
|
allUnselected: true,
|
||||||
|
anySelected: false,
|
||||||
|
selectedCount: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setItemSelectState((s) => {
|
||||||
|
if (
|
||||||
|
s.allSelected === allSelected &&
|
||||||
|
s.allUnselected === allUnselected &&
|
||||||
|
s.anySelected === anySelected &&
|
||||||
|
s.selectedCount === selectedCount
|
||||||
|
) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allSelected,
|
||||||
|
allUnselected,
|
||||||
|
anySelected,
|
||||||
|
selectedCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
store.current.setState((state) => {
|
||||||
|
const nextItemState = items.reduce((acc: ItemState<T>, item) => {
|
||||||
|
const id = item.id;
|
||||||
|
const existingItem = state.itemState.get(id);
|
||||||
|
|
||||||
|
acc.set(
|
||||||
|
id,
|
||||||
|
existingItem ?? {
|
||||||
|
isSelected: false,
|
||||||
|
isDisabled: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, new Map<T['id'], ItemStateValue>());
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemState: nextItemState,
|
||||||
|
lastToggled: null,
|
||||||
|
items: items as T[],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...itemSelectState,
|
||||||
|
getIsSelected,
|
||||||
|
getSelectedIds,
|
||||||
|
reset,
|
||||||
|
selectAll,
|
||||||
|
toggleDisabled,
|
||||||
|
toggleSelected,
|
||||||
|
unselectAll,
|
||||||
|
useHasItems,
|
||||||
|
useIsSelected,
|
||||||
|
useSelectedIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { cloneDeep } from 'lodash';
|
|
||||||
import React, { useCallback, useEffect } from 'react';
|
|
||||||
import useSelectState, {
|
|
||||||
SelectState,
|
|
||||||
SelectStateModel,
|
|
||||||
} from 'Helpers/Hooks/useSelectState';
|
|
||||||
import ModelBase from './ModelBase';
|
|
||||||
|
|
||||||
export type SelectContextAction =
|
|
||||||
| { type: 'reset' }
|
|
||||||
| { type: 'selectAll' }
|
|
||||||
| { type: 'unselectAll' }
|
|
||||||
| {
|
|
||||||
type: 'toggleSelected';
|
|
||||||
id: number | string;
|
|
||||||
isSelected: boolean | null;
|
|
||||||
shiftKey: boolean;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'removeItem';
|
|
||||||
id: number | string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'updateItems';
|
|
||||||
items: ModelBase[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SelectDispatch = (action: SelectContextAction) => void;
|
|
||||||
|
|
||||||
interface SelectProviderOptions<T extends SelectStateModel> {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
children: any;
|
|
||||||
items: Array<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SelectContext = React.createContext<
|
|
||||||
[SelectState, SelectDispatch] | undefined
|
|
||||||
>(cloneDeep(undefined));
|
|
||||||
|
|
||||||
export function SelectProvider<T extends SelectStateModel>(
|
|
||||||
props: SelectProviderOptions<T>
|
|
||||||
) {
|
|
||||||
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 (
|
|
||||||
<SelectContext.Provider value={value}>
|
|
||||||
{props.children}
|
|
||||||
</SelectContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSelect() {
|
|
||||||
const context = React.useContext(SelectContext);
|
|
||||||
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useSelect must be used within a SelectProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import Column from 'Components/Table/Column';
|
import Column from 'Components/Table/Column';
|
||||||
|
import { Filter, FilterBuilderProp } from 'Filters/Filter';
|
||||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||||
import { ValidationFailure } from 'typings/pending';
|
import { ValidationFailure } from 'typings/pending';
|
||||||
import { Filter, FilterBuilderProp } from './AppState';
|
|
||||||
|
|
||||||
export interface Error {
|
export interface Error {
|
||||||
status?: number;
|
status?: number;
|
||||||
|
|||||||
@@ -1,111 +1,9 @@
|
|||||||
import ModelBase from 'App/ModelBase';
|
|
||||||
import { FilterBuilderTypes } from 'Helpers/Props/filterBuilderTypes';
|
|
||||||
import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes';
|
|
||||||
import { Error } from './AppSectionState';
|
|
||||||
import BlocklistAppState from './BlocklistAppState';
|
|
||||||
import CalendarAppState from './CalendarAppState';
|
|
||||||
import CaptchaAppState from './CaptchaAppState';
|
import CaptchaAppState from './CaptchaAppState';
|
||||||
import CommandAppState from './CommandAppState';
|
|
||||||
import CustomFiltersAppState from './CustomFiltersAppState';
|
|
||||||
import EpisodeFilesAppState from './EpisodeFilesAppState';
|
|
||||||
import EpisodesAppState from './EpisodesAppState';
|
|
||||||
import HistoryAppState, { SeriesHistoryAppState } from './HistoryAppState';
|
|
||||||
import ImportSeriesAppState from './ImportSeriesAppState';
|
|
||||||
import InteractiveImportAppState from './InteractiveImportAppState';
|
|
||||||
import MessagesAppState from './MessagesAppState';
|
|
||||||
import OAuthAppState from './OAuthAppState';
|
|
||||||
import OrganizePreviewAppState from './OrganizePreviewAppState';
|
|
||||||
import ParseAppState from './ParseAppState';
|
|
||||||
import PathsAppState from './PathsAppState';
|
|
||||||
import ProviderOptionsAppState from './ProviderOptionsAppState';
|
|
||||||
import ReleasesAppState from './ReleasesAppState';
|
|
||||||
import RootFolderAppState from './RootFolderAppState';
|
|
||||||
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
|
||||||
import SettingsAppState from './SettingsAppState';
|
import SettingsAppState from './SettingsAppState';
|
||||||
import SystemAppState from './SystemAppState';
|
|
||||||
import TagsAppState from './TagsAppState';
|
|
||||||
import WantedAppState from './WantedAppState';
|
|
||||||
|
|
||||||
export interface FilterBuilderPropOption {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FilterBuilderProp<T> {
|
|
||||||
name: string;
|
|
||||||
label: string | (() => string);
|
|
||||||
type: FilterBuilderTypes;
|
|
||||||
valueType?: string;
|
|
||||||
optionsSelector?: (items: T[]) => FilterBuilderPropOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PropertyFilter {
|
|
||||||
key: string;
|
|
||||||
value: string | string[] | number[] | boolean[] | DateFilterValue;
|
|
||||||
type: FilterType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Filter {
|
|
||||||
key: string;
|
|
||||||
label: string | (() => string);
|
|
||||||
filters: PropertyFilter[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CustomFilter extends ModelBase {
|
|
||||||
type: string;
|
|
||||||
label: string;
|
|
||||||
filters: PropertyFilter[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppSectionState {
|
|
||||||
isUpdated: boolean;
|
|
||||||
isConnected: boolean;
|
|
||||||
isDisconnected: boolean;
|
|
||||||
isReconnecting: boolean;
|
|
||||||
isRestarting: boolean;
|
|
||||||
isSidebarVisible: boolean;
|
|
||||||
version: string;
|
|
||||||
prevVersion?: string;
|
|
||||||
dimensions: {
|
|
||||||
isSmallScreen: boolean;
|
|
||||||
isLargeScreen: boolean;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
translations: {
|
|
||||||
error?: Error;
|
|
||||||
isPopulated: boolean;
|
|
||||||
};
|
|
||||||
messages: MessagesAppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
app: AppSectionState;
|
|
||||||
blocklist: BlocklistAppState;
|
|
||||||
calendar: CalendarAppState;
|
|
||||||
captcha: CaptchaAppState;
|
captcha: CaptchaAppState;
|
||||||
commands: CommandAppState;
|
|
||||||
customFilters: CustomFiltersAppState;
|
|
||||||
episodeFiles: EpisodeFilesAppState;
|
|
||||||
episodeHistory: HistoryAppState;
|
|
||||||
episodes: EpisodesAppState;
|
|
||||||
episodesSelection: EpisodesAppState;
|
|
||||||
importSeries: ImportSeriesAppState;
|
|
||||||
interactiveImport: InteractiveImportAppState;
|
|
||||||
oAuth: OAuthAppState;
|
|
||||||
organizePreview: OrganizePreviewAppState;
|
|
||||||
parse: ParseAppState;
|
|
||||||
paths: PathsAppState;
|
|
||||||
providerOptions: ProviderOptionsAppState;
|
|
||||||
releases: ReleasesAppState;
|
|
||||||
rootFolders: RootFolderAppState;
|
|
||||||
series: SeriesAppState;
|
|
||||||
seriesHistory: SeriesHistoryAppState;
|
|
||||||
seriesIndex: SeriesIndexAppState;
|
|
||||||
settings: SettingsAppState;
|
settings: SettingsAppState;
|
||||||
system: SystemAppState;
|
|
||||||
tags: TagsAppState;
|
|
||||||
wanted: WantedAppState;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AppState;
|
export default AppState;
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import Backup from 'typings/Backup';
|
|
||||||
import AppSectionState, { Error } from './AppSectionState';
|
|
||||||
|
|
||||||
interface BackupAppState extends AppSectionState<Backup> {
|
|
||||||
isRestoring: boolean;
|
|
||||||
restoreError?: Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BackupAppState;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import Blocklist from 'typings/Blocklist';
|
|
||||||
import AppSectionState, {
|
|
||||||
AppSectionFilterState,
|
|
||||||
PagedAppSectionState,
|
|
||||||
TableAppSectionState,
|
|
||||||
} from './AppSectionState';
|
|
||||||
|
|
||||||
interface BlocklistAppState
|
|
||||||
extends AppSectionState<Blocklist>,
|
|
||||||
AppSectionFilterState<Blocklist>,
|
|
||||||
PagedAppSectionState,
|
|
||||||
TableAppSectionState {
|
|
||||||
isRemoving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BlocklistAppState;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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<CalendarItem>,
|
|
||||||
AppSectionFilterState<CalendarItem> {
|
|
||||||
searchMissingCommandId: number | null;
|
|
||||||
start: moment.Moment;
|
|
||||||
end: moment.Moment;
|
|
||||||
dates: string[];
|
|
||||||
time: string;
|
|
||||||
view: CalendarView;
|
|
||||||
options: CalendarOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CalendarAppState;
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import { CustomFilter } from './AppState';
|
|
||||||
|
|
||||||
interface ClientSideCollectionAppState {
|
interface ClientSideCollectionAppState {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
customFilters: CustomFilter[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ClientSideCollectionAppState;
|
export default ClientSideCollectionAppState;
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import AppSectionState from 'App/State/AppSectionState';
|
|
||||||
import Command from 'Commands/Command';
|
|
||||||
|
|
||||||
export type CommandAppState = AppSectionState<Command>;
|
|
||||||
|
|
||||||
export default CommandAppState;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import AppSectionState, {
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState,
|
|
||||||
} from 'App/State/AppSectionState';
|
|
||||||
import { CustomFilter } from './AppState';
|
|
||||||
|
|
||||||
interface CustomFiltersAppState
|
|
||||||
extends AppSectionState<CustomFilter>,
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
export default CustomFiltersAppState;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import AppSectionState, {
|
|
||||||
AppSectionDeleteState,
|
|
||||||
} from 'App/State/AppSectionState';
|
|
||||||
import { EpisodeFile } from 'EpisodeFile/EpisodeFile';
|
|
||||||
|
|
||||||
interface EpisodeFilesAppState
|
|
||||||
extends AppSectionState<EpisodeFile>,
|
|
||||||
AppSectionDeleteState {}
|
|
||||||
|
|
||||||
export default EpisodeFilesAppState;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import AppSectionState from 'App/State/AppSectionState';
|
|
||||||
import Column from 'Components/Table/Column';
|
|
||||||
import Episode from 'Episode/Episode';
|
|
||||||
|
|
||||||
interface EpisodesAppState extends AppSectionState<Episode> {
|
|
||||||
columns: Column[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default EpisodesAppState;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import AppSectionState, {
|
|
||||||
AppSectionFilterState,
|
|
||||||
PagedAppSectionState,
|
|
||||||
TableAppSectionState,
|
|
||||||
} from 'App/State/AppSectionState';
|
|
||||||
import History from 'typings/History';
|
|
||||||
|
|
||||||
export type SeriesHistoryAppState = AppSectionState<History>;
|
|
||||||
|
|
||||||
interface HistoryAppState
|
|
||||||
extends AppSectionState<History>,
|
|
||||||
AppSectionFilterState<History>,
|
|
||||||
PagedAppSectionState,
|
|
||||||
TableAppSectionState {}
|
|
||||||
|
|
||||||
export default HistoryAppState;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
|
|
||||||
import { Error } from './AppSectionState';
|
|
||||||
|
|
||||||
export interface ImportSeries {
|
|
||||||
id: string;
|
|
||||||
error?: Error;
|
|
||||||
isFetching: boolean;
|
|
||||||
isPopulated: boolean;
|
|
||||||
isQueued: boolean;
|
|
||||||
items: Series[];
|
|
||||||
monitor: SeriesMonitor;
|
|
||||||
path: string;
|
|
||||||
qualityProfileId: number;
|
|
||||||
relativePath: string;
|
|
||||||
seasonFolder: boolean;
|
|
||||||
selectedSeries?: Series;
|
|
||||||
seriesType: SeriesType;
|
|
||||||
term: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportSeriesAppState {
|
|
||||||
isLookingUpSeries: false;
|
|
||||||
isImporting: false;
|
|
||||||
isImported: false;
|
|
||||||
importError: Error | null;
|
|
||||||
items: ImportSeries[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ImportSeriesAppState;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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<InteractiveImport> {
|
|
||||||
originalItems: InteractiveImport[];
|
|
||||||
importMode: ImportMode;
|
|
||||||
favoriteFolders: FavoriteFolder[];
|
|
||||||
recentFolders: RecentFolder[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default InteractiveImportAppState;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import ModelBase from 'App/ModelBase';
|
|
||||||
import AppSectionState from 'App/State/AppSectionState';
|
|
||||||
|
|
||||||
export type MessageType = 'error' | 'info' | 'success' | 'warning';
|
|
||||||
|
|
||||||
export interface Message extends ModelBase {
|
|
||||||
hideAfter: number;
|
|
||||||
message: string;
|
|
||||||
name: string;
|
|
||||||
type: MessageType;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessagesAppState = AppSectionState<Message>;
|
|
||||||
|
|
||||||
export default MessagesAppState;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { AppSectionProviderState } from 'App/State/AppSectionState';
|
|
||||||
import Metadata from 'typings/Metadata';
|
|
||||||
|
|
||||||
type MetadataAppState = AppSectionProviderState<Metadata>;
|
|
||||||
|
|
||||||
export default MetadataAppState;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { Error } from './AppSectionState';
|
|
||||||
|
|
||||||
interface OAuthAppState {
|
|
||||||
authorizing: boolean;
|
|
||||||
result: Record<string, unknown> | null;
|
|
||||||
error: Error;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default OAuthAppState;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import ModelBase from 'App/ModelBase';
|
|
||||||
import AppSectionState from 'App/State/AppSectionState';
|
|
||||||
|
|
||||||
export interface OrganizePreviewModel extends ModelBase {
|
|
||||||
seriesId: number;
|
|
||||||
seasonNumber: number;
|
|
||||||
episodeNumbers: number[];
|
|
||||||
episodeFileId: number;
|
|
||||||
existingPath: string;
|
|
||||||
newPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type OrganizePreviewAppState = AppSectionState<OrganizePreviewModel>;
|
|
||||||
|
|
||||||
export default OrganizePreviewAppState;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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<ProviderOptionsDevice>;
|
|
||||||
servers: AppSectionState<FieldSelectOption<unknown>>;
|
|
||||||
newznabCategories: AppSectionState<FieldSelectOption<unknown>>;
|
|
||||||
getProfiles: AppSectionState<FieldSelectOption<unknown>>;
|
|
||||||
getTags: AppSectionState<FieldSelectOption<unknown>>;
|
|
||||||
getRootFolders: AppSectionState<FieldSelectOption<unknown>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ProviderOptionsAppState;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import AppSectionState, {
|
|
||||||
AppSectionFilterState,
|
|
||||||
} from 'App/State/AppSectionState';
|
|
||||||
import Release from 'typings/Release';
|
|
||||||
|
|
||||||
interface ReleasesAppState
|
|
||||||
extends AppSectionState<Release>,
|
|
||||||
AppSectionFilterState<Release> {}
|
|
||||||
|
|
||||||
export default ReleasesAppState;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import AppSectionState, {
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState,
|
|
||||||
} from 'App/State/AppSectionState';
|
|
||||||
import RootFolder from 'typings/RootFolder';
|
|
||||||
|
|
||||||
interface RootFolderAppState
|
|
||||||
extends AppSectionState<RootFolder>,
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
export default RootFolderAppState;
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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<Series>[];
|
|
||||||
filters: Filter[];
|
|
||||||
columns: Column[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SeriesAppState
|
|
||||||
extends AppSectionState<Series>,
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState {
|
|
||||||
itemMap: Record<number, number>;
|
|
||||||
|
|
||||||
deleteOptions: {
|
|
||||||
addImportListExclusion: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
pendingChanges: Partial<Series>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SeriesAppState;
|
|
||||||
@@ -1,36 +1,18 @@
|
|||||||
import AppSectionState, {
|
import AppSectionState, {
|
||||||
AppSectionDeleteState,
|
AppSectionDeleteState,
|
||||||
AppSectionItemSchemaState,
|
|
||||||
AppSectionItemState,
|
AppSectionItemState,
|
||||||
AppSectionListState,
|
AppSectionListState,
|
||||||
AppSectionSaveState,
|
AppSectionSaveState,
|
||||||
AppSectionSchemaState,
|
AppSectionSchemaState,
|
||||||
PagedAppSectionState,
|
|
||||||
} from 'App/State/AppSectionState';
|
} from 'App/State/AppSectionState';
|
||||||
import Language from 'Language/Language';
|
|
||||||
import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging';
|
import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging';
|
||||||
import CustomFormat from 'typings/CustomFormat';
|
import CustomFormat from 'typings/CustomFormat';
|
||||||
import CustomFormatSpecification from 'typings/CustomFormatSpecification';
|
import CustomFormatSpecification from 'typings/CustomFormatSpecification';
|
||||||
import DelayProfile from 'typings/DelayProfile';
|
import DelayProfile from 'typings/DelayProfile';
|
||||||
import DownloadClient from 'typings/DownloadClient';
|
import DownloadClient from 'typings/DownloadClient';
|
||||||
import ImportList from 'typings/ImportList';
|
import ImportList from 'typings/ImportList';
|
||||||
import ImportListExclusion from 'typings/ImportListExclusion';
|
|
||||||
import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
|
import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
|
||||||
import Indexer from 'typings/Indexer';
|
|
||||||
import IndexerFlag from 'typings/IndexerFlag';
|
|
||||||
import Notification from 'typings/Notification';
|
|
||||||
import QualityDefinition from 'typings/QualityDefinition';
|
|
||||||
import QualityProfile from 'typings/QualityProfile';
|
|
||||||
import DownloadClientOptions from 'typings/Settings/DownloadClientOptions';
|
import DownloadClientOptions from 'typings/Settings/DownloadClientOptions';
|
||||||
import General from 'typings/Settings/General';
|
|
||||||
import IndexerOptions from 'typings/Settings/IndexerOptions';
|
|
||||||
import MediaManagement from 'typings/Settings/MediaManagement';
|
|
||||||
import NamingConfig from 'typings/Settings/NamingConfig';
|
|
||||||
import NamingExample from 'typings/Settings/NamingExample';
|
|
||||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
|
||||||
import RemotePathMapping from 'typings/Settings/RemotePathMapping';
|
|
||||||
import UiSettings from 'typings/Settings/UiSettings';
|
|
||||||
import MetadataAppState from './MetadataAppState';
|
|
||||||
|
|
||||||
type Presets<T> = T & {
|
type Presets<T> = T & {
|
||||||
presets: T[];
|
presets: T[];
|
||||||
@@ -64,20 +46,6 @@ export interface DownloadClientOptionsAppState
|
|||||||
extends AppSectionItemState<DownloadClientOptions>,
|
extends AppSectionItemState<DownloadClientOptions>,
|
||||||
AppSectionSaveState {}
|
AppSectionSaveState {}
|
||||||
|
|
||||||
export interface GeneralAppState
|
|
||||||
extends AppSectionItemState<General>,
|
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
export interface MediaManagementAppState
|
|
||||||
extends AppSectionItemState<MediaManagement>,
|
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
export interface NamingAppState
|
|
||||||
extends AppSectionItemState<NamingConfig>,
|
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
export type NamingExamplesAppState = AppSectionItemState<NamingExample>;
|
|
||||||
|
|
||||||
export interface ImportListAppState
|
export interface ImportListAppState
|
||||||
extends AppSectionState<ImportList>,
|
extends AppSectionState<ImportList>,
|
||||||
AppSectionDeleteState,
|
AppSectionDeleteState,
|
||||||
@@ -86,44 +54,6 @@ export interface ImportListAppState
|
|||||||
isTestingAll: boolean;
|
isTestingAll: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IndexerOptionsAppState
|
|
||||||
extends AppSectionItemState<IndexerOptions>,
|
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
export interface IndexerAppState
|
|
||||||
extends AppSectionState<Indexer>,
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState,
|
|
||||||
AppSectionSchemaState<Presets<Indexer>> {
|
|
||||||
isTestingAll: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotificationAppState
|
|
||||||
extends AppSectionState<Notification>,
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState,
|
|
||||||
AppSectionSchemaState<Presets<Notification>> {}
|
|
||||||
|
|
||||||
export interface QualityDefinitionsAppState
|
|
||||||
extends AppSectionState<QualityDefinition>,
|
|
||||||
AppSectionSaveState {
|
|
||||||
pendingChanges: {
|
|
||||||
[key: number]: Partial<QualityProfile>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QualityProfilesAppState
|
|
||||||
extends AppSectionState<QualityProfile>,
|
|
||||||
AppSectionItemSchemaState<QualityProfile>,
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
export interface ReleaseProfilesAppState
|
|
||||||
extends AppSectionState<ReleaseProfile>,
|
|
||||||
AppSectionSaveState {
|
|
||||||
pendingChanges: Partial<ReleaseProfile>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CustomFormatAppState
|
export interface CustomFormatAppState
|
||||||
extends AppSectionState<CustomFormat>,
|
extends AppSectionState<CustomFormat>,
|
||||||
AppSectionDeleteState,
|
AppSectionDeleteState,
|
||||||
@@ -139,27 +69,7 @@ export interface ImportListOptionsSettingsAppState
|
|||||||
extends AppSectionItemState<ImportListOptionsSettings>,
|
extends AppSectionItemState<ImportListOptionsSettings>,
|
||||||
AppSectionSaveState {}
|
AppSectionSaveState {}
|
||||||
|
|
||||||
export interface ImportListExclusionsSettingsAppState
|
|
||||||
extends AppSectionState<ImportListExclusion>,
|
|
||||||
AppSectionSaveState,
|
|
||||||
PagedAppSectionState,
|
|
||||||
AppSectionDeleteState {
|
|
||||||
pendingChanges: Partial<ImportListExclusion>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RemotePathMappingsAppState
|
|
||||||
extends AppSectionState<RemotePathMapping>,
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState {
|
|
||||||
pendingChanges: Partial<RemotePathMapping>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type IndexerFlagSettingsAppState = AppSectionState<IndexerFlag>;
|
|
||||||
export type LanguageSettingsAppState = AppSectionState<Language>;
|
|
||||||
export type UiSettingsAppState = AppSectionItemState<UiSettings>;
|
|
||||||
|
|
||||||
interface SettingsAppState {
|
interface SettingsAppState {
|
||||||
advancedSettings: boolean;
|
|
||||||
autoTaggings: AutoTaggingAppState;
|
autoTaggings: AutoTaggingAppState;
|
||||||
autoTaggingSpecifications: AutoTaggingSpecificationAppState;
|
autoTaggingSpecifications: AutoTaggingSpecificationAppState;
|
||||||
customFormats: CustomFormatAppState;
|
customFormats: CustomFormatAppState;
|
||||||
@@ -167,24 +77,8 @@ interface SettingsAppState {
|
|||||||
delayProfiles: DelayProfileAppState;
|
delayProfiles: DelayProfileAppState;
|
||||||
downloadClients: DownloadClientAppState;
|
downloadClients: DownloadClientAppState;
|
||||||
downloadClientOptions: DownloadClientOptionsAppState;
|
downloadClientOptions: DownloadClientOptionsAppState;
|
||||||
general: GeneralAppState;
|
|
||||||
importListExclusions: ImportListExclusionsSettingsAppState;
|
|
||||||
importListOptions: ImportListOptionsSettingsAppState;
|
importListOptions: ImportListOptionsSettingsAppState;
|
||||||
importLists: ImportListAppState;
|
importLists: ImportListAppState;
|
||||||
indexerFlags: IndexerFlagSettingsAppState;
|
|
||||||
indexerOptions: IndexerOptionsAppState;
|
|
||||||
indexers: IndexerAppState;
|
|
||||||
languages: LanguageSettingsAppState;
|
|
||||||
mediaManagement: MediaManagementAppState;
|
|
||||||
metadata: MetadataAppState;
|
|
||||||
naming: NamingAppState;
|
|
||||||
namingExamples: NamingExamplesAppState;
|
|
||||||
notifications: NotificationAppState;
|
|
||||||
qualityDefinitions: QualityDefinitionsAppState;
|
|
||||||
qualityProfiles: QualityProfilesAppState;
|
|
||||||
releaseProfiles: ReleaseProfilesAppState;
|
|
||||||
remotePathMappings: RemotePathMappingsAppState;
|
|
||||||
ui: UiSettingsAppState;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SettingsAppState;
|
export default SettingsAppState;
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import DiskSpace from 'typings/DiskSpace';
|
|
||||||
import Health from 'typings/Health';
|
|
||||||
import LogFile from 'typings/LogFile';
|
|
||||||
import SystemStatus from 'typings/SystemStatus';
|
|
||||||
import Task from 'typings/Task';
|
|
||||||
import AppSectionState, { AppSectionItemState } from './AppSectionState';
|
|
||||||
import BackupAppState from './BackupAppState';
|
|
||||||
|
|
||||||
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
|
|
||||||
export type HealthAppState = AppSectionState<Health>;
|
|
||||||
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
|
|
||||||
export type TaskAppState = AppSectionState<Task>;
|
|
||||||
export type LogFilesAppState = AppSectionState<LogFile>;
|
|
||||||
|
|
||||||
interface SystemAppState {
|
|
||||||
backups: BackupAppState;
|
|
||||||
diskSpace: DiskSpaceAppState;
|
|
||||||
health: HealthAppState;
|
|
||||||
logFiles: LogFilesAppState;
|
|
||||||
status: SystemStatusAppState;
|
|
||||||
tasks: TaskAppState;
|
|
||||||
updateLogFiles: LogFilesAppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SystemAppState;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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[];
|
|
||||||
excludedReleaseProfileIds: number[];
|
|
||||||
seriesIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TagDetailAppState
|
|
||||||
extends AppSectionState<TagDetail>,
|
|
||||||
AppSectionDeleteState,
|
|
||||||
AppSectionSaveState {}
|
|
||||||
|
|
||||||
interface TagsAppState extends AppSectionState<Tag>, AppSectionDeleteState {
|
|
||||||
details: TagDetailAppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TagsAppState;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import AppSectionState, {
|
|
||||||
AppSectionFilterState,
|
|
||||||
PagedAppSectionState,
|
|
||||||
TableAppSectionState,
|
|
||||||
} from 'App/State/AppSectionState';
|
|
||||||
import Episode from 'Episode/Episode';
|
|
||||||
|
|
||||||
interface WantedEpisode extends Episode {
|
|
||||||
isSaving?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WantedCutoffUnmetAppState
|
|
||||||
extends AppSectionState<WantedEpisode>,
|
|
||||||
AppSectionFilterState<WantedEpisode>,
|
|
||||||
PagedAppSectionState,
|
|
||||||
TableAppSectionState {}
|
|
||||||
|
|
||||||
interface WantedMissingAppState
|
|
||||||
extends AppSectionState<WantedEpisode>,
|
|
||||||
AppSectionFilterState<WantedEpisode>,
|
|
||||||
PagedAppSectionState,
|
|
||||||
TableAppSectionState {}
|
|
||||||
|
|
||||||
interface WantedAppState {
|
|
||||||
cutoffUnmet: WantedCutoffUnmetAppState;
|
|
||||||
missing: WantedMissingAppState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WantedAppState;
|
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import getQueryPath from 'Utilities/Fetch/getQueryPath';
|
||||||
|
import fetchJson from 'Utilities/requestAction';
|
||||||
|
|
||||||
|
function getDimensions(width: number, height: number) {
|
||||||
|
const dimensions = {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
isExtraSmallScreen: width <= 480,
|
||||||
|
isSmallScreen: width <= 768,
|
||||||
|
isMediumScreen: width <= 992,
|
||||||
|
isLargeScreen: width <= 1200,
|
||||||
|
};
|
||||||
|
|
||||||
|
return dimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Dimensions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
isExtraSmallScreen: boolean;
|
||||||
|
isSmallScreen: boolean;
|
||||||
|
isMediumScreen: boolean;
|
||||||
|
isLargeScreen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
dimensions: Dimensions;
|
||||||
|
version: string;
|
||||||
|
prevVersion?: string;
|
||||||
|
isUpdated: boolean;
|
||||||
|
isConnected: boolean;
|
||||||
|
isReconnecting: boolean;
|
||||||
|
isDisconnected: boolean;
|
||||||
|
isRestarting: boolean;
|
||||||
|
isSidebarVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variables for ping functionality
|
||||||
|
let abortPingServer: (() => void) | null = null;
|
||||||
|
let pingTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const useAppStore = create<AppState>()(() => {
|
||||||
|
const dimensions = getDimensions(window.innerWidth, window.innerHeight);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dimensions,
|
||||||
|
version: window.Sonarr.version,
|
||||||
|
isUpdated: false,
|
||||||
|
isConnected: true,
|
||||||
|
isReconnecting: false,
|
||||||
|
isDisconnected: false,
|
||||||
|
isRestarting: false,
|
||||||
|
isSidebarVisible: !dimensions.isSmallScreen,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useAppValues = <K extends keyof AppState>(...keys: K[]) => {
|
||||||
|
return useAppStore(
|
||||||
|
useShallow((state) => {
|
||||||
|
return keys.reduce((acc, key) => {
|
||||||
|
acc[key] = state[key];
|
||||||
|
return acc;
|
||||||
|
}, {} as Pick<AppState, K>);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAppValue = <K extends keyof AppState>(key: K) => {
|
||||||
|
return useAppStore(useShallow((state) => state[key]));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAppDimensions = () => {
|
||||||
|
return useAppStore(useShallow((state) => state.dimensions));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAppDimension = <K extends keyof Dimensions>(key: K) => {
|
||||||
|
return useAppStore(useShallow((state) => state.dimensions[key]));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAppDimensions = () => {
|
||||||
|
return useAppStore.getState().dimensions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAppValues = <K extends keyof AppState>(...keys: K[]) => {
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
return keys.reduce((acc, key) => {
|
||||||
|
acc[key] = state[key];
|
||||||
|
return acc;
|
||||||
|
}, {} as Pick<AppState, K>);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAppValue = <K extends keyof AppState>(key: K) => {
|
||||||
|
return useAppStore.getState()[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
function pingServerAfterTimeout() {
|
||||||
|
if (abortPingServer) {
|
||||||
|
abortPingServer();
|
||||||
|
abortPingServer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pingTimeout) {
|
||||||
|
clearTimeout(pingTimeout);
|
||||||
|
pingTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pingTimeout = setTimeout(async () => {
|
||||||
|
const { isRestarting, isConnected } = getAppValues(
|
||||||
|
'isRestarting',
|
||||||
|
'isConnected'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isRestarting && isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortPingServer = () => abortController.abort();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetchJson({
|
||||||
|
url: getQueryPath('/system/status'),
|
||||||
|
method: 'GET',
|
||||||
|
signal: abortController.signal,
|
||||||
|
headers: {
|
||||||
|
'X-Api-Key': window.Sonarr.apiKey,
|
||||||
|
'X-Sonarr-Client': 'Sonarr',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
abortPingServer = null;
|
||||||
|
pingTimeout = null;
|
||||||
|
|
||||||
|
setAppValue({
|
||||||
|
isRestarting: false,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
abortPingServer = null;
|
||||||
|
pingTimeout = null;
|
||||||
|
|
||||||
|
if ((error as { status?: number }).status === 401) {
|
||||||
|
setAppValue({
|
||||||
|
isRestarting: false,
|
||||||
|
});
|
||||||
|
} else if (!abortController.signal.aborted) {
|
||||||
|
pingServerAfterTimeout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveDimensions = ({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}) => {
|
||||||
|
const dimensions = getDimensions(width, height);
|
||||||
|
useAppStore.setState({ dimensions });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setVersion = ({ version }: { version: string }) => {
|
||||||
|
useAppStore.setState((state) => {
|
||||||
|
const newState: Partial<AppState> = {
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.version !== version) {
|
||||||
|
if (!state.prevVersion) {
|
||||||
|
newState.prevVersion = state.version;
|
||||||
|
}
|
||||||
|
newState.isUpdated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setIsSidebarVisible = ({
|
||||||
|
isSidebarVisible,
|
||||||
|
}: {
|
||||||
|
isSidebarVisible: boolean;
|
||||||
|
}) => {
|
||||||
|
useAppStore.setState({ isSidebarVisible });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleIsSidebarVisible = () => {
|
||||||
|
useAppStore.setState((state) => ({
|
||||||
|
isSidebarVisible: !state.isSidebarVisible,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setAppValue = (payload: Partial<AppState>) => {
|
||||||
|
useAppStore.setState(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pingServer = () => {
|
||||||
|
pingServerAfterTimeout();
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
import ModelBase from './ModelBase';
|
||||||
|
|
||||||
|
export type MessageType = 'error' | 'info' | 'success' | 'warning';
|
||||||
|
|
||||||
|
export interface Message extends ModelBase {
|
||||||
|
hideAfter: number;
|
||||||
|
message: string;
|
||||||
|
name: string;
|
||||||
|
type: MessageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessagesState {
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const useMessagesStore = create<MessagesState>()(() => ({
|
||||||
|
messages: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useMessages = () => {
|
||||||
|
return useMessagesStore(useShallow((state) => state.messages));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMessages = () => {
|
||||||
|
return useMessagesStore.getState().messages;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showMessage = (payload: Message) => {
|
||||||
|
useMessagesStore.setState((state) => {
|
||||||
|
const messages = [...state.messages];
|
||||||
|
const index = messages.findIndex((item) => item.id === payload.id);
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
const item = messages[index];
|
||||||
|
messages.splice(index, 1, { ...item, ...payload });
|
||||||
|
} else {
|
||||||
|
messages.push({ ...payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hideMessage = ({ id }: { id: string | number }) => {
|
||||||
|
useMessagesStore.setState((state) => {
|
||||||
|
const messages = state.messages.filter((item) => item.id !== id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient();
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||||
|
import { setTranslations } from 'Utilities/String/translate';
|
||||||
|
|
||||||
|
interface TranslationsResponse {
|
||||||
|
strings: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTranslations() {
|
||||||
|
const { data, ...result } = useApiQuery<TranslationsResponse>({
|
||||||
|
path: '/localization',
|
||||||
|
queryOptions: {
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: Infinity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setTranslations(data.strings);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import useCalendar from 'Calendar/useCalendar';
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import AgendaEvent from './AgendaEvent';
|
import AgendaEvent from './AgendaEvent';
|
||||||
import styles from './Agenda.css';
|
import styles from './Agenda.css';
|
||||||
|
|
||||||
function Agenda() {
|
function Agenda() {
|
||||||
const { items } = useSelector((state: AppState) => state.calendar);
|
const { data } = useCalendar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.agenda}>
|
<div className={styles.agenda}>
|
||||||
{items.map((item, index) => {
|
{data.map((item, index) => {
|
||||||
const momentDate = moment(item.airDateUtc);
|
const momentDate = moment(item.airDateUtc);
|
||||||
const showDate =
|
const showDate =
|
||||||
index === 0 ||
|
index === 0 ||
|
||||||
!moment(items[index - 1].airDateUtc).isSame(momentDate, 'day');
|
!moment(data[index - 1].airDateUtc).isSame(momentDate, 'day');
|
||||||
|
|
||||||
return <AgendaEvent key={item.id} showDate={showDate} {...item} />;
|
return <AgendaEvent key={item.id} showDate={showDate} {...item} />;
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import AppState from 'App/State/AppState';
|
import { useCalendarOptions } from 'Calendar/calendarOptionsStore';
|
||||||
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
|
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
|
||||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
@@ -10,10 +9,10 @@ import Link from 'Components/Link/Link';
|
|||||||
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
||||||
import episodeEntities from 'Episode/episodeEntities';
|
import episodeEntities from 'Episode/episodeEntities';
|
||||||
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
import { useEpisodeFile } from 'EpisodeFile/EpisodeFileProvider';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import useSeries from 'Series/useSeries';
|
import { useSingleSeries } from 'Series/useSeries';
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
|
||||||
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
|
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
|
||||||
import formatTime from 'Utilities/Date/formatTime';
|
import formatTime from 'Utilities/Date/formatTime';
|
||||||
import padNumber from 'Utilities/Number/padNumber';
|
import padNumber from 'Utilities/Number/padNumber';
|
||||||
@@ -55,18 +54,18 @@ function AgendaEvent(props: AgendaEventProps) {
|
|||||||
showDate,
|
showDate,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const series = useSeries(seriesId)!;
|
const series = useSingleSeries(seriesId)!;
|
||||||
const episodeFile = useEpisodeFile(episodeFileId);
|
const episodeFile = useEpisodeFile(episodeFileId);
|
||||||
const queueItem = useQueueItemForEpisode(id);
|
const queueItem = useQueueItemForEpisode(id);
|
||||||
const { timeFormat, longDateFormat, enableColorImpairedMode, timeZone } =
|
const { timeFormat, longDateFormat, enableColorImpairedMode, timeZone } =
|
||||||
useSelector(createUISettingsSelector());
|
useUiSettingsValues();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showEpisodeInformation,
|
showEpisodeInformation,
|
||||||
showFinaleIcon,
|
showFinaleIcon,
|
||||||
showSpecialIcon,
|
showSpecialIcon,
|
||||||
showCutoffUnmetIcon,
|
showCutoffUnmetIcon,
|
||||||
} = useSelector((state: AppState) => state.calendar.options);
|
} = useCalendarOptions();
|
||||||
|
|
||||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,91 +1,65 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import CommandNames from 'Commands/CommandNames';
|
||||||
import AppState from 'App/State/AppState';
|
import { useCommandExecuting } from 'Commands/useCommands';
|
||||||
import * as commandNames from 'Commands/commandNames';
|
|
||||||
import Alert from 'Components/Alert';
|
import Alert from 'Components/Alert';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
import Episode from 'Episode/Episode';
|
|
||||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
import {
|
|
||||||
clearCalendar,
|
|
||||||
fetchCalendar,
|
|
||||||
gotoCalendarToday,
|
|
||||||
} from 'Store/Actions/calendarActions';
|
|
||||||
import {
|
|
||||||
clearEpisodeFiles,
|
|
||||||
fetchEpisodeFiles,
|
|
||||||
} from 'Store/Actions/episodeFileActions';
|
|
||||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
|
||||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
|
||||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
|
||||||
import {
|
import {
|
||||||
registerPagePopulator,
|
registerPagePopulator,
|
||||||
unregisterPagePopulator,
|
unregisterPagePopulator,
|
||||||
} from 'Utilities/pagePopulator';
|
} from 'Utilities/pagePopulator';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import Agenda from './Agenda/Agenda';
|
import Agenda from './Agenda/Agenda';
|
||||||
|
import { useCalendarOption } from './calendarOptionsStore';
|
||||||
import CalendarDays from './Day/CalendarDays';
|
import CalendarDays from './Day/CalendarDays';
|
||||||
import DaysOfWeek from './Day/DaysOfWeek';
|
import DaysOfWeek from './Day/DaysOfWeek';
|
||||||
import CalendarHeader from './Header/CalendarHeader';
|
import CalendarHeader from './Header/CalendarHeader';
|
||||||
|
import useCalendar, { goToToday } from './useCalendar';
|
||||||
import styles from './Calendar.css';
|
import styles from './Calendar.css';
|
||||||
|
|
||||||
const UPDATE_DELAY = 3600000; // 1 hour
|
const UPDATE_DELAY = 3600000; // 1 hour
|
||||||
|
|
||||||
function Calendar() {
|
function Calendar() {
|
||||||
const dispatch = useDispatch();
|
|
||||||
const requestCurrentPage = useCurrentPage();
|
const requestCurrentPage = useCurrentPage();
|
||||||
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
|
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const { isFetching, isPopulated, error, items, time, view } = useSelector(
|
const { isFetching, isLoading, error, refetch } = useCalendar();
|
||||||
(state: AppState) => state.calendar
|
const view = useCalendarOption('view');
|
||||||
);
|
|
||||||
|
|
||||||
const isRefreshingSeries = useSelector(
|
const isRefreshingSeries = useCommandExecuting(CommandNames.RefreshSeries);
|
||||||
createCommandExecutingSelector(commandNames.REFRESH_SERIES)
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstDayOfWeek = useSelector(
|
|
||||||
(state: AppState) => state.settings.ui.item.firstDayOfWeek
|
|
||||||
);
|
|
||||||
|
|
||||||
const wasRefreshingSeries = usePrevious(isRefreshingSeries);
|
const wasRefreshingSeries = usePrevious(isRefreshingSeries);
|
||||||
const previousFirstDayOfWeek = usePrevious(firstDayOfWeek);
|
|
||||||
const previousItems = usePrevious(items);
|
|
||||||
|
|
||||||
const handleScheduleUpdate = useCallback(() => {
|
const handleScheduleUpdate = useCallback(() => {
|
||||||
clearTimeout(updateTimeout.current);
|
clearTimeout(updateTimeout.current);
|
||||||
|
|
||||||
function updateCalendar() {
|
function updateCalendar() {
|
||||||
dispatch(gotoCalendarToday());
|
goToToday();
|
||||||
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
|
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
|
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
|
||||||
}, [dispatch]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleScheduleUpdate();
|
handleScheduleUpdate();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
dispatch(clearCalendar());
|
|
||||||
dispatch(clearEpisodeFiles());
|
|
||||||
clearTimeout(updateTimeout.current);
|
clearTimeout(updateTimeout.current);
|
||||||
};
|
};
|
||||||
}, [dispatch, handleScheduleUpdate]);
|
}, [handleScheduleUpdate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (requestCurrentPage) {
|
if (!requestCurrentPage) {
|
||||||
dispatch(fetchCalendar());
|
goToToday();
|
||||||
} else {
|
|
||||||
dispatch(gotoCalendarToday());
|
|
||||||
}
|
}
|
||||||
}, [requestCurrentPage, dispatch]);
|
}, [requestCurrentPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const repopulate = () => {
|
const repopulate = () => {
|
||||||
dispatch(fetchCalendar({ time, view }));
|
refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
registerPagePopulator(repopulate, [
|
registerPagePopulator(repopulate, [
|
||||||
@@ -96,53 +70,31 @@ function Calendar() {
|
|||||||
return () => {
|
return () => {
|
||||||
unregisterPagePopulator(repopulate);
|
unregisterPagePopulator(repopulate);
|
||||||
};
|
};
|
||||||
}, [time, view, dispatch]);
|
}, [refetch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleScheduleUpdate();
|
handleScheduleUpdate();
|
||||||
}, [time, handleScheduleUpdate]);
|
}, [handleScheduleUpdate]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
previousFirstDayOfWeek != null &&
|
|
||||||
firstDayOfWeek !== previousFirstDayOfWeek
|
|
||||||
) {
|
|
||||||
dispatch(fetchCalendar({ time, view }));
|
|
||||||
}
|
|
||||||
}, [time, view, firstDayOfWeek, previousFirstDayOfWeek, dispatch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wasRefreshingSeries && !isRefreshingSeries) {
|
if (wasRefreshingSeries && !isRefreshingSeries) {
|
||||||
dispatch(fetchCalendar({ time, view }));
|
refetch();
|
||||||
}
|
}
|
||||||
}, [time, view, isRefreshingSeries, wasRefreshingSeries, dispatch]);
|
}, [isRefreshingSeries, wasRefreshingSeries, refetch]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!previousItems || hasDifferentItems(items, previousItems)) {
|
|
||||||
const episodeFileIds = selectUniqueIds<Episode, number>(
|
|
||||||
items,
|
|
||||||
'episodeFileId'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (episodeFileIds.length) {
|
|
||||||
dispatch(fetchEpisodeFiles({ episodeFileIds }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [items, previousItems, dispatch]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.calendar}>
|
<div className={styles.calendar}>
|
||||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
{isLoading ? <LoadingIndicator /> : null}
|
||||||
{!isFetching && error ? (
|
{!isFetching && error ? (
|
||||||
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
|
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
{!error && isPopulated && view === 'agenda' ? (
|
{!error && !isLoading && view === 'agenda' ? (
|
||||||
<div className={styles.calendarContent}>
|
<div className={styles.calendarContent}>
|
||||||
<CalendarHeader />
|
<CalendarHeader />
|
||||||
<Agenda />
|
<Agenda />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{!error && isPopulated && view !== 'agenda' ? (
|
{!error && !isLoading && view !== 'agenda' ? (
|
||||||
<div className={styles.calendarContent}>
|
<div className={styles.calendarContent}>
|
||||||
<CalendarHeader />
|
<CalendarHeader />
|
||||||
<DaysOfWeek />
|
<DaysOfWeek />
|
||||||
|
|||||||
@@ -1,49 +1,24 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { SetFilter } from 'Components/Filter/Filter';
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||||
import { setCalendarFilter } from 'Store/Actions/calendarActions';
|
import { setCalendarOption } from './calendarOptionsStore';
|
||||||
|
import useCalendar, { FILTER_BUILDER } from './useCalendar';
|
||||||
function createCalendarSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.calendar.items,
|
|
||||||
(calendar) => {
|
|
||||||
return calendar;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFilterBuilderPropsSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state: AppState) => state.calendar.filterBuilderProps,
|
|
||||||
(filterBuilderProps) => {
|
|
||||||
return filterBuilderProps;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type CalendarFilterModalProps = FilterModalProps<History>;
|
type CalendarFilterModalProps = FilterModalProps<History>;
|
||||||
|
|
||||||
export default function CalendarFilterModal(props: CalendarFilterModalProps) {
|
export default function CalendarFilterModal(props: CalendarFilterModalProps) {
|
||||||
const sectionItems = useSelector(createCalendarSelector());
|
const { data } = useCalendar();
|
||||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
|
||||||
const customFilterType = 'calendar';
|
const customFilterType = 'calendar';
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatchSetFilter = useCallback(({ selectedFilterKey }: SetFilter) => {
|
||||||
|
setCalendarOption('selectedFilterKey', selectedFilterKey);
|
||||||
const dispatchSetFilter = useCallback(
|
}, []);
|
||||||
(payload: unknown) => {
|
|
||||||
dispatch(setCalendarFilter(payload));
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterModal
|
<FilterModal
|
||||||
{...props}
|
{...props}
|
||||||
sectionItems={sectionItems}
|
sectionItems={data}
|
||||||
filterBuilderProps={filterBuilderProps}
|
filterBuilderProps={FILTER_BUILDER}
|
||||||
customFilterType={customFilterType}
|
customFilterType={customFilterType}
|
||||||
dispatchSetFilter={dispatchSetFilter}
|
dispatchSetFilter={dispatchSetFilter}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,68 +1,59 @@
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { useQueueDetails } from 'Activity/Queue/Details/QueueDetailsProvider';
|
import { useQueueDetails } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import AppState from 'App/State/AppState';
|
import { useCommands } from 'Commands/useCommands';
|
||||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||||
import { icons } from 'Helpers/Props';
|
import { icons } from 'Helpers/Props';
|
||||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
|
||||||
import Queue from 'typings/Queue';
|
|
||||||
import { isCommandExecuting } from 'Utilities/Command';
|
import { isCommandExecuting } from 'Utilities/Command';
|
||||||
import isBefore from 'Utilities/Date/isBefore';
|
import isBefore from 'Utilities/Date/isBefore';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
import useCalendar, {
|
||||||
|
useCalendarRange,
|
||||||
|
useCalendarSearchMissingCommandId,
|
||||||
|
} from './useCalendar';
|
||||||
|
|
||||||
function createIsSearchingSelector() {
|
function useIsSearching(searchMissingCommandId: number | undefined) {
|
||||||
return createSelector(
|
const { data: commands } = useCommands();
|
||||||
(state: AppState) => state.calendar.searchMissingCommandId,
|
|
||||||
createCommandsSelector(),
|
|
||||||
(searchMissingCommandId, commands) => {
|
|
||||||
if (searchMissingCommandId == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isCommandExecuting(
|
if (searchMissingCommandId == null) {
|
||||||
commands.find((command) => {
|
return false;
|
||||||
return command.id === searchMissingCommandId;
|
}
|
||||||
})
|
|
||||||
);
|
return isCommandExecuting(
|
||||||
}
|
commands.find((command) => {
|
||||||
|
return command.id === searchMissingCommandId;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMissingEpisodeIdsSelector(queueDetails: Queue[]) {
|
const useMissingEpisodeIdsSelector = () => {
|
||||||
return createSelector(
|
const { start, end } = useCalendarRange();
|
||||||
(state: AppState) => state.calendar.start,
|
const { data } = useCalendar();
|
||||||
(state: AppState) => state.calendar.end,
|
const queueDetails = useQueueDetails();
|
||||||
(state: AppState) => state.calendar.items,
|
|
||||||
(start, end, episodes) => {
|
|
||||||
return episodes.reduce<number[]>((acc, episode) => {
|
|
||||||
const airDateUtc = episode.airDateUtc;
|
|
||||||
|
|
||||||
if (
|
return data.reduce<number[]>((acc, episode) => {
|
||||||
!episode.episodeFileId &&
|
const airDateUtc = episode.airDateUtc;
|
||||||
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;
|
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;
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
|
|
||||||
export default function CalendarMissingEpisodeSearchButton() {
|
export default function CalendarMissingEpisodeSearchButton() {
|
||||||
const queueDetails = useQueueDetails();
|
const searchMissingCommandId = useCalendarSearchMissingCommandId();
|
||||||
const missingEpisodeIds = useSelector(
|
const missingEpisodeIds = useMissingEpisodeIdsSelector();
|
||||||
createMissingEpisodeIdsSelector(queueDetails)
|
const isSearchingForMissing = useIsSearching(searchMissingCommandId);
|
||||||
);
|
|
||||||
const isSearchingForMissing = useSelector(createIsSearchingSelector());
|
|
||||||
|
|
||||||
const handlePress = useCallback(() => {}, []);
|
const handlePress = useCallback(() => {}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, {
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
PropsWithChildren,
|
||||||
import QueueDetails from 'Activity/Queue/Details/QueueDetailsProvider';
|
useCallback,
|
||||||
import AppState from 'App/State/AppState';
|
useEffect,
|
||||||
import * as commandNames from 'Commands/commandNames';
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import QueueDetailsProvider from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
|
import CommandNames from 'Commands/CommandNames';
|
||||||
|
import { useCommandExecuting, useExecuteCommand } from 'Commands/useCommands';
|
||||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||||
import PageContent from 'Components/Page/PageContent';
|
import PageContent from 'Components/Page/PageContent';
|
||||||
import PageContentBody from 'Components/Page/PageContentBody';
|
import PageContentBody from 'Components/Page/PageContentBody';
|
||||||
@@ -11,40 +16,41 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
|||||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||||
import Episode from 'Episode/Episode';
|
import Episode from 'Episode/Episode';
|
||||||
|
import EpisodeFileProvider from 'EpisodeFile/EpisodeFileProvider';
|
||||||
|
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||||
import { align, icons } from 'Helpers/Props';
|
import { align, icons } from 'Helpers/Props';
|
||||||
import NoSeries from 'Series/NoSeries';
|
import NoSeries from 'Series/NoSeries';
|
||||||
import {
|
import { useHasSeries } from 'Series/useSeries';
|
||||||
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 createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
|
|
||||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import Calendar from './Calendar';
|
import Calendar from './Calendar';
|
||||||
import CalendarFilterModal from './CalendarFilterModal';
|
import CalendarFilterModal from './CalendarFilterModal';
|
||||||
import CalendarMissingEpisodeSearchButton from './CalendarMissingEpisodeSearchButton';
|
import CalendarMissingEpisodeSearchButton from './CalendarMissingEpisodeSearchButton';
|
||||||
|
import { setCalendarOption, useCalendarOption } from './calendarOptionsStore';
|
||||||
import CalendarLinkModal from './iCal/CalendarLinkModal';
|
import CalendarLinkModal from './iCal/CalendarLinkModal';
|
||||||
import Legend from './Legend/Legend';
|
import Legend from './Legend/Legend';
|
||||||
import CalendarOptionsModal from './Options/CalendarOptionsModal';
|
import CalendarOptionsModal from './Options/CalendarOptionsModal';
|
||||||
|
import useCalendar, {
|
||||||
|
FILTERS,
|
||||||
|
setCalendarDayCount,
|
||||||
|
useCalendarPage,
|
||||||
|
} from './useCalendar';
|
||||||
import styles from './CalendarPage.css';
|
import styles from './CalendarPage.css';
|
||||||
|
|
||||||
const MINIMUM_DAY_WIDTH = 120;
|
const MINIMUM_DAY_WIDTH = 120;
|
||||||
|
|
||||||
function CalendarPage() {
|
function CalendarPage() {
|
||||||
const dispatch = useDispatch();
|
const executeCommand = useExecuteCommand();
|
||||||
|
|
||||||
const { selectedFilterKey, filters, items } = useSelector(
|
const selectedFilterKey = useCalendarOption('selectedFilterKey');
|
||||||
(state: AppState) => state.calendar
|
const { data } = useCalendar();
|
||||||
);
|
|
||||||
const isRssSyncExecuting = useSelector(
|
useCalendarPage();
|
||||||
createCommandExecutingSelector(commandNames.RSS_SYNC)
|
|
||||||
);
|
const isRssSyncExecuting = useCommandExecuting(CommandNames.RssSync);
|
||||||
const customFilters = useSelector(createCustomFiltersSelector('calendar'));
|
const customFilters = useCustomFiltersList('calendar');
|
||||||
const hasSeries = !!useSelector(createSeriesCountSelector());
|
const hasSeries = useHasSeries();
|
||||||
|
|
||||||
const [pageContentRef, { width }] = useMeasure();
|
const [pageContentRef, { width }] = useMeasure();
|
||||||
const [isCalendarLinkModalOpen, setIsCalendarLinkModalOpen] = useState(false);
|
const [isCalendarLinkModalOpen, setIsCalendarLinkModalOpen] = useState(false);
|
||||||
@@ -70,23 +76,22 @@ function CalendarPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRssSyncPress = useCallback(() => {
|
const handleRssSyncPress = useCallback(() => {
|
||||||
dispatch(
|
executeCommand({
|
||||||
executeCommand({
|
name: CommandNames.RssSync,
|
||||||
name: commandNames.RSS_SYNC,
|
});
|
||||||
})
|
}, [executeCommand]);
|
||||||
);
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const handleFilterSelect = useCallback(
|
const handleFilterSelect = useCallback((key: string | number) => {
|
||||||
(key: string | number) => {
|
setCalendarOption('selectedFilterKey', key);
|
||||||
dispatch(setCalendarFilter({ selectedFilterKey: key }));
|
}, []);
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const episodeIds = useMemo(() => {
|
const episodeIds = useMemo(() => {
|
||||||
return selectUniqueIds<Episode, number>(items, 'id');
|
return selectUniqueIds<Episode, number>(data, 'id');
|
||||||
}, [items]);
|
}, [data]);
|
||||||
|
|
||||||
|
const episodeFileIds = useMemo(() => {
|
||||||
|
return selectUniqueIds<Episode, number>(data, 'episodeFileId');
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (width === 0) {
|
if (width === 0) {
|
||||||
@@ -98,11 +103,14 @@ function CalendarPage() {
|
|||||||
Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH))
|
Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH))
|
||||||
);
|
);
|
||||||
|
|
||||||
dispatch(setCalendarDaysCount({ dayCount }));
|
setCalendarDayCount(dayCount);
|
||||||
}, [width, dispatch]);
|
}, [width]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueueDetails episodeIds={episodeIds}>
|
<CalendarPageProvider
|
||||||
|
episodeIds={episodeIds}
|
||||||
|
episodeFileIds={episodeFileIds}
|
||||||
|
>
|
||||||
<PageContent title={translate('Calendar')}>
|
<PageContent title={translate('Calendar')}>
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
<PageToolbarSection>
|
<PageToolbarSection>
|
||||||
@@ -135,7 +143,7 @@ function CalendarPage() {
|
|||||||
alignMenu={align.RIGHT}
|
alignMenu={align.RIGHT}
|
||||||
isDisabled={!hasSeries}
|
isDisabled={!hasSeries}
|
||||||
selectedFilterKey={selectedFilterKey}
|
selectedFilterKey={selectedFilterKey}
|
||||||
filters={filters}
|
filters={FILTERS}
|
||||||
customFilters={customFilters}
|
customFilters={customFilters}
|
||||||
filterModalConnectorComponent={CalendarFilterModal}
|
filterModalConnectorComponent={CalendarFilterModal}
|
||||||
onFilterSelect={handleFilterSelect}
|
onFilterSelect={handleFilterSelect}
|
||||||
@@ -162,8 +170,22 @@ function CalendarPage() {
|
|||||||
onModalClose={handleOptionsModalClose}
|
onModalClose={handleOptionsModalClose}
|
||||||
/>
|
/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</QueueDetails>
|
</CalendarPageProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CalendarPage;
|
export default CalendarPage;
|
||||||
|
|
||||||
|
function CalendarPageProvider({
|
||||||
|
episodeIds,
|
||||||
|
episodeFileIds,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{ episodeIds: number[]; episodeFileIds: number[] }>) {
|
||||||
|
return (
|
||||||
|
<QueueDetailsProvider episodeIds={episodeIds}>
|
||||||
|
<EpisodeFileProvider episodeFileIds={episodeFileIds}>
|
||||||
|
{children}
|
||||||
|
</EpisodeFileProvider>
|
||||||
|
</QueueDetailsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useCalendarOption } from 'Calendar/calendarOptionsStore';
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import * as calendarViews from 'Calendar/calendarViews';
|
import * as calendarViews from 'Calendar/calendarViews';
|
||||||
import CalendarEvent from 'Calendar/Events/CalendarEvent';
|
import CalendarEvent from 'Calendar/Events/CalendarEvent';
|
||||||
import CalendarEventGroup from 'Calendar/Events/CalendarEventGroup';
|
import CalendarEventGroup from 'Calendar/Events/CalendarEventGroup';
|
||||||
|
import useCalendar, { useCalendarTime } from 'Calendar/useCalendar';
|
||||||
import {
|
import {
|
||||||
CalendarEvent as CalendarEventModel,
|
CalendarEvent as CalendarEventModel,
|
||||||
CalendarEventGroup as CalendarEventGroupModel,
|
CalendarEventGroup as CalendarEventGroupModel,
|
||||||
@@ -28,63 +27,61 @@ function sort(items: (CalendarEventModel | CalendarEventGroupModel)[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCalendarEventsConnector(date: string) {
|
const useCalendarEvents = (date: string) => {
|
||||||
return createSelector(
|
const { data } = useCalendar();
|
||||||
(state: AppState) => state.calendar.items,
|
const collapseMultipleEpisodes = useCalendarOption(
|
||||||
(state: AppState) => state.calendar.options.collapseMultipleEpisodes,
|
'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);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
const momentDate = moment(date);
|
||||||
|
|
||||||
|
const filtered = data.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 {
|
interface CalendarDayProps {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -97,8 +94,9 @@ function CalendarDay({
|
|||||||
isTodaysDate,
|
isTodaysDate,
|
||||||
onEventModalOpenToggle,
|
onEventModalOpenToggle,
|
||||||
}: CalendarDayProps) {
|
}: CalendarDayProps) {
|
||||||
const { time, view } = useSelector((state: AppState) => state.calendar);
|
const view = useCalendarOption('view');
|
||||||
const events = useSelector(createCalendarEventsConnector(date));
|
const time = useCalendarTime();
|
||||||
|
const events = useCalendarEvents(date);
|
||||||
|
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useAppValue } from 'App/appStore';
|
||||||
import AppState from 'App/State/AppState';
|
import { useCalendarOption } from 'Calendar/calendarOptionsStore';
|
||||||
import * as calendarViews from 'Calendar/calendarViews';
|
import * as calendarViews from 'Calendar/calendarViews';
|
||||||
import {
|
import {
|
||||||
gotoCalendarNextRange,
|
goToNextRange,
|
||||||
gotoCalendarPreviousRange,
|
goToPreviousRange,
|
||||||
} from 'Store/Actions/calendarActions';
|
useCalendarDates,
|
||||||
|
} from 'Calendar/useCalendar';
|
||||||
import CalendarDay from './CalendarDay';
|
import CalendarDay from './CalendarDay';
|
||||||
import styles from './CalendarDays.css';
|
import styles from './CalendarDays.css';
|
||||||
|
|
||||||
function CalendarDays() {
|
function CalendarDays() {
|
||||||
const dispatch = useDispatch();
|
const view = useCalendarOption('view');
|
||||||
const { dates, view } = useSelector((state: AppState) => state.calendar);
|
const dates = useCalendarDates();
|
||||||
const isSidebarVisible = useSelector(
|
const isSidebarVisible = useAppValue('isSidebarVisible');
|
||||||
(state: AppState) => state.app.isSidebarVisible
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
|
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const touchStart = useRef<number | null>(null);
|
const touchStart = useRef<number | null>(null);
|
||||||
@@ -58,31 +57,28 @@ function CalendarDays() {
|
|||||||
[isSidebarVisible]
|
[isSidebarVisible]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTouchEnd = useCallback(
|
const handleTouchEnd = useCallback((event: TouchEvent) => {
|
||||||
(event: TouchEvent) => {
|
const touches = event.changedTouches;
|
||||||
const touches = event.changedTouches;
|
const currentTouch = touches[0].pageX;
|
||||||
const currentTouch = touches[0].pageX;
|
|
||||||
|
|
||||||
if (!touchStart.current) {
|
if (!touchStart.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
currentTouch > touchStart.current &&
|
currentTouch > touchStart.current &&
|
||||||
currentTouch - touchStart.current > 100
|
currentTouch - touchStart.current > 100
|
||||||
) {
|
) {
|
||||||
dispatch(gotoCalendarPreviousRange());
|
goToPreviousRange();
|
||||||
} else if (
|
} else if (
|
||||||
currentTouch < touchStart.current &&
|
currentTouch < touchStart.current &&
|
||||||
touchStart.current - currentTouch > 100
|
touchStart.current - currentTouch > 100
|
||||||
) {
|
) {
|
||||||
dispatch(gotoCalendarNextRange());
|
goToNextRange();
|
||||||
}
|
}
|
||||||
|
|
||||||
touchStart.current = null;
|
touchStart.current = null;
|
||||||
},
|
}, []);
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTouchCancel = useCallback(() => {
|
const handleTouchCancel = useCallback(() => {
|
||||||
touchStart.current = null;
|
touchStart.current = null;
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useCalendarOption } from 'Calendar/calendarOptionsStore';
|
||||||
import AppState from 'App/State/AppState';
|
|
||||||
import * as calendarViews from 'Calendar/calendarViews';
|
import * as calendarViews from 'Calendar/calendarViews';
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import { useCalendarDates } from 'Calendar/useCalendar';
|
||||||
|
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
|
||||||
import DayOfWeek from './DayOfWeek';
|
import DayOfWeek from './DayOfWeek';
|
||||||
import styles from './DaysOfWeek.css';
|
import styles from './DaysOfWeek.css';
|
||||||
|
|
||||||
function DaysOfWeek() {
|
function DaysOfWeek() {
|
||||||
const { dates, view } = useSelector((state: AppState) => state.calendar);
|
const view = useCalendarOption('view');
|
||||||
|
const dates = useCalendarDates();
|
||||||
const { calendarWeekColumnHeader, shortDateFormat, showRelativeDates } =
|
const { calendarWeekColumnHeader, shortDateFormat, showRelativeDates } =
|
||||||
useSelector(createUISettingsSelector());
|
useUiSettingsValues();
|
||||||
|
|
||||||
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
|
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const [todaysDate, setTodaysDate] = useState(
|
const [todaysDate, setTodaysDate] = useState(
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import AppState from 'App/State/AppState';
|
import { useCalendarOptions } from 'Calendar/calendarOptionsStore';
|
||||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
import Link from 'Components/Link/Link';
|
import Link from 'Components/Link/Link';
|
||||||
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
||||||
import episodeEntities from 'Episode/episodeEntities';
|
import episodeEntities from 'Episode/episodeEntities';
|
||||||
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
import { useEpisodeFile } from 'EpisodeFile/EpisodeFileProvider';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import useSeries from 'Series/useSeries';
|
import { useSingleSeries } from 'Series/useSeries';
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
|
||||||
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
|
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
|
||||||
import formatTime from 'Utilities/Date/formatTime';
|
import formatTime from 'Utilities/Date/formatTime';
|
||||||
import padNumber from 'Utilities/Number/padNumber';
|
import padNumber from 'Utilities/Number/padNumber';
|
||||||
@@ -56,13 +55,12 @@ function CalendarEvent(props: CalendarEventProps) {
|
|||||||
onEventModalOpenToggle,
|
onEventModalOpenToggle,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const series = useSeries(seriesId);
|
const series = useSingleSeries(seriesId);
|
||||||
const episodeFile = useEpisodeFile(episodeFileId);
|
const episodeFile = useEpisodeFile(episodeFileId);
|
||||||
const queueItem = useQueueItemForEpisode(id);
|
const queueItem = useQueueItemForEpisode(id);
|
||||||
|
|
||||||
const { timeFormat, enableColorImpairedMode, timeZone } = useSelector(
|
const { timeFormat, enableColorImpairedMode, timeZone } =
|
||||||
createUISettingsSelector()
|
useUiSettingsValues();
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showEpisodeInformation,
|
showEpisodeInformation,
|
||||||
@@ -70,7 +68,7 @@ function CalendarEvent(props: CalendarEventProps) {
|
|||||||
showSpecialIcon,
|
showSpecialIcon,
|
||||||
showCutoffUnmetIcon,
|
showCutoffUnmetIcon,
|
||||||
fullColorEvents,
|
fullColorEvents,
|
||||||
} = useSelector((state: AppState) => state.calendar.options);
|
} = useCalendarOptions();
|
||||||
|
|
||||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider';
|
import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||||
import AppState from 'App/State/AppState';
|
import { useCalendarOptions } from 'Calendar/calendarOptionsStore';
|
||||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
import Link from 'Components/Link/Link';
|
import Link from 'Components/Link/Link';
|
||||||
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import useSeries from 'Series/useSeries';
|
import { useSingleSeries } from 'Series/useSeries';
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
|
||||||
import { CalendarItem } from 'typings/Calendar';
|
import { CalendarItem } from 'typings/Calendar';
|
||||||
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
|
import { convertToTimezone } from 'Utilities/Date/convertToTimezone';
|
||||||
import formatTime from 'Utilities/Date/formatTime';
|
import formatTime from 'Utilities/Date/formatTime';
|
||||||
@@ -32,14 +31,13 @@ function CalendarEventGroup({
|
|||||||
onEventModalOpenToggle,
|
onEventModalOpenToggle,
|
||||||
}: CalendarEventGroupProps) {
|
}: CalendarEventGroupProps) {
|
||||||
const isDownloading = useIsDownloadingEpisodes(episodeIds);
|
const isDownloading = useIsDownloadingEpisodes(episodeIds);
|
||||||
const series = useSeries(seriesId)!;
|
const series = useSingleSeries(seriesId)!;
|
||||||
|
|
||||||
const { timeFormat, enableColorImpairedMode, timeZone } = useSelector(
|
const { timeFormat, enableColorImpairedMode, timeZone } =
|
||||||
createUISettingsSelector()
|
useUiSettingsValues();
|
||||||
);
|
|
||||||
|
|
||||||
const { showEpisodeInformation, showFinaleIcon, fullColorEvents } =
|
const { showEpisodeInformation, showFinaleIcon, fullColorEvents } =
|
||||||
useSelector((state: AppState) => state.calendar.options);
|
useCalendarOptions();
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useAppDimensions } from 'App/appStore';
|
||||||
import AppState from 'App/State/AppState';
|
import {
|
||||||
|
setCalendarOption,
|
||||||
|
useCalendarOption,
|
||||||
|
} from 'Calendar/calendarOptionsStore';
|
||||||
|
import { CalendarView } from 'Calendar/calendarViews';
|
||||||
|
import useCalendar, {
|
||||||
|
goToNextRange,
|
||||||
|
goToPreviousRange,
|
||||||
|
goToToday,
|
||||||
|
useCalendarRange,
|
||||||
|
useCalendarTime,
|
||||||
|
} from 'Calendar/useCalendar';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
import Button from 'Components/Link/Button';
|
import Button from 'Components/Link/Button';
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
@@ -10,49 +21,36 @@ import MenuButton from 'Components/Menu/MenuButton';
|
|||||||
import MenuContent from 'Components/Menu/MenuContent';
|
import MenuContent from 'Components/Menu/MenuContent';
|
||||||
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
|
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
|
||||||
import { align, icons } from 'Helpers/Props';
|
import { align, icons } from 'Helpers/Props';
|
||||||
import {
|
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
|
||||||
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 translate from 'Utilities/String/translate';
|
||||||
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
|
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
|
||||||
import styles from './CalendarHeader.css';
|
import styles from './CalendarHeader.css';
|
||||||
|
|
||||||
function CalendarHeader() {
|
function CalendarHeader() {
|
||||||
const dispatch = useDispatch();
|
const { isFetching } = useCalendar();
|
||||||
|
const view = useCalendarOption('view');
|
||||||
|
const time = useCalendarTime();
|
||||||
|
const { start, end } = useCalendarRange();
|
||||||
|
|
||||||
const { isFetching, view, time, start, end } = useSelector(
|
const { isSmallScreen, isLargeScreen } = useAppDimensions();
|
||||||
(state: AppState) => state.calendar
|
|
||||||
);
|
|
||||||
|
|
||||||
const { isSmallScreen, isLargeScreen } = useSelector(
|
const { longDateFormat } = useUiSettingsValues();
|
||||||
createDimensionsSelector()
|
|
||||||
);
|
|
||||||
|
|
||||||
const { longDateFormat } = useSelector(createUISettingsSelector());
|
const handleViewChange = useCallback((newView: string) => {
|
||||||
|
setCalendarOption('view', newView as CalendarView);
|
||||||
const handleViewChange = useCallback(
|
}, []);
|
||||||
(newView: string) => {
|
|
||||||
dispatch(setCalendarView({ view: newView }));
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTodayPress = useCallback(() => {
|
const handleTodayPress = useCallback(() => {
|
||||||
dispatch(gotoCalendarToday());
|
goToToday();
|
||||||
}, [dispatch]);
|
}, []);
|
||||||
|
|
||||||
const handlePreviousPress = useCallback(() => {
|
const handlePreviousPress = useCallback(() => {
|
||||||
dispatch(gotoCalendarPreviousRange());
|
goToPreviousRange();
|
||||||
}, [dispatch]);
|
}, []);
|
||||||
|
|
||||||
const handleNextPress = useCallback(() => {
|
const handleNextPress = useCallback(() => {
|
||||||
dispatch(gotoCalendarNextRange());
|
goToNextRange();
|
||||||
}, [dispatch]);
|
}, []);
|
||||||
|
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
const timeMoment = moment(time);
|
const timeMoment = moment(time);
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import {
|
||||||
import AppState from 'App/State/AppState';
|
useCalendarOption,
|
||||||
|
useCalendarOptions,
|
||||||
|
} from 'Calendar/calendarOptionsStore';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import LegendIconItem from './LegendIconItem';
|
import LegendIconItem from './LegendIconItem';
|
||||||
import LegendItem from './LegendItem';
|
import LegendItem from './LegendItem';
|
||||||
import styles from './Legend.css';
|
import styles from './Legend.css';
|
||||||
|
|
||||||
function Legend() {
|
function Legend() {
|
||||||
const view = useSelector((state: AppState) => state.calendar.view);
|
const view = useCalendarOption('view');
|
||||||
const {
|
const {
|
||||||
showFinaleIcon,
|
showFinaleIcon,
|
||||||
showSpecialIcon,
|
showSpecialIcon,
|
||||||
showCutoffUnmetIcon,
|
showCutoffUnmetIcon,
|
||||||
fullColorEvents,
|
fullColorEvents,
|
||||||
} = useSelector((state: AppState) => state.calendar.options);
|
} = useCalendarOptions();
|
||||||
const { enableColorImpairedMode } = useSelector(createUISettingsSelector());
|
const { enableColorImpairedMode } = useUiSettingsValues();
|
||||||
|
|
||||||
const iconsToShow = [];
|
const iconsToShow = [];
|
||||||
const isAgendaView = view === 'agenda';
|
const isAgendaView = view === 'agenda';
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import {
|
||||||
import AppState from 'App/State/AppState';
|
CalendarOptions,
|
||||||
|
setCalendarOption,
|
||||||
|
useCalendarOptions,
|
||||||
|
} from 'Calendar/calendarOptionsStore';
|
||||||
import FieldSet from 'Components/FieldSet';
|
import FieldSet from 'Components/FieldSet';
|
||||||
import Form from 'Components/Form/Form';
|
import Form from 'Components/Form/Form';
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
@@ -11,17 +14,19 @@ import ModalBody from 'Components/Modal/ModalBody';
|
|||||||
import ModalContent from 'Components/Modal/ModalContent';
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
|
||||||
import { inputTypes } from 'Helpers/Props';
|
import { inputTypes } from 'Helpers/Props';
|
||||||
import {
|
import {
|
||||||
firstDayOfWeekOptions,
|
firstDayOfWeekOptions,
|
||||||
timeFormatOptions,
|
timeFormatOptions,
|
||||||
weekColumnOptions,
|
weekColumnOptions,
|
||||||
} from 'Settings/UI/UISettings';
|
} from 'Settings/UI/UISettings';
|
||||||
import { setCalendarOption } from 'Store/Actions/calendarActions';
|
import {
|
||||||
import { saveUISettings } from 'Store/Actions/settingsActions';
|
UiSettingsModel,
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
useSaveUiSettings,
|
||||||
|
useUiSettingsValues,
|
||||||
|
} from 'Settings/UI/useUiSettings';
|
||||||
import { InputChanged } from 'typings/inputs';
|
import { InputChanged } from 'typings/inputs';
|
||||||
import UiSettings from 'typings/Settings/UiSettings';
|
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
interface CalendarOptionsModalContentProps {
|
interface CalendarOptionsModalContentProps {
|
||||||
@@ -31,8 +36,6 @@ interface CalendarOptionsModalContentProps {
|
|||||||
function CalendarOptionsModalContent({
|
function CalendarOptionsModalContent({
|
||||||
onModalClose,
|
onModalClose,
|
||||||
}: CalendarOptionsModalContentProps) {
|
}: CalendarOptionsModalContentProps) {
|
||||||
const dispatch = useDispatch();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
collapseMultipleEpisodes,
|
collapseMultipleEpisodes,
|
||||||
showEpisodeInformation,
|
showEpisodeInformation,
|
||||||
@@ -40,11 +43,12 @@ function CalendarOptionsModalContent({
|
|||||||
showSpecialIcon,
|
showSpecialIcon,
|
||||||
showCutoffUnmetIcon,
|
showCutoffUnmetIcon,
|
||||||
fullColorEvents,
|
fullColorEvents,
|
||||||
} = useSelector((state: AppState) => state.calendar.options);
|
} = useCalendarOptions();
|
||||||
|
|
||||||
const uiSettings = useSelector(createUISettingsSelector());
|
const uiSettings = useUiSettingsValues();
|
||||||
|
const saveUiSettings = useSaveUiSettings();
|
||||||
|
|
||||||
const [state, setState] = useState<Partial<UiSettings>>({
|
const [state, setState] = useState<Partial<UiSettingsModel>>({
|
||||||
firstDayOfWeek: uiSettings.firstDayOfWeek,
|
firstDayOfWeek: uiSettings.firstDayOfWeek,
|
||||||
calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
|
calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
|
||||||
timeFormat: uiSettings.timeFormat,
|
timeFormat: uiSettings.timeFormat,
|
||||||
@@ -59,19 +63,19 @@ function CalendarOptionsModalContent({
|
|||||||
} = state;
|
} = state;
|
||||||
|
|
||||||
const handleOptionInputChange = useCallback(
|
const handleOptionInputChange = useCallback(
|
||||||
({ name, value }: InputChanged) => {
|
({ name, value }: OptionChanged<CalendarOptions>) => {
|
||||||
dispatch(setCalendarOption({ [name]: value }));
|
setCalendarOption(name, value);
|
||||||
},
|
},
|
||||||
[dispatch]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGlobalInputChange = useCallback(
|
const handleGlobalInputChange = useCallback(
|
||||||
({ name, value }: InputChanged) => {
|
({ name, value }: InputChanged) => {
|
||||||
setState((prevState) => ({ ...prevState, [name]: value }));
|
setState((prevState) => ({ ...prevState, [name]: value }));
|
||||||
|
|
||||||
dispatch(saveUISettings({ [name]: value }));
|
saveUiSettings({ [name]: value });
|
||||||
},
|
},
|
||||||
[dispatch]
|
[saveUiSettings]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,6 +102,7 @@ function CalendarOptionsModalContent({
|
|||||||
name="collapseMultipleEpisodes"
|
name="collapseMultipleEpisodes"
|
||||||
value={collapseMultipleEpisodes}
|
value={collapseMultipleEpisodes}
|
||||||
helpText={translate('CollapseMultipleEpisodesHelpText')}
|
helpText={translate('CollapseMultipleEpisodesHelpText')}
|
||||||
|
// @ts-expect-error - The typing for inputs needs more work
|
||||||
onChange={handleOptionInputChange}
|
onChange={handleOptionInputChange}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
@@ -110,6 +115,7 @@ function CalendarOptionsModalContent({
|
|||||||
name="showEpisodeInformation"
|
name="showEpisodeInformation"
|
||||||
value={showEpisodeInformation}
|
value={showEpisodeInformation}
|
||||||
helpText={translate('ShowEpisodeInformationHelpText')}
|
helpText={translate('ShowEpisodeInformationHelpText')}
|
||||||
|
// @ts-expect-error - The typing for inputs needs more work
|
||||||
onChange={handleOptionInputChange}
|
onChange={handleOptionInputChange}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
@@ -122,6 +128,7 @@ function CalendarOptionsModalContent({
|
|||||||
name="showFinaleIcon"
|
name="showFinaleIcon"
|
||||||
value={showFinaleIcon}
|
value={showFinaleIcon}
|
||||||
helpText={translate('IconForFinalesHelpText')}
|
helpText={translate('IconForFinalesHelpText')}
|
||||||
|
// @ts-expect-error - The typing for inputs needs more work
|
||||||
onChange={handleOptionInputChange}
|
onChange={handleOptionInputChange}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
@@ -134,6 +141,7 @@ function CalendarOptionsModalContent({
|
|||||||
name="showSpecialIcon"
|
name="showSpecialIcon"
|
||||||
value={showSpecialIcon}
|
value={showSpecialIcon}
|
||||||
helpText={translate('IconForSpecialsHelpText')}
|
helpText={translate('IconForSpecialsHelpText')}
|
||||||
|
// @ts-expect-error - The typing for inputs needs more work
|
||||||
onChange={handleOptionInputChange}
|
onChange={handleOptionInputChange}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
@@ -146,6 +154,7 @@ function CalendarOptionsModalContent({
|
|||||||
name="showCutoffUnmetIcon"
|
name="showCutoffUnmetIcon"
|
||||||
value={showCutoffUnmetIcon}
|
value={showCutoffUnmetIcon}
|
||||||
helpText={translate('IconForCutoffUnmetHelpText')}
|
helpText={translate('IconForCutoffUnmetHelpText')}
|
||||||
|
// @ts-expect-error - The typing for inputs needs more work
|
||||||
onChange={handleOptionInputChange}
|
onChange={handleOptionInputChange}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
@@ -158,6 +167,7 @@ function CalendarOptionsModalContent({
|
|||||||
name="fullColorEvents"
|
name="fullColorEvents"
|
||||||
value={fullColorEvents}
|
value={fullColorEvents}
|
||||||
helpText={translate('FullColorEventsHelpText')}
|
helpText={translate('FullColorEventsHelpText')}
|
||||||
|
// @ts-expect-error - The typing for inputs needs more work
|
||||||
onChange={handleOptionInputChange}
|
onChange={handleOptionInputChange}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { SelectedFilterKey } from 'Components/Filter/Filter';
|
||||||
|
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
|
||||||
|
import { CalendarView } from './calendarViews';
|
||||||
|
|
||||||
|
export interface CalendarOptions {
|
||||||
|
collapseMultipleEpisodes: boolean;
|
||||||
|
showEpisodeInformation: boolean;
|
||||||
|
showFinaleIcon: boolean;
|
||||||
|
showSpecialIcon: boolean;
|
||||||
|
showCutoffUnmetIcon: boolean;
|
||||||
|
fullColorEvents: boolean;
|
||||||
|
selectedFilterKey: SelectedFilterKey;
|
||||||
|
view: CalendarView;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { useOptions, useOption, getOptions, getOption, setOptions, setOption } =
|
||||||
|
createOptionsStore<CalendarOptions>('calendar_options', () => {
|
||||||
|
return {
|
||||||
|
collapseMultipleEpisodes: false,
|
||||||
|
showEpisodeInformation: true,
|
||||||
|
showFinaleIcon: false,
|
||||||
|
showSpecialIcon: false,
|
||||||
|
showCutoffUnmetIcon: false,
|
||||||
|
fullColorEvents: false,
|
||||||
|
selectedFilterKey: 'monitored',
|
||||||
|
view: window.innerWidth > 768 ? 'week' : 'day',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useCalendarOptions = useOptions;
|
||||||
|
export const getCalendarOptions = getOptions;
|
||||||
|
export const setCalendarOptions = setOptions;
|
||||||
|
export const useCalendarOption = useOption;
|
||||||
|
export const getCalendarOption = getOption;
|
||||||
|
export const setCalendarOption = setOption;
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { keepPreviousData } from '@tanstack/react-query';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { setEpisodeQueryKey } from 'Episode/useEpisode';
|
||||||
|
import { Filter, FilterBuilderProp } from 'Filters/Filter';
|
||||||
|
import { useCustomFiltersList } from 'Filters/useCustomFilters';
|
||||||
|
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||||
|
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||||
|
import { useUiSettingsValues } from 'Settings/UI/useUiSettings';
|
||||||
|
import { CalendarItem } from 'typings/Calendar';
|
||||||
|
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import { getCalendarOption, useCalendarOption } from './calendarOptionsStore';
|
||||||
|
import { CalendarView } from './calendarViews';
|
||||||
|
|
||||||
|
export const FILTERS: Filter[] = [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
label: () => translate('All'),
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'monitored',
|
||||||
|
label: () => translate('MonitoredOnly'),
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: 'unmonitored',
|
||||||
|
value: [false],
|
||||||
|
type: 'equal',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FILTER_BUILDER: FilterBuilderProp<CalendarItem>[] = [
|
||||||
|
{
|
||||||
|
name: 'unmonitored',
|
||||||
|
label: () => translate('IncludeUnmonitored'),
|
||||||
|
type: 'equal',
|
||||||
|
valueType: filterBuilderValueTypes.BOOL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'includeSpecials',
|
||||||
|
label: () => translate('IncludeSpecials'),
|
||||||
|
type: 'equal',
|
||||||
|
valueType: filterBuilderValueTypes.BOOL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tags',
|
||||||
|
label: () => translate('Tags'),
|
||||||
|
type: 'contains',
|
||||||
|
valueType: filterBuilderValueTypes.TAG,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CalendarStore {
|
||||||
|
time: moment.Moment;
|
||||||
|
dates: string[];
|
||||||
|
dayCount: number;
|
||||||
|
searchMissingCommandId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarStore = create<CalendarStore>(() => ({
|
||||||
|
time: moment(),
|
||||||
|
dates: [],
|
||||||
|
dayCount: 7,
|
||||||
|
queryKey: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const VIEW_RANGES: Record<
|
||||||
|
CalendarView,
|
||||||
|
moment.unitOfTime.DurationConstructor | undefined
|
||||||
|
> = {
|
||||||
|
agenda: undefined,
|
||||||
|
day: 'day',
|
||||||
|
week: 'week',
|
||||||
|
month: 'month',
|
||||||
|
forecast: 'day',
|
||||||
|
};
|
||||||
|
|
||||||
|
const useCalendar = () => {
|
||||||
|
const dates = useCalendarDates();
|
||||||
|
const time = useCalendarTime();
|
||||||
|
const selectedFilterKey = useCalendarOption('selectedFilterKey');
|
||||||
|
const view = useCalendarOption('view');
|
||||||
|
const customFilters = useCustomFiltersList('calendar');
|
||||||
|
|
||||||
|
const { start, end } = useMemo(() => {
|
||||||
|
return getPopulatableRange(dates[0], dates[dates.length - 1], view);
|
||||||
|
}, [dates, view]);
|
||||||
|
|
||||||
|
const { includeUnmonitored, includeSpecials, tags } = useMemo(() => {
|
||||||
|
const selectedFilters = findSelectedFilters(
|
||||||
|
selectedFilterKey,
|
||||||
|
FILTERS,
|
||||||
|
customFilters
|
||||||
|
);
|
||||||
|
|
||||||
|
return selectedFilters.reduce<{
|
||||||
|
includeUnmonitored: boolean;
|
||||||
|
includeSpecials: boolean;
|
||||||
|
tags?: number[] | undefined;
|
||||||
|
}>(
|
||||||
|
(acc, filter) => {
|
||||||
|
if (filter.key === 'unmonitored' && Array.isArray(filter.value)) {
|
||||||
|
acc.includeUnmonitored = (filter.value as boolean[]).includes(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.key === 'includeSpecials' && Array.isArray(filter.value)) {
|
||||||
|
acc.includeSpecials = (filter.value as boolean[]).includes(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.key === 'tags' && filter.type === 'contains') {
|
||||||
|
acc.tags = filter.value as number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
includeUnmonitored: false,
|
||||||
|
includeSpecials: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [customFilters, selectedFilterKey]);
|
||||||
|
|
||||||
|
const { queryKey, ...result } = useApiQuery<CalendarItem[]>({
|
||||||
|
path: '/calendar',
|
||||||
|
queryParams: {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
includeUnmonitored,
|
||||||
|
includeSpecials,
|
||||||
|
tags,
|
||||||
|
},
|
||||||
|
queryOptions: {
|
||||||
|
enabled: !!time && !!start && !!end,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEpisodeQueryKey('calendar', queryKey);
|
||||||
|
}, [queryKey]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data ?? [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCalendar;
|
||||||
|
|
||||||
|
export const useCalendarPage = () => {
|
||||||
|
const dayCount = useCalendarDayCount();
|
||||||
|
const time = useCalendarTime();
|
||||||
|
const view = useCalendarOption('view');
|
||||||
|
const { firstDayOfWeek } = useUiSettingsValues();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { dates } = getDates(time, view, firstDayOfWeek, dayCount);
|
||||||
|
|
||||||
|
calendarStore.setState({ dates });
|
||||||
|
}, [firstDayOfWeek, dayCount, time, view]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCalendarTime = () => {
|
||||||
|
return calendarStore((state) => state.time);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCalendarDates = () => {
|
||||||
|
return calendarStore((state) => state.dates);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCalendarDayCount = () => {
|
||||||
|
return calendarStore((state) => state.dayCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCalendarRange = () => {
|
||||||
|
const dates = useCalendarDates();
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: dates[0],
|
||||||
|
end: dates[dates.length - 1],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCalendarSearchMissingCommandId = () => {
|
||||||
|
return calendarStore((state) => state.searchMissingCommandId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setCalendarDayCount = (dayCount: number) => {
|
||||||
|
calendarStore.setState({ dayCount });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const goToToday = () => {
|
||||||
|
setCalendarTime(moment());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const goToPreviousRange = () => {
|
||||||
|
const { dayCount, time } = calendarStore.getState();
|
||||||
|
const view = getCalendarOption('view');
|
||||||
|
|
||||||
|
const amount = view === 'forecast' ? dayCount : 1;
|
||||||
|
const newTime = moment(time).subtract(amount, VIEW_RANGES[view]);
|
||||||
|
|
||||||
|
setCalendarTime(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const goToNextRange = () => {
|
||||||
|
const { dayCount, time } = calendarStore.getState();
|
||||||
|
const view = getCalendarOption('view');
|
||||||
|
|
||||||
|
const amount = view === 'forecast' ? dayCount : 1;
|
||||||
|
const newTime = moment(time).add(amount, VIEW_RANGES[view]);
|
||||||
|
|
||||||
|
setCalendarTime(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCalendarTime = (time: moment.Moment) => {
|
||||||
|
calendarStore.setState({ time });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDays = (start: moment.Moment, end: moment.Moment) => {
|
||||||
|
const startTime = moment(start);
|
||||||
|
const endTime = moment(end);
|
||||||
|
const difference = endTime.diff(startTime, 'days');
|
||||||
|
|
||||||
|
return Array(difference + 1)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, i) => startTime.clone().add(i, 'days').toISOString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDates = (
|
||||||
|
time: moment.Moment,
|
||||||
|
view: CalendarView,
|
||||||
|
firstDayOfWeek: number,
|
||||||
|
dayCount: number
|
||||||
|
) => {
|
||||||
|
const weekName = firstDayOfWeek === 0 ? 'week' : 'isoWeek';
|
||||||
|
|
||||||
|
let start = time.clone().startOf('day');
|
||||||
|
let end = time.clone().endOf('day');
|
||||||
|
|
||||||
|
if (view === 'week') {
|
||||||
|
start = time.clone().startOf(weekName);
|
||||||
|
end = time.clone().endOf(weekName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === 'forecast') {
|
||||||
|
start = time.clone().subtract(1, 'day').startOf('day');
|
||||||
|
end = time
|
||||||
|
.clone()
|
||||||
|
.add(dayCount - 2, 'days')
|
||||||
|
.endOf('day');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === 'month') {
|
||||||
|
start = time.clone().startOf('month').startOf(weekName);
|
||||||
|
end = time.clone().endOf('month').endOf(weekName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === 'agenda') {
|
||||||
|
start = time.clone().subtract(1, 'day').startOf('day');
|
||||||
|
end = time.clone().add(1, 'month').endOf('day');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: start.toISOString(),
|
||||||
|
end: end.toISOString(),
|
||||||
|
time: time.toISOString(),
|
||||||
|
dates: getDays(start, end),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPopulatableRange(
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
view: CalendarView
|
||||||
|
) {
|
||||||
|
switch (view) {
|
||||||
|
case 'day':
|
||||||
|
return {
|
||||||
|
start: moment(startDate).subtract(1, 'day').toISOString(),
|
||||||
|
end: moment(endDate).add(1, 'day').toISOString(),
|
||||||
|
};
|
||||||
|
case 'week':
|
||||||
|
case 'forecast':
|
||||||
|
return {
|
||||||
|
start: moment(startDate).subtract(1, 'week').toISOString(),
|
||||||
|
end: moment(endDate).add(1, 'week').toISOString(),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
start: startDate,
|
||||||
|
end: endDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import ModelBase from 'App/ModelBase';
|
import ModelBase from 'App/ModelBase';
|
||||||
|
import { InteractiveImportCommandOptions } from 'InteractiveImport/InteractiveImport';
|
||||||
|
|
||||||
export type CommandStatus =
|
export type CommandStatus =
|
||||||
| 'queued'
|
| 'queued'
|
||||||
@@ -10,8 +11,10 @@ export type CommandStatus =
|
|||||||
| 'orphaned';
|
| 'orphaned';
|
||||||
|
|
||||||
export type CommandResult = 'unknown' | 'successful' | 'unsuccessful';
|
export type CommandResult = 'unknown' | 'successful' | 'unsuccessful';
|
||||||
|
export type CommandPriority = 'low' | 'normal' | 'high';
|
||||||
|
|
||||||
export interface CommandBody {
|
// Base command body with common properties
|
||||||
|
export interface BaseCommandBody {
|
||||||
sendUpdatesToClient: boolean;
|
sendUpdatesToClient: boolean;
|
||||||
updateScheduledTask: boolean;
|
updateScheduledTask: boolean;
|
||||||
completionMessage: string;
|
completionMessage: string;
|
||||||
@@ -23,19 +26,109 @@ export interface CommandBody {
|
|||||||
lastStartTime: string;
|
lastStartTime: string;
|
||||||
trigger: string;
|
trigger: string;
|
||||||
suppressMessages: boolean;
|
suppressMessages: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific command body interfaces
|
||||||
|
export interface SeriesCommandBody extends BaseCommandBody {
|
||||||
|
seriesId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultipleSeriesCommandBody extends BaseCommandBody {
|
||||||
|
seriesIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeasonCommandBody extends BaseCommandBody {
|
||||||
|
seriesId: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EpisodeCommandBody extends BaseCommandBody {
|
||||||
|
episodeIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeriesEpisodeCommandBody extends BaseCommandBody {
|
||||||
|
seriesId: number;
|
||||||
|
episodeIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenameFilesCommandBody extends BaseCommandBody {
|
||||||
|
seriesId: number;
|
||||||
|
files: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveSeriesCommandBody extends BaseCommandBody {
|
||||||
|
seriesId: number;
|
||||||
|
destinationPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualImportCommandBody extends BaseCommandBody {
|
||||||
|
files: Array<{
|
||||||
|
path: string;
|
||||||
|
seriesId: number;
|
||||||
|
episodeIds: number[];
|
||||||
|
quality: Record<string, unknown>;
|
||||||
|
language: Record<string, unknown>;
|
||||||
|
releaseGroup?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandBody =
|
||||||
|
| SeriesCommandBody
|
||||||
|
| MultipleSeriesCommandBody
|
||||||
|
| SeasonCommandBody
|
||||||
|
| EpisodeCommandBody
|
||||||
|
| SeriesEpisodeCommandBody
|
||||||
|
| RenameFilesCommandBody
|
||||||
|
| MoveSeriesCommandBody
|
||||||
|
| ManualImportCommandBody
|
||||||
|
| BaseCommandBody;
|
||||||
|
|
||||||
|
// Simplified interface for creating new commands
|
||||||
|
export interface NewCommandBody {
|
||||||
|
name: string;
|
||||||
|
priority?: CommandPriority;
|
||||||
seriesId?: number;
|
seriesId?: number;
|
||||||
seriesIds?: number[];
|
seriesIds?: number[];
|
||||||
seasonNumber?: number;
|
seasonNumber?: number;
|
||||||
episodeIds?: number[];
|
episodeIds?: number[];
|
||||||
[key: string]: string | number | boolean | number[] | undefined;
|
files?: number[] | InteractiveImportCommandOptions[];
|
||||||
|
destinationPath?: string;
|
||||||
|
[key: string]: string | number | boolean | number[] | object | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommandBodyMap {
|
||||||
|
RefreshSeries: SeriesCommandBody | MultipleSeriesCommandBody;
|
||||||
|
SeriesSearch: SeriesCommandBody;
|
||||||
|
SeasonSearch: SeasonCommandBody;
|
||||||
|
EpisodeSearch: EpisodeCommandBody | SeriesEpisodeCommandBody;
|
||||||
|
MissingEpisodeSearch: BaseCommandBody;
|
||||||
|
CutoffUnmetEpisodeSearch: BaseCommandBody;
|
||||||
|
RenameFiles: RenameFilesCommandBody;
|
||||||
|
RenameSeries: MultipleSeriesCommandBody;
|
||||||
|
MoveSeries: MoveSeriesCommandBody;
|
||||||
|
ManualImport: ManualImportCommandBody;
|
||||||
|
DownloadedEpisodesScan: SeriesCommandBody | BaseCommandBody;
|
||||||
|
RssSync: BaseCommandBody;
|
||||||
|
ApplicationUpdate: BaseCommandBody;
|
||||||
|
Backup: BaseCommandBody;
|
||||||
|
ClearBlocklist: BaseCommandBody;
|
||||||
|
ClearLog: BaseCommandBody;
|
||||||
|
DeleteLogFiles: BaseCommandBody;
|
||||||
|
DeleteUpdateLogFiles: BaseCommandBody;
|
||||||
|
RefreshMonitoredDownloads: BaseCommandBody;
|
||||||
|
ResetApiKey: BaseCommandBody;
|
||||||
|
ResetQualityDefinitions: BaseCommandBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandBodyForName<T extends keyof CommandBodyMap> =
|
||||||
|
CommandBodyMap[T];
|
||||||
|
|
||||||
interface Command extends ModelBase {
|
interface Command extends ModelBase {
|
||||||
name: string;
|
name: string;
|
||||||
commandName: string;
|
commandName: string;
|
||||||
message: string;
|
message: string;
|
||||||
body: CommandBody;
|
body: CommandBody;
|
||||||
priority: string;
|
priority: CommandPriority;
|
||||||
status: CommandStatus;
|
status: CommandStatus;
|
||||||
result: CommandResult;
|
result: CommandResult;
|
||||||
queued: string;
|
queued: string;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
enum CommandNames {
|
||||||
|
ApplicationUpdate = 'ApplicationUpdate',
|
||||||
|
Backup = 'Backup',
|
||||||
|
ClearBlocklist = 'ClearBlocklist',
|
||||||
|
ClearLog = 'ClearLog',
|
||||||
|
CutoffUnmetEpisodeSearch = 'CutoffUnmetEpisodeSearch',
|
||||||
|
DeleteLogFiles = 'DeleteLogFiles',
|
||||||
|
DeleteSeriesFiles = 'DeleteSeriesFiles',
|
||||||
|
DeleteUpdateLogFiles = 'DeleteUpdateLogFiles',
|
||||||
|
DownloadedEpisodesScan = 'DownloadedEpisodesScan',
|
||||||
|
EpisodeSearch = 'EpisodeSearch',
|
||||||
|
ManualImport = 'ManualImport',
|
||||||
|
MissingEpisodeSearch = 'MissingEpisodeSearch',
|
||||||
|
MoveSeries = 'MoveSeries',
|
||||||
|
RefreshMonitoredDownloads = 'RefreshMonitoredDownloads',
|
||||||
|
RefreshSeries = 'RefreshSeries',
|
||||||
|
RenameFiles = 'RenameFiles',
|
||||||
|
RenameSeries = 'RenameSeries',
|
||||||
|
ResetApiKey = 'ResetApiKey',
|
||||||
|
ResetQualityDefinitions = 'ResetQualityDefinitions',
|
||||||
|
RssSync = 'RssSync',
|
||||||
|
SeasonSearch = 'SeasonSearch',
|
||||||
|
SeriesSearch = 'SeriesSearch',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommandNames;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user