mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-03-10 15:10:09 -04:00
Compare commits
326 Commits
v5-build
...
sidebar-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9484904f60 | ||
|
|
a4f210855e | ||
|
|
bc4ad574fc | ||
|
|
a5ea19ddfb | ||
|
|
858c690543 | ||
|
|
8e169561f2 | ||
|
|
994faa60c6 | ||
|
|
a4a18d6121 | ||
|
|
0407564784 | ||
|
|
0a61e66ef1 | ||
|
|
051451eb2a | ||
|
|
a4e3be721d | ||
|
|
224e74605b | ||
|
|
6c581b7e3c | ||
|
|
6588ba8435 | ||
|
|
ce4c2e4fcc | ||
|
|
b1f77007dc | ||
|
|
8bab0a06dd | ||
|
|
5b135addaa | ||
|
|
4904e85887 | ||
|
|
c4978022eb | ||
|
|
15e9350601 | ||
|
|
2e1289b924 | ||
|
|
7dac00d5aa | ||
|
|
3796c9e30f | ||
|
|
f2f4edad0c | ||
|
|
b0b15c78ff | ||
|
|
64c421c187 | ||
|
|
6440151053 | ||
|
|
cf6b21aef6 | ||
|
|
1610e54650 | ||
|
|
c40fbeed50 | ||
|
|
b57e7e2db0 | ||
|
|
478866b2bb | ||
|
|
ae201f5299 | ||
|
|
642f4f97bc | ||
|
|
37cb978f18 | ||
|
|
7fdc4d6638 | ||
|
|
309b55fe38 | ||
|
|
d6f265c7b5 | ||
|
|
e757dca038 | ||
|
|
9ebe043bd9 | ||
|
|
f055e8a3e5 | ||
|
|
8c697afa67 | ||
|
|
8d68879edd | ||
|
|
e9c82078da | ||
|
|
f0798550af | ||
|
|
d9c7838329 | ||
|
|
b00229e53c | ||
|
|
880628fb68 | ||
|
|
b09c6f0811 | ||
|
|
b376b63c9e | ||
|
|
99feaa34d2 | ||
|
|
d7f82a72c2 | ||
|
|
bd20ebfad7 | ||
|
|
71553ad67b | ||
|
|
41c39f1f28 | ||
|
|
d0066358eb | ||
|
|
6f1d461dad | ||
|
|
6ccab3cfc8 | ||
|
|
5e47cc3baa | ||
|
|
78ca30d1f8 | ||
|
|
f9d0abada3 | ||
|
|
4bdb0408f1 | ||
|
|
40ea6ce4e5 | ||
|
|
ccf33033dc | ||
|
|
996c0e9f50 | ||
|
|
8b7f9daab0 | ||
|
|
dfb6fdfbeb | ||
|
|
29d0073ee6 | ||
|
|
9cf6be32fa | ||
|
|
fee3f8150e | ||
|
|
010bbbd222 | ||
|
|
d3c3a6ebce | ||
|
|
f26344ae75 | ||
|
|
034f731308 | ||
|
|
4b50861a6b | ||
|
|
f977b8ba1b | ||
|
|
8374ebc25b | ||
|
|
71851d038c | ||
|
|
9ffcd141a5 | ||
|
|
a6f50408f2 | ||
|
|
6e43b08dab | ||
|
|
90c4791d5f | ||
|
|
030910babc | ||
|
|
59af86cea4 | ||
|
|
4cb25228b6 | ||
|
|
bf34b43094 | ||
|
|
1cdca8ef3e | ||
|
|
103b1335b9 | ||
|
|
b3d830c475 | ||
|
|
a279240335 | ||
|
|
3eed84c679 | ||
|
|
51c17fd312 | ||
|
|
70c74fc176 | ||
|
|
cfda24536c | ||
|
|
14e324ee30 | ||
|
|
32ba06ecd0 | ||
|
|
61807fede0 | ||
|
|
2a1efe5f59 | ||
|
|
0f43f8c9f6 | ||
|
|
a853c537db | ||
|
|
f9dccd6ec7 | ||
|
|
72b3b825eb | ||
|
|
818ae02a7a | ||
|
|
5ba3ff5987 | ||
|
|
e38deb3422 | ||
|
|
f35888e053 | ||
|
|
a50d256264 | ||
|
|
70165bddc8 | ||
|
|
4258e94e90 | ||
|
|
066b39032b | ||
|
|
728df146ad | ||
|
|
751a07bb40 | ||
|
|
d4ce60bd41 | ||
|
|
4b868d3f06 | ||
|
|
817d13e85c | ||
|
|
fae014c8be | ||
|
|
2fa02472ee | ||
|
|
7c3c577811 | ||
|
|
9fdf545f47 | ||
|
|
e537a2dc8f | ||
|
|
1047e71b7d | ||
|
|
415498efb3 | ||
|
|
cf08e947c4 | ||
|
|
bb872ee35b | ||
|
|
ab0d8352e8 | ||
|
|
9683b0af35 | ||
|
|
76b1130b68 | ||
|
|
5be58249f8 | ||
|
|
4d67b8ae2b | ||
|
|
66633b9b07 | ||
|
|
4728fa29ef | ||
|
|
9cb9c711be | ||
|
|
d62eea604a | ||
|
|
3185315343 | ||
|
|
e52b68ee7d | ||
|
|
f7eece32e7 | ||
|
|
c96c47af9e | ||
|
|
a5999b1410 | ||
|
|
ac1bb497ef | ||
|
|
9bd619ccfe | ||
|
|
dfbf12b711 | ||
|
|
0ae07898ba | ||
|
|
2314d0b506 | ||
|
|
2093f08a57 | ||
|
|
0a7ffb64f0 | ||
|
|
41b65abd1d | ||
|
|
0f904e0917 | ||
|
|
f8e57b0985 | ||
|
|
9e774f4026 | ||
|
|
2acc4c8865 | ||
|
|
0fcd92e441 | ||
|
|
b103005aa2 | ||
|
|
41b5118938 | ||
|
|
c84699ed5d | ||
|
|
bdd975da0f | ||
|
|
08d1bcb351 | ||
|
|
5fb632eb46 | ||
|
|
da29de4cfe | ||
|
|
83b2c9e97a | ||
|
|
095126bfe8 | ||
|
|
6aee9c7fd5 | ||
|
|
20c2d59e9a | ||
|
|
7ee90fb05d | ||
|
|
1a8ba51260 | ||
|
|
2e66cd2a1e | ||
|
|
6115236d38 | ||
|
|
7ff8c9e18d | ||
|
|
f0e320f3aa | ||
|
|
9f29b06ca4 | ||
|
|
08c0c5aa30 | ||
|
|
64956d7be7 | ||
|
|
b598795262 | ||
|
|
9756a3df38 | ||
|
|
94f64435f5 | ||
|
|
a324052deb | ||
|
|
e08c9d5501 | ||
|
|
1449941471 | ||
|
|
28c4f0cef2 | ||
|
|
e199710c15 | ||
|
|
e3a048790d | ||
|
|
6dc47755ec | ||
|
|
01f7783519 | ||
|
|
e9f59188b1 | ||
|
|
a6e6b7518d | ||
|
|
38cd63ec04 | ||
|
|
71f1593fd9 | ||
|
|
3d951f6db8 | ||
|
|
0f16837b59 | ||
|
|
f0d0eb9a7a | ||
|
|
608fc29086 | ||
|
|
8acd154206 | ||
|
|
6f451b2206 | ||
|
|
3cb6f866cc | ||
|
|
649ed04f8a | ||
|
|
fef00dccf8 | ||
|
|
cbc5127a14 | ||
|
|
38746fc95b | ||
|
|
24ce10006e | ||
|
|
c034282f45 | ||
|
|
b2214fd912 | ||
|
|
4db4388236 | ||
|
|
093ee5b88d | ||
|
|
f58dfc5605 | ||
|
|
1c9a0232ad | ||
|
|
c86822b114 | ||
|
|
5342416659 | ||
|
|
5d7c94f8e9 | ||
|
|
6d8c3f15b3 | ||
|
|
efef9f88ff | ||
|
|
8748cdb1bf | ||
|
|
7a9b6d3262 | ||
|
|
c902735927 | ||
|
|
156d306334 | ||
|
|
d09e7893b3 | ||
|
|
3e2e8e9388 | ||
|
|
99fc61e636 | ||
|
|
271979d637 | ||
|
|
b572a6f759 | ||
|
|
950330b091 | ||
|
|
8940ef8b81 | ||
|
|
91bdf06214 | ||
|
|
5678f98344 | ||
|
|
05d57aa913 | ||
|
|
b22d598ebf | ||
|
|
e22dd15443 | ||
|
|
1fa532dd3e | ||
|
|
350dea10dd | ||
|
|
d3777dd43c | ||
|
|
a72288a14e | ||
|
|
e506eb6d03 | ||
|
|
591b569bdd | ||
|
|
094df71301 | ||
|
|
31e02bdead | ||
|
|
b122ee9670 | ||
|
|
0f9e063e21 | ||
|
|
609e964794 | ||
|
|
5cebec6ae4 | ||
|
|
33da537a63 | ||
|
|
11c945c2dc | ||
|
|
1b0a20535c | ||
|
|
1734fcaa8f | ||
|
|
b99e06acc0 | ||
|
|
5d16169e52 | ||
|
|
a2670a5804 | ||
|
|
9e5ebdc624 | ||
|
|
e62aa5e041 | ||
|
|
80a8176c58 | ||
|
|
39f1c669b8 | ||
|
|
5208f5e966 | ||
|
|
bb0ad312f1 | ||
|
|
413d6b996b | ||
|
|
79474f26e9 | ||
|
|
18bbb8bbd1 | ||
|
|
374c6d13f6 | ||
|
|
92f0aa4e8f | ||
|
|
7345c06003 | ||
|
|
ff08b914f3 | ||
|
|
e40c8f3e3e | ||
|
|
2fd1fea4cb | ||
|
|
a72bc164a9 | ||
|
|
d9a86bcb31 | ||
|
|
553c4aeae1 | ||
|
|
5a47f34ef9 | ||
|
|
15b070119d | ||
|
|
7b133bd80d | ||
|
|
ce6536f8ab | ||
|
|
c6eb6c3cd8 | ||
|
|
f4f3fdfb0b | ||
|
|
6b92627004 | ||
|
|
c3f9cd12af | ||
|
|
6f871a1bfb | ||
|
|
ab1f8bdbd9 | ||
|
|
b72b1d5e5c | ||
|
|
87c840974b | ||
|
|
ee46e6378a | ||
|
|
7644cec376 | ||
|
|
d3153685ac | ||
|
|
7035bb2944 | ||
|
|
6dc16f3ddd | ||
|
|
0e1474579a | ||
|
|
7b4bd50f18 | ||
|
|
4849d1da10 | ||
|
|
8482f3da1a | ||
|
|
782af002d6 | ||
|
|
8f6d9f3bf4 | ||
|
|
699120a8fd | ||
|
|
0fdeb05663 | ||
|
|
7c64911b6b | ||
|
|
3035521b93 | ||
|
|
45c53bea86 | ||
|
|
4c6d6b726e | ||
|
|
1765feac03 | ||
|
|
92db4769be | ||
|
|
6838f068bc | ||
|
|
b218461678 | ||
|
|
10e3a237ef | ||
|
|
6e008a8e85 | ||
|
|
27f81117ed | ||
|
|
839658a698 | ||
|
|
1bc1b080d1 | ||
|
|
572bdc979c | ||
|
|
cde0a31ff0 | ||
|
|
60529f0bac | ||
|
|
5ed7780ed7 | ||
|
|
89c8a10e0d | ||
|
|
fd09ca6e71 | ||
|
|
95929dd9c2 | ||
|
|
23bc6a157c | ||
|
|
756e985b66 | ||
|
|
a2fd23c84d | ||
|
|
32ce09648c | ||
|
|
20e1a8d116 | ||
|
|
12a1ef0387 | ||
|
|
2935d148a8 | ||
|
|
a90c13e86f | ||
|
|
9a7ddd751e | ||
|
|
a1d4bb5399 | ||
|
|
9d0acba000 | ||
|
|
ee1a0a1f71 | ||
|
|
f35a27449d | ||
|
|
4e65669c48 | ||
|
|
fa38498db0 | ||
|
|
3b024443c5 | ||
|
|
4ba9b21bb7 |
4
.github/actions/build/action.yml
vendored
4
.github/actions/build/action.yml
vendored
@@ -21,7 +21,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
|
||||
- name: Setup Environment Variables
|
||||
id: variables
|
||||
@@ -170,7 +170,7 @@ runs:
|
||||
framework="${{ inputs.framework }}"
|
||||
runtime="${{ inputs.runtime }}"
|
||||
|
||||
cp test.sh "_tests/$framework/$runtime/publish"
|
||||
cp scripts/test.sh "_tests/$framework/$runtime/publish"
|
||||
|
||||
rm -f _tests/$framework/$runtime/*.log.config
|
||||
|
||||
|
||||
14
.github/actions/test/action.yml
vendored
14
.github/actions/test/action.yml
vendored
@@ -4,6 +4,8 @@ description: Runs unit/integration tests
|
||||
inputs:
|
||||
use_postgres:
|
||||
description: 'Whether postgres should be used for the database'
|
||||
postgres-version:
|
||||
description: 'Which postgres version should be used for the database'
|
||||
os:
|
||||
description: 'OS that the tests are running on'
|
||||
required: true
|
||||
@@ -27,16 +29,18 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v5
|
||||
|
||||
- name: Setup Postgres
|
||||
if: ${{ inputs.use_postgres }}
|
||||
uses: ikalnytskyi/action-setup-postgres@v4
|
||||
uses: ikalnytskyi/action-setup-postgres@v7
|
||||
with:
|
||||
postgres-version: ${{ inputs.postgres-version }}
|
||||
|
||||
- name: Setup Test Variables
|
||||
shell: bash
|
||||
run: |
|
||||
echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}" >> "$GITHUB_ENV"
|
||||
echo "RESULTS_NAME=${{ inputs.integration_tests && 'integation-' || 'unit-' }}${{ inputs.artifact }}${{ inputs.use_postgres && '-postgres' }}${{ inputs.use_postgres && inputs.postgres-version && inputs.postgres-version }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Postgres Environment Variables
|
||||
if: ${{ inputs.use_postgres }}
|
||||
@@ -48,14 +52,14 @@ runs:
|
||||
echo "Sonarr__Postgres__Password=postgres" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: ${{ inputs.artifact }}
|
||||
path: _tests
|
||||
|
||||
- name: Download Binary Artifact
|
||||
if: ${{ inputs.integration_tests }}
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: ${{ inputs.binary_artifact }}
|
||||
path: _output
|
||||
|
||||
15
.github/workflows/api_docs.yml
vendored
15
.github/workflows/api_docs.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
id: setup-dotnet
|
||||
|
||||
- name: Create openapi.json
|
||||
run: ./docs.sh Linux x64
|
||||
run: ./scripts/docs.sh Linux x64
|
||||
|
||||
- name: Commit API Docs Change
|
||||
continue-on-error: true
|
||||
@@ -50,3 +50,16 @@ jobs:
|
||||
else
|
||||
echo "No changes since last run"
|
||||
fi
|
||||
|
||||
- name: Notify
|
||||
if: failure()
|
||||
uses: tsickert/discord-webhook@v6.0.0
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
username: "GitHub Actions"
|
||||
avatar-url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
|
||||
embed-title: "${{ github.workflow }}: Failure"
|
||||
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
embed-description: |
|
||||
Failed to update API docs
|
||||
embed-color: "15158332"
|
||||
|
||||
15
.github/workflows/build_v5.yml
vendored
15
.github/workflows/build_v5.yml
vendored
@@ -82,7 +82,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Build
|
||||
uses: ./.github/actions/build
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Volta
|
||||
uses: volta-cli/action@v4
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
@@ -152,9 +152,13 @@ jobs:
|
||||
unit_test_postgres:
|
||||
needs: backend
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
postgres-version: [16, 17]
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
@@ -164,6 +168,7 @@ jobs:
|
||||
pattern: Sonarr.*.Test.dll
|
||||
filter: TestCategory!=ManualTest&TestCategory!=WINDOWS&TestCategory!=IntegrationTest&TestCategory!=AutomationTest
|
||||
use_postgres: true
|
||||
postgres-version: ${{ matrix.postgres-version }}
|
||||
|
||||
integration_test:
|
||||
needs: [prepare, backend]
|
||||
@@ -190,7 +195,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Test
|
||||
uses: ./.github/actions/test
|
||||
|
||||
@@ -82,4 +82,4 @@ Thank you to [<img src="https://resources.jetbrains.com/storage/products/company
|
||||
### Licenses
|
||||
|
||||
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
|
||||
- Copyright 2010-2024
|
||||
- Copyright 2010-2025
|
||||
|
||||
113
distribution/debian/install.sh
Normal file → Executable file
113
distribution/debian/install.sh
Normal file → Executable file
@@ -6,6 +6,8 @@
|
||||
### Version V1.0.1 2024-01-02 - StevieTV - remove UTF8-BOM
|
||||
### Version V1.0.2 2024-01-03 - markus101 - Get user input from /dev/tty
|
||||
### Version V1.0.3 2024-01-06 - StevieTV - exit script when it is ran from install directory
|
||||
### Version V1.0.4 2025-04-05 - kaecyra - Allow user/group to be supplied via CLI, add unattended mode
|
||||
### Version V1.0.5 2025-07-08 - bparkin1283 - use systemctl instead of service for stopping app
|
||||
|
||||
### Boilerplate Warning
|
||||
#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
@@ -16,8 +18,8 @@
|
||||
#OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
#WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
scriptversion="1.0.3"
|
||||
scriptdate="2024-01-06"
|
||||
scriptversion="1.0.4"
|
||||
scriptdate="2025-04-05"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -49,18 +51,106 @@ if [ "$installdir" == "$(dirname -- "$( readlink -f -- "$0"; )")" ] || [ "$bindi
|
||||
exit
|
||||
fi
|
||||
|
||||
# Prompt User
|
||||
read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty
|
||||
show_help() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS]
|
||||
|
||||
Options:
|
||||
--user <name> What user will $app run under?
|
||||
User will be created if it doesn't already exist.
|
||||
|
||||
--group <name> What group will $app run under?
|
||||
Group will be created if it doesn't already exist.
|
||||
|
||||
-u Unattended mode
|
||||
The installer will not prompt or pause, making it suitable for automated installations.
|
||||
This option requires the use of --user and --group to supply those inputs for the script.
|
||||
|
||||
-h, --help Show this help message and exit
|
||||
EOF
|
||||
}
|
||||
|
||||
# Default values for command-line arguments
|
||||
arg_user=""
|
||||
arg_group=""
|
||||
arg_unattended=false
|
||||
|
||||
# Parse command-line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user=*)
|
||||
arg_user="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--user)
|
||||
if [[ -n "$2" && "$2" != -* ]]; then
|
||||
arg_user="$2"
|
||||
shift 2
|
||||
else
|
||||
echo "Error: --user requires a value." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--group=*)
|
||||
arg_group="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--group)
|
||||
if [[ -n "$2" && "$2" != -* ]]; then
|
||||
arg_group="$2"
|
||||
shift 2
|
||||
else
|
||||
echo "Error: --group requires a value." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
-u)
|
||||
arg_unattended=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
echo "Use --help to see valid options." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# If unattended mode is requested, require user and group
|
||||
if $arg_unattended; then
|
||||
if [[ -z "$arg_user" || -z "$arg_group" ]]; then
|
||||
echo "Error: --user and --group are required when using -u (unattended mode)." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Prompt User if necessary
|
||||
if [ -n "$arg_user" ]; then
|
||||
app_uid="$arg_user"
|
||||
else
|
||||
read -r -p "What user should ${app^} run as? (Default: $app): " app_uid < /dev/tty
|
||||
fi
|
||||
app_uid=$(echo "$app_uid" | tr -d ' ')
|
||||
app_uid=${app_uid:-$app}
|
||||
# Prompt Group
|
||||
read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty
|
||||
|
||||
# Prompt Group if necessary
|
||||
if [ -n "$arg_group" ]; then
|
||||
app_guid="$arg_group"
|
||||
else
|
||||
read -r -p "What group should ${app^} run as? (Default: media): " app_guid < /dev/tty
|
||||
fi
|
||||
app_guid=$(echo "$app_guid" | tr -d ' ')
|
||||
app_guid=${app_guid:-media}
|
||||
|
||||
echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory"
|
||||
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
|
||||
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
|
||||
if ! $arg_unattended; then
|
||||
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
|
||||
fi
|
||||
|
||||
# Create User / Group as needed
|
||||
if [ "$app_guid" != "$app_uid" ]; then
|
||||
@@ -78,11 +168,10 @@ if ! getent group "$app_guid" | grep -qw "$app_uid"; then
|
||||
echo "Added User [$app_uid] to Group [$app_guid]"
|
||||
fi
|
||||
|
||||
# Stop the App if running
|
||||
if service --status-all | grep -Fq "$app"; then
|
||||
systemctl stop "$app"
|
||||
systemctl disable "$app".service
|
||||
echo "Stopped existing $app"
|
||||
# Stop and disable the App if running
|
||||
if [ $(systemctl is-active "$app") = "active" ]; then
|
||||
systemctl disable --now -q "$app"
|
||||
echo "Stopped and disabled existing $app"
|
||||
fi
|
||||
|
||||
# Create Appdata Directory
|
||||
|
||||
@@ -7,9 +7,9 @@ cd /data/test
|
||||
|
||||
runTest()
|
||||
{
|
||||
bash test.sh Linux $1
|
||||
bash scripts/test.sh Linux $1
|
||||
cp TestResult.xml /data/_tests_results/TestResult_$1.xml
|
||||
}
|
||||
|
||||
runTest Integration
|
||||
runTest Unit
|
||||
runTest Unit
|
||||
|
||||
@@ -65,7 +65,7 @@ module.exports = (env) => {
|
||||
|
||||
output: {
|
||||
path: distFolder,
|
||||
publicPath: '/',
|
||||
publicPath: 'auto',
|
||||
filename: isProduction ? '[name]-[contenthash].js' : '[name].js',
|
||||
sourceMapFilename: '[file].map'
|
||||
},
|
||||
@@ -176,7 +176,7 @@ module.exports = (env) => {
|
||||
loose: true,
|
||||
debug: false,
|
||||
useBuiltIns: 'entry',
|
||||
corejs: '3.39'
|
||||
corejs: '3.42'
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { setQueueOptions } from 'Activity/Queue/queueOptionsStore';
|
||||
import { SelectProvider } from 'App/SelectContext';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
@@ -16,20 +16,8 @@ import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import usePaging from 'Components/Table/usePaging';
|
||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import {
|
||||
clearBlocklist,
|
||||
fetchBlocklist,
|
||||
gotoBlocklistPage,
|
||||
removeBlocklistItems,
|
||||
setBlocklistFilter,
|
||||
setBlocklistSort,
|
||||
setBlocklistTableOption,
|
||||
} from 'Store/Actions/blocklistActions';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
@@ -43,27 +31,35 @@ import {
|
||||
import translate from 'Utilities/String/translate';
|
||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import BlocklistFilterModal from './BlocklistFilterModal';
|
||||
import {
|
||||
setBlocklistOption,
|
||||
useBlocklistOptions,
|
||||
} from './blocklistOptionsStore';
|
||||
import BlocklistRow from './BlocklistRow';
|
||||
import useBlocklist, {
|
||||
useFilters,
|
||||
useRemoveBlocklistItems,
|
||||
} from './useBlocklist';
|
||||
|
||||
function Blocklist() {
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
columns,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
records,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
isRemoving,
|
||||
} = useSelector((state: AppState) => state.blocklist);
|
||||
isFetching,
|
||||
isFetched,
|
||||
isLoading,
|
||||
error,
|
||||
page,
|
||||
goToPage,
|
||||
refetch,
|
||||
} = useBlocklist();
|
||||
|
||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||
useBlocklistOptions();
|
||||
|
||||
const filters = useFilters();
|
||||
const { isRemoving, removeBlocklistItems } = useRemoveBlocklistItems();
|
||||
|
||||
const customFilters = useSelector(createCustomFiltersSelector('blocklist'));
|
||||
const isClearingBlocklistExecuting = useSelector(
|
||||
@@ -82,28 +78,27 @@ function Blocklist() {
|
||||
return getSelectedIds(selectedState);
|
||||
}, [selectedState]);
|
||||
|
||||
const wasClearingBlocklistExecuting = usePrevious(
|
||||
isClearingBlocklistExecuting
|
||||
);
|
||||
|
||||
const handleSelectAllChange = useCallback(
|
||||
({ value }: CheckInputChanged) => {
|
||||
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
|
||||
setSelectState({
|
||||
type: value ? 'selectAll' : 'unselectAll',
|
||||
items: records,
|
||||
});
|
||||
},
|
||||
[items, setSelectState]
|
||||
[records, setSelectState]
|
||||
);
|
||||
|
||||
const handleSelectedChange = useCallback(
|
||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||
setSelectState({
|
||||
type: 'toggleSelected',
|
||||
items,
|
||||
items: records,
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[items, setSelectState]
|
||||
[records, setSelectState]
|
||||
);
|
||||
|
||||
const handleRemoveSelectedPress = useCallback(() => {
|
||||
@@ -111,9 +106,9 @@ function Blocklist() {
|
||||
}, [setIsConfirmRemoveModalOpen]);
|
||||
|
||||
const handleRemoveSelectedConfirmed = useCallback(() => {
|
||||
dispatch(removeBlocklistItems({ ids: selectedIds }));
|
||||
removeBlocklistItems({ ids: selectedIds });
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
}, [selectedIds, setIsConfirmRemoveModalOpen, dispatch]);
|
||||
}, [selectedIds, setIsConfirmRemoveModalOpen, removeBlocklistItems]);
|
||||
|
||||
const handleConfirmRemoveModalClose = useCallback(() => {
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
@@ -124,66 +119,46 @@ function Blocklist() {
|
||||
}, [setIsConfirmClearModalOpen]);
|
||||
|
||||
const handleClearBlocklistConfirmed = useCallback(() => {
|
||||
dispatch(executeCommand({ name: commandNames.CLEAR_BLOCKLIST }));
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: commandNames.CLEAR_BLOCKLIST,
|
||||
commandFinished: () => {
|
||||
goToPage(1);
|
||||
},
|
||||
})
|
||||
);
|
||||
setIsConfirmClearModalOpen(false);
|
||||
}, [setIsConfirmClearModalOpen, dispatch]);
|
||||
}, [setIsConfirmClearModalOpen, goToPage, dispatch]);
|
||||
|
||||
const handleConfirmClearModalClose = useCallback(() => {
|
||||
setIsConfirmClearModalOpen(false);
|
||||
}, [setIsConfirmClearModalOpen]);
|
||||
|
||||
const {
|
||||
handleFirstPagePress,
|
||||
handlePreviousPagePress,
|
||||
handleNextPagePress,
|
||||
handleLastPagePress,
|
||||
handlePageSelect,
|
||||
} = usePaging({
|
||||
page,
|
||||
totalPages,
|
||||
gotoPage: gotoBlocklistPage,
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setBlocklistFilter({ selectedFilterKey }));
|
||||
setBlocklistOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSortPress = useCallback(
|
||||
(sortKey: string) => {
|
||||
dispatch(setBlocklistSort({ sortKey }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
const handleSortPress = useCallback((sortKey: string) => {
|
||||
setBlocklistOption('sortKey', sortKey);
|
||||
}, []);
|
||||
|
||||
const handleTableOptionChange = useCallback(
|
||||
(payload: TableOptionsChangePayload) => {
|
||||
dispatch(setBlocklistTableOption(payload));
|
||||
setQueueOptions(payload);
|
||||
|
||||
if (payload.pageSize) {
|
||||
dispatch(gotoBlocklistPage({ page: 1 }));
|
||||
goToPage(1);
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchBlocklist());
|
||||
} else {
|
||||
dispatch(gotoBlocklistPage({ page: 1 }));
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispatch(clearBlocklist());
|
||||
};
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchBlocklist());
|
||||
refetch();
|
||||
};
|
||||
|
||||
registerPagePopulator(repopulate);
|
||||
@@ -191,16 +166,10 @@ function Blocklist() {
|
||||
return () => {
|
||||
unregisterPagePopulator(repopulate);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasClearingBlocklistExecuting && !isClearingBlocklistExecuting) {
|
||||
dispatch(gotoBlocklistPage({ page: 1 }));
|
||||
}
|
||||
}, [isClearingBlocklistExecuting, wasClearingBlocklistExecuting, dispatch]);
|
||||
}, [refetch]);
|
||||
|
||||
return (
|
||||
<SelectProvider items={items}>
|
||||
<SelectProvider items={records}>
|
||||
<PageContent title={translate('Blocklist')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
@@ -215,7 +184,7 @@ function Blocklist() {
|
||||
<PageToolbarButton
|
||||
label={translate('Clear')}
|
||||
iconName={icons.CLEAR}
|
||||
isDisabled={!items.length}
|
||||
isDisabled={!records.length}
|
||||
isSpinning={isClearingBlocklistExecuting}
|
||||
onPress={handleClearBlocklistPress}
|
||||
/>
|
||||
@@ -245,13 +214,13 @@ function Blocklist() {
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody>
|
||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||
{isLoading && !isFetched ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetching && !!error ? (
|
||||
{!isLoading && !!error ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('BlocklistLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !error && !items.length ? (
|
||||
{isFetched && !error && !records.length ? (
|
||||
<Alert kind={kinds.INFO}>
|
||||
{selectedFilterKey === 'all'
|
||||
? translate('NoBlocklistItems')
|
||||
@@ -259,7 +228,7 @@ function Blocklist() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !error && !!items.length ? (
|
||||
{isFetched && !error && !!records.length ? (
|
||||
<div>
|
||||
<Table
|
||||
selectAll={true}
|
||||
@@ -274,7 +243,7 @@ function Blocklist() {
|
||||
onSortPress={handleSortPress}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
{records.map((item) => {
|
||||
return (
|
||||
<BlocklistRow
|
||||
key={item.id}
|
||||
@@ -292,11 +261,7 @@ function Blocklist() {
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onFirstPagePress={handleFirstPagePress}
|
||||
onPreviousPagePress={handlePreviousPagePress}
|
||||
onNextPagePress={handleNextPagePress}
|
||||
onLastPagePress={handleLastPagePress}
|
||||
onPageSelect={handlePageSelect}
|
||||
onPageSelect={goToPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -16,13 +16,19 @@ interface BlocklistDetailsModalProps {
|
||||
protocol: DownloadProtocol;
|
||||
indexer?: string;
|
||||
message?: string;
|
||||
source?: string;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
|
||||
const { isOpen, sourceTitle, protocol, indexer, message, onModalClose } =
|
||||
props;
|
||||
|
||||
function BlocklistDetailsModal({
|
||||
isOpen,
|
||||
sourceTitle,
|
||||
protocol,
|
||||
indexer,
|
||||
message,
|
||||
source,
|
||||
onModalClose,
|
||||
}: BlocklistDetailsModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
@@ -50,6 +56,9 @@ function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
|
||||
data={message}
|
||||
/>
|
||||
) : null}
|
||||
{source ? (
|
||||
<DescriptionListItem title={translate('Source')} data={source} />
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
</ModalBody>
|
||||
|
||||
|
||||
@@ -1,50 +1,26 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||
import { setBlocklistFilter } from 'Store/Actions/blocklistActions';
|
||||
|
||||
function createBlocklistSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.blocklist.items,
|
||||
(blocklistItems) => {
|
||||
return blocklistItems;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createFilterBuilderPropsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.blocklist.filterBuilderProps,
|
||||
(filterBuilderProps) => {
|
||||
return filterBuilderProps;
|
||||
}
|
||||
);
|
||||
}
|
||||
import { setBlocklistOption } from './blocklistOptionsStore';
|
||||
import useBlocklist, { FILTER_BUILDER } from './useBlocklist';
|
||||
|
||||
type BlocklistFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function BlocklistFilterModal(props: BlocklistFilterModalProps) {
|
||||
const sectionItems = useSelector(createBlocklistSelector());
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
const customFilterType = 'blocklist';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { records } = useBlocklist();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
(payload: unknown) => {
|
||||
dispatch(setBlocklistFilter(payload));
|
||||
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
||||
setBlocklistOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
customFilterType={customFilterType}
|
||||
sectionItems={records}
|
||||
filterBuilderProps={FILTER_BUILDER}
|
||||
customFilterType="blocklist"
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
@@ -12,11 +11,11 @@ import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { removeBlocklistItem } from 'Store/Actions/blocklistActions';
|
||||
import Blocklist from 'typings/Blocklist';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import BlocklistDetailsModal from './BlocklistDetailsModal';
|
||||
import { useRemoveBlocklistItem } from './useBlocklist';
|
||||
import styles from './BlocklistRow.css';
|
||||
|
||||
interface BlocklistRowProps extends Blocklist {
|
||||
@@ -25,25 +24,24 @@ interface BlocklistRowProps extends Blocklist {
|
||||
onSelectedChange: (options: SelectStateInputProps) => void;
|
||||
}
|
||||
|
||||
function BlocklistRow(props: BlocklistRowProps) {
|
||||
const {
|
||||
id,
|
||||
seriesId,
|
||||
sourceTitle,
|
||||
languages,
|
||||
quality,
|
||||
customFormats,
|
||||
date,
|
||||
protocol,
|
||||
indexer,
|
||||
message,
|
||||
isSelected,
|
||||
columns,
|
||||
onSelectedChange,
|
||||
} = props;
|
||||
|
||||
function BlocklistRow({
|
||||
id,
|
||||
seriesId,
|
||||
sourceTitle,
|
||||
languages,
|
||||
quality,
|
||||
customFormats,
|
||||
date,
|
||||
protocol,
|
||||
indexer,
|
||||
message,
|
||||
source,
|
||||
isSelected,
|
||||
columns,
|
||||
onSelectedChange,
|
||||
}: BlocklistRowProps) {
|
||||
const series = useSeries(seriesId);
|
||||
const dispatch = useDispatch();
|
||||
const { isRemoving, removeBlocklistItem } = useRemoveBlocklistItem(id);
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
|
||||
const handleDetailsPress = useCallback(() => {
|
||||
@@ -55,8 +53,8 @@ function BlocklistRow(props: BlocklistRowProps) {
|
||||
}, [setIsDetailsModalOpen]);
|
||||
|
||||
const handleRemovePress = useCallback(() => {
|
||||
dispatch(removeBlocklistItem({ id }));
|
||||
}, [id, dispatch]);
|
||||
removeBlocklistItem();
|
||||
}, [removeBlocklistItem]);
|
||||
|
||||
if (!series) {
|
||||
return null;
|
||||
@@ -139,6 +137,7 @@ function BlocklistRow(props: BlocklistRowProps) {
|
||||
title={translate('RemoveFromBlocklist')}
|
||||
name={icons.REMOVE}
|
||||
kind={kinds.DANGER}
|
||||
isSpinning={isRemoving}
|
||||
onPress={handleRemovePress}
|
||||
/>
|
||||
</TableRowCell>
|
||||
@@ -154,6 +153,7 @@ function BlocklistRow(props: BlocklistRowProps) {
|
||||
protocol={protocol}
|
||||
indexer={indexer}
|
||||
message={message}
|
||||
source={source}
|
||||
onModalClose={handleDetailsModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
|
||||
71
frontend/src/Activity/Blocklist/blocklistOptionsStore.ts
Normal file
71
frontend/src/Activity/Blocklist/blocklistOptionsStore.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
createOptionsStore,
|
||||
PageableOptions,
|
||||
} from 'Helpers/Hooks/useOptionsStore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
export type BlocklistOptions = PageableOptions;
|
||||
|
||||
const { useOptions, useOption, setOptions, setOption } =
|
||||
createOptionsStore<BlocklistOptions>('blocklist_options', () => {
|
||||
return {
|
||||
pageSize: 20,
|
||||
selectedFilterKey: 'all',
|
||||
sortKey: 'time',
|
||||
sortDirection: 'descending',
|
||||
columns: [
|
||||
{
|
||||
name: 'series.sortTitle',
|
||||
label: () => translate('SeriesTitle'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'sourceTitle',
|
||||
label: () => translate('SourceTitle'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: () => translate('Formats'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
label: () => translate('Date'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'indexer',
|
||||
label: () => translate('Indexer'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: '',
|
||||
columnLabel: () => translate('Actions'),
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
export const useBlocklistOptions = useOptions;
|
||||
export const setBlocklistOptions = setOptions;
|
||||
export const useBlocklistOption = useOption;
|
||||
export const setBlocklistOption = setOption;
|
||||
116
frontend/src/Activity/Blocklist/useBlocklist.ts
Normal file
116
frontend/src/Activity/Blocklist/useBlocklist.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import usePage from 'Helpers/Hooks/usePage';
|
||||
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import Blocklist from 'typings/Blocklist';
|
||||
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useBlocklistOptions } from './blocklistOptionsStore';
|
||||
|
||||
interface BulkBlocklistData {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
export const FILTERS: Filter[] = [
|
||||
{
|
||||
key: 'all',
|
||||
label: () => translate('All'),
|
||||
filters: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const FILTER_BUILDER: FilterBuilderProp<Blocklist>[] = [
|
||||
{
|
||||
name: 'seriesIds',
|
||||
label: () => translate('Series'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.SERIES,
|
||||
},
|
||||
{
|
||||
name: 'protocols',
|
||||
label: () => translate('Protocol'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.PROTOCOL,
|
||||
},
|
||||
];
|
||||
|
||||
const useBlocklist = () => {
|
||||
const { page, goToPage } = usePage('blocklist');
|
||||
const { pageSize, selectedFilterKey, sortKey, sortDirection } =
|
||||
useBlocklistOptions();
|
||||
const customFilters = useSelector(
|
||||
createCustomFiltersSelector('blocklist')
|
||||
) as CustomFilter[];
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
||||
}, [selectedFilterKey, customFilters]);
|
||||
|
||||
const { refetch, ...query } = usePagedApiQuery<Blocklist>({
|
||||
path: '/blocklist',
|
||||
page,
|
||||
pageSize,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
queryOptions: {
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...query,
|
||||
goToPage,
|
||||
page,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
|
||||
export default useBlocklist;
|
||||
|
||||
export const useFilters = () => {
|
||||
return FILTERS;
|
||||
};
|
||||
|
||||
export const useRemoveBlocklistItem = (id: number) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, void>({
|
||||
path: `/blocklist/${id}`,
|
||||
method: 'DELETE',
|
||||
mutationOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/blocklist'] });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
removeBlocklistItem: mutate,
|
||||
isRemoving: isPending,
|
||||
};
|
||||
};
|
||||
|
||||
export const useRemoveBlocklistItems = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, BulkBlocklistData>({
|
||||
path: `/blocklist/bulk`,
|
||||
method: 'DELETE',
|
||||
mutationOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/blocklist'] });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
removeBlocklistItems: mutate,
|
||||
isRemoving: isPending,
|
||||
};
|
||||
};
|
||||
@@ -174,7 +174,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFailed') {
|
||||
const { message } = data as DownloadFailedHistory;
|
||||
const { indexer, message, source } = data as DownloadFailedHistory;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
@@ -188,9 +188,17 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
|
||||
) : null}
|
||||
|
||||
{indexer ? (
|
||||
<DescriptionListItem title={translate('Indexer')} data={indexer} />
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<DescriptionListItem title={translate('Message')} data={message} />
|
||||
) : null}
|
||||
|
||||
{source ? (
|
||||
<DescriptionListItem title={translate('Source')} data={source} />
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
113
frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx
Normal file
113
frontend/src/Activity/Queue/Details/QueueDetailsProvider.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { createContext, ReactNode, useContext, useMemo } from 'react';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import Queue from 'typings/Queue';
|
||||
|
||||
interface EpisodeDetails {
|
||||
episodeIds: number[];
|
||||
}
|
||||
|
||||
interface SeriesDetails {
|
||||
seriesId: number;
|
||||
}
|
||||
|
||||
interface AllDetails {
|
||||
all: boolean;
|
||||
}
|
||||
|
||||
type QueueDetailsFilter = AllDetails | EpisodeDetails | SeriesDetails;
|
||||
|
||||
interface QueueDetailsProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const QueueDetailsContext = createContext<Queue[] | undefined>(undefined);
|
||||
|
||||
export default function QueueDetailsProvider({
|
||||
children,
|
||||
...filter
|
||||
}: QueueDetailsProps & QueueDetailsFilter) {
|
||||
const { data } = useApiQuery<Queue[]>({
|
||||
path: '/queue/details',
|
||||
queryParams: { ...filter },
|
||||
queryOptions: {
|
||||
enabled: Object.keys(filter).length > 0,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<QueueDetailsContext.Provider value={data}>
|
||||
{children}
|
||||
</QueueDetailsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useQueueItemForEpisode(episodeId: number) {
|
||||
const queue = useContext(QueueDetailsContext);
|
||||
|
||||
return useMemo(() => {
|
||||
return queue?.find((item) => item.episodeIds.includes(episodeId));
|
||||
}, [episodeId, queue]);
|
||||
}
|
||||
|
||||
export function useIsDownloadingEpisodes(episodeIds: number[]) {
|
||||
const queue = useContext(QueueDetailsContext);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!queue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return queue.some((item) =>
|
||||
item.episodeIds?.some((e) => episodeIds.includes(e))
|
||||
);
|
||||
}, [episodeIds, queue]);
|
||||
}
|
||||
|
||||
export interface SeriesQueueDetails {
|
||||
count: number;
|
||||
episodesWithFiles: number;
|
||||
}
|
||||
|
||||
export function useQueueDetailsForSeries(
|
||||
seriesId: number,
|
||||
seasonNumber?: number
|
||||
) {
|
||||
const queue = useContext(QueueDetailsContext);
|
||||
|
||||
return useMemo<SeriesQueueDetails>(() => {
|
||||
if (!queue) {
|
||||
return { count: 0, episodesWithFiles: 0 };
|
||||
}
|
||||
|
||||
return queue.reduce<SeriesQueueDetails>(
|
||||
(acc: SeriesQueueDetails, item) => {
|
||||
if (
|
||||
item.trackedDownloadState === 'imported' ||
|
||||
item.seriesId !== seriesId
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (seasonNumber != null && item.seasonNumber !== seasonNumber) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.count++;
|
||||
|
||||
if (item.episodeHasFile) {
|
||||
acc.episodesWithFiles++;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
episodesWithFiles: 0,
|
||||
}
|
||||
);
|
||||
}, [seriesId, seasonNumber, queue]);
|
||||
}
|
||||
|
||||
export const useQueueDetails = () => {
|
||||
return useContext(QueueDetailsContext) ?? [];
|
||||
};
|
||||
76
frontend/src/Activity/Queue/EpisodeCellContent.tsx
Normal file
76
frontend/src/Activity/Queue/EpisodeCellContent.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import Episode from 'Episode/Episode';
|
||||
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||
import Series from 'Series/Series';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface EpisodeCellContentProps {
|
||||
episodes: Episode[];
|
||||
isFullSeason: boolean;
|
||||
seasonNumber?: number;
|
||||
series?: Series;
|
||||
}
|
||||
|
||||
export default function EpisodeCellContent({
|
||||
episodes,
|
||||
isFullSeason,
|
||||
seasonNumber,
|
||||
series,
|
||||
}: EpisodeCellContentProps) {
|
||||
if (episodes.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (isFullSeason && seasonNumber != null) {
|
||||
return translate('SeasonNumberToken', { seasonNumber });
|
||||
}
|
||||
|
||||
if (episodes.length === 1) {
|
||||
const episode = episodes[0];
|
||||
|
||||
return (
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={episode.seasonNumber}
|
||||
episodeNumber={episode.episodeNumber}
|
||||
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={episode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={episode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={episode.sceneAbsoluteEpisodeNumber}
|
||||
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const firstEpisode = episodes[0];
|
||||
const lastEpisode = episodes[episodes.length - 1];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={firstEpisode.seasonNumber}
|
||||
episodeNumber={firstEpisode.episodeNumber}
|
||||
absoluteEpisodeNumber={firstEpisode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={firstEpisode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={firstEpisode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={firstEpisode.sceneAbsoluteEpisodeNumber}
|
||||
unverifiedSceneNumbering={firstEpisode.unverifiedSceneNumbering}
|
||||
/>
|
||||
{' - '}
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={lastEpisode.seasonNumber}
|
||||
episodeNumber={lastEpisode.episodeNumber}
|
||||
absoluteEpisodeNumber={lastEpisode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={lastEpisode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={lastEpisode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={lastEpisode.sceneAbsoluteEpisodeNumber}
|
||||
unverifiedSceneNumbering={lastEpisode.unverifiedSceneNumbering}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
frontend/src/Activity/Queue/EpisodeTitleCellContent.css
Normal file
13
frontend/src/Activity/Queue/EpisodeTitleCellContent.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.multiple {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.episodeNumber {
|
||||
margin-right: 8px;
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
}
|
||||
9
frontend/src/Activity/Queue/EpisodeTitleCellContent.css.d.ts
vendored
Normal file
9
frontend/src/Activity/Queue/EpisodeTitleCellContent.css.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'episodeNumber': string;
|
||||
'multiple': string;
|
||||
'row': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
66
frontend/src/Activity/Queue/EpisodeTitleCellContent.tsx
Normal file
66
frontend/src/Activity/Queue/EpisodeTitleCellContent.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import Episode from 'Episode/Episode';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import Series from 'Series/Series';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EpisodeTitleCellContent.css';
|
||||
|
||||
interface EpisodeTitleCellContentProps {
|
||||
episodes: Episode[];
|
||||
series?: Series;
|
||||
}
|
||||
|
||||
export default function EpisodeTitleCellContent({
|
||||
episodes,
|
||||
series,
|
||||
}: EpisodeTitleCellContentProps) {
|
||||
if (episodes.length === 0 || !series) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (episodes.length === 1) {
|
||||
const episode = episodes[0];
|
||||
|
||||
return (
|
||||
<EpisodeTitleLink
|
||||
episodeId={episode.id}
|
||||
seriesId={series.id}
|
||||
episodeTitle={episode.title}
|
||||
episodeEntity="episodes"
|
||||
showOpenSeriesButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
anchor={
|
||||
<span className={styles.multiple}>{translate('MultipleEpisodes')}</span>
|
||||
}
|
||||
title={translate('EpisodeTitles')}
|
||||
body={
|
||||
<>
|
||||
{episodes.map((episode) => {
|
||||
return (
|
||||
<div key={episode.id} className={styles.row}>
|
||||
<div className={styles.episodeNumber}>
|
||||
{episode.episodeNumber}
|
||||
</div>
|
||||
|
||||
<EpisodeTitleLink
|
||||
episodeId={episode.id}
|
||||
seriesId={series.id}
|
||||
episodeTitle={episode.title}
|
||||
episodeEntity="episodes"
|
||||
showOpenSeriesButton={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
position="right"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
@@ -22,28 +21,15 @@ import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import usePaging from 'Components/Table/usePaging';
|
||||
import createEpisodesFetchingSelector from 'Episode/createEpisodesFetchingSelector';
|
||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { clearEpisodes, fetchEpisodes } from 'Store/Actions/episodeActions';
|
||||
import {
|
||||
clearQueue,
|
||||
fetchQueue,
|
||||
gotoQueuePage,
|
||||
grabQueueItems,
|
||||
removeQueueItems,
|
||||
setQueueFilter,
|
||||
setQueueSort,
|
||||
setQueueTableOption,
|
||||
} from 'Store/Actions/queueActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import QueueItem from 'typings/Queue';
|
||||
import { TableOptionsChangePayload } from 'typings/Table';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import {
|
||||
@@ -54,33 +40,45 @@ import translate from 'Utilities/String/translate';
|
||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import QueueFilterModal from './QueueFilterModal';
|
||||
import QueueOptions from './QueueOptions';
|
||||
import {
|
||||
setQueueOption,
|
||||
setQueueOptions,
|
||||
useQueueOptions,
|
||||
} from './queueOptionsStore';
|
||||
import QueueRow from './QueueRow';
|
||||
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
|
||||
import createQueueStatusSelector from './Status/createQueueStatusSelector';
|
||||
import RemoveQueueItemModal from './RemoveQueueItemModal';
|
||||
import useQueueStatus from './Status/useQueueStatus';
|
||||
import useQueue, {
|
||||
useFilters,
|
||||
useGrabQueueItems,
|
||||
useRemoveQueueItems,
|
||||
} from './useQueue';
|
||||
|
||||
function Queue() {
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
columns,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
records,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
isGrabbing,
|
||||
isRemoving,
|
||||
} = useSelector((state: AppState) => state.queue.paged);
|
||||
error,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isLoading,
|
||||
page,
|
||||
goToPage,
|
||||
refetch,
|
||||
} = useQueue();
|
||||
|
||||
const { count } = useSelector(createQueueStatusSelector());
|
||||
const { columns, pageSize, sortKey, sortDirection, selectedFilterKey } =
|
||||
useQueueOptions();
|
||||
|
||||
const filters = useFilters();
|
||||
|
||||
const { isRemoving, removeQueueItems } = useRemoveQueueItems();
|
||||
const { isGrabbing, grabQueueItems } = useGrabQueueItems();
|
||||
|
||||
const { count } = useQueueStatus();
|
||||
const { isEpisodesFetching, isEpisodesPopulated, episodesError } =
|
||||
useSelector(createEpisodesFetchingSelector());
|
||||
const customFilters = useSelector(createCustomFiltersSelector('queue'));
|
||||
@@ -100,41 +98,46 @@ function Queue() {
|
||||
}, [selectedState]);
|
||||
|
||||
const isPendingSelected = useMemo(() => {
|
||||
return items.some((item) => {
|
||||
return records.some((item) => {
|
||||
return selectedIds.indexOf(item.id) > -1 && item.status === 'delay';
|
||||
});
|
||||
}, [items, selectedIds]);
|
||||
}, [records, selectedIds]);
|
||||
|
||||
const [isConfirmRemoveModalOpen, setIsConfirmRemoveModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const isRefreshing =
|
||||
isFetching || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
||||
isLoading || isEpisodesFetching || isRefreshMonitoredDownloadsExecuting;
|
||||
const isAllPopulated =
|
||||
isPopulated &&
|
||||
(isEpisodesPopulated || !items.length || items.every((e) => !e.episodeId));
|
||||
isFetched &&
|
||||
(isEpisodesPopulated ||
|
||||
!records.length ||
|
||||
records.every((e) => !e.episodeIds?.length));
|
||||
const hasError = error || episodesError;
|
||||
const selectedCount = selectedIds.length;
|
||||
const disableSelectedActions = selectedCount === 0;
|
||||
|
||||
const handleSelectAllChange = useCallback(
|
||||
({ value }: CheckInputChanged) => {
|
||||
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
|
||||
setSelectState({
|
||||
type: value ? 'selectAll' : 'unselectAll',
|
||||
items: records,
|
||||
});
|
||||
},
|
||||
[items, setSelectState]
|
||||
[records, setSelectState]
|
||||
);
|
||||
|
||||
const handleSelectedChange = useCallback(
|
||||
({ id, value, shiftKey = false }: SelectStateInputProps) => {
|
||||
setSelectState({
|
||||
type: 'toggleSelected',
|
||||
items,
|
||||
items: records,
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[items, setSelectState]
|
||||
[records, setSelectState]
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
@@ -150,93 +153,60 @@ function Queue() {
|
||||
}, []);
|
||||
|
||||
const handleGrabSelectedPress = useCallback(() => {
|
||||
dispatch(grabQueueItems({ ids: selectedIds }));
|
||||
}, [selectedIds, dispatch]);
|
||||
grabQueueItems({ ids: selectedIds });
|
||||
}, [selectedIds, grabQueueItems]);
|
||||
|
||||
const handleRemoveSelectedPress = useCallback(() => {
|
||||
shouldBlockRefresh.current = true;
|
||||
setIsConfirmRemoveModalOpen(true);
|
||||
}, [setIsConfirmRemoveModalOpen]);
|
||||
|
||||
const handleRemoveSelectedConfirmed = useCallback(
|
||||
(payload: RemovePressProps) => {
|
||||
shouldBlockRefresh.current = false;
|
||||
dispatch(removeQueueItems({ ids: selectedIds, ...payload }));
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
},
|
||||
[selectedIds, setIsConfirmRemoveModalOpen, dispatch]
|
||||
);
|
||||
const handleRemoveSelectedConfirmed = useCallback(() => {
|
||||
shouldBlockRefresh.current = false;
|
||||
removeQueueItems({ ids: selectedIds });
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
}, [selectedIds, setIsConfirmRemoveModalOpen, removeQueueItems]);
|
||||
|
||||
const handleConfirmRemoveModalClose = useCallback(() => {
|
||||
shouldBlockRefresh.current = false;
|
||||
setIsConfirmRemoveModalOpen(false);
|
||||
}, [setIsConfirmRemoveModalOpen]);
|
||||
|
||||
const {
|
||||
handleFirstPagePress,
|
||||
handlePreviousPagePress,
|
||||
handleNextPagePress,
|
||||
handleLastPagePress,
|
||||
handlePageSelect,
|
||||
} = usePaging({
|
||||
page,
|
||||
totalPages,
|
||||
gotoPage: gotoQueuePage,
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setQueueFilter({ selectedFilterKey }));
|
||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSortPress = useCallback(
|
||||
(sortKey: string) => {
|
||||
dispatch(setQueueSort({ sortKey }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
const handleSortPress = useCallback((sortKey: string) => {
|
||||
setQueueOption('sortKey', sortKey);
|
||||
}, []);
|
||||
|
||||
const handleTableOptionChange = useCallback(
|
||||
(payload: TableOptionsChangePayload) => {
|
||||
dispatch(setQueueTableOption(payload));
|
||||
setQueueOptions(payload);
|
||||
|
||||
if (payload.pageSize) {
|
||||
dispatch(gotoQueuePage({ page: 1 }));
|
||||
goToPage(1);
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchQueue());
|
||||
} else {
|
||||
dispatch(gotoQueuePage({ page: 1 }));
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispatch(clearQueue());
|
||||
};
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const episodeIds = selectUniqueIds<QueueItem, number | undefined>(
|
||||
items,
|
||||
'episodeId'
|
||||
);
|
||||
const episodeIds = selectUniqueIds(records, 'episodeIds');
|
||||
|
||||
if (episodeIds.length) {
|
||||
dispatch(fetchEpisodes({ episodeIds }));
|
||||
} else {
|
||||
dispatch(clearEpisodes());
|
||||
}
|
||||
}, [items, dispatch]);
|
||||
}, [records, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchQueue());
|
||||
refetch();
|
||||
};
|
||||
|
||||
registerPagePopulator(repopulate);
|
||||
@@ -244,7 +214,7 @@ function Queue() {
|
||||
return () => {
|
||||
unregisterPagePopulator(repopulate);
|
||||
};
|
||||
}, [dispatch]);
|
||||
}, [refetch]);
|
||||
|
||||
if (!shouldBlockRefresh.current) {
|
||||
currentQueue.current = (
|
||||
@@ -255,7 +225,7 @@ function Queue() {
|
||||
<Alert kind={kinds.DANGER}>{translate('QueueLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{isAllPopulated && !hasError && !items.length ? (
|
||||
{isAllPopulated && !hasError && !records.length ? (
|
||||
<Alert kind={kinds.INFO}>
|
||||
{selectedFilterKey !== 'all' && count > 0
|
||||
? translate('QueueFilterHasNoItems')
|
||||
@@ -263,7 +233,7 @@ function Queue() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{isAllPopulated && !hasError && !!items.length ? (
|
||||
{isAllPopulated && !hasError && !!records.length ? (
|
||||
<div>
|
||||
<Table
|
||||
selectAll={true}
|
||||
@@ -279,11 +249,10 @@ function Queue() {
|
||||
onSortPress={handleSortPress}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
{records.map((item) => {
|
||||
return (
|
||||
<QueueRow
|
||||
key={item.id}
|
||||
episodeId={item.episodeId}
|
||||
isSelected={selectedState[item.id]}
|
||||
columns={columns}
|
||||
{...item}
|
||||
@@ -302,11 +271,7 @@ function Queue() {
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onFirstPagePress={handleFirstPagePress}
|
||||
onPreviousPagePress={handlePreviousPagePress}
|
||||
onNextPagePress={handleNextPagePress}
|
||||
onLastPagePress={handleLastPagePress}
|
||||
onPageSelect={handlePageSelect}
|
||||
onPageSelect={goToPage}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -377,7 +342,7 @@ function Queue() {
|
||||
canChangeCategory={
|
||||
isConfirmRemoveModalOpen &&
|
||||
selectedIds.every((id) => {
|
||||
const item = items.find((i) => i.id === id);
|
||||
const item = records.find((i) => i.id === id);
|
||||
|
||||
return !!(item && item.downloadClientHasPostImportCategory);
|
||||
})
|
||||
@@ -385,7 +350,7 @@ function Queue() {
|
||||
canIgnore={
|
||||
isConfirmRemoveModalOpen &&
|
||||
selectedIds.every((id) => {
|
||||
const item = items.find((i) => i.id === id);
|
||||
const item = records.find((i) => i.id === id);
|
||||
|
||||
return !!(item && item.seriesId && item.episodeId);
|
||||
})
|
||||
@@ -393,7 +358,7 @@ function Queue() {
|
||||
isPending={
|
||||
isConfirmRemoveModalOpen &&
|
||||
selectedIds.every((id) => {
|
||||
const item = items.find((i) => i.id === id);
|
||||
const item = records.find((i) => i.id === id);
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
|
||||
@@ -14,7 +14,7 @@ import styles from './QueueDetails.css';
|
||||
interface QueueDetailsProps {
|
||||
title: string;
|
||||
size: number;
|
||||
sizeleft: number;
|
||||
sizeLeft: number;
|
||||
estimatedCompletionTime?: string;
|
||||
status: string;
|
||||
trackedDownloadState?: QueueTrackedDownloadState;
|
||||
@@ -28,7 +28,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
||||
const {
|
||||
title,
|
||||
size,
|
||||
sizeleft,
|
||||
sizeLeft,
|
||||
status,
|
||||
trackedDownloadState = 'downloading',
|
||||
trackedDownloadStatus = 'ok',
|
||||
@@ -37,7 +37,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
||||
progressBar,
|
||||
} = props;
|
||||
|
||||
const progress = 100 - (sizeleft / size) * 100;
|
||||
const progress = 100 - (sizeLeft / size) * 100;
|
||||
const isDownloading = status === 'downloading';
|
||||
const isPaused = status === 'paused';
|
||||
const hasWarning = trackedDownloadStatus === 'warning';
|
||||
@@ -61,7 +61,7 @@ function QueueDetails(props: QueueDetailsProps) {
|
||||
anchor={progressBar!}
|
||||
title={`${state} - ${progress.toFixed(1)}%`}
|
||||
body={<div>{title}</div>}
|
||||
position={tooltipPositions.LEFT}
|
||||
position="bottom-start"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,50 +1,26 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterModal, { FilterModalProps } from 'Components/Filter/FilterModal';
|
||||
import { setQueueFilter } from 'Store/Actions/queueActions';
|
||||
|
||||
function createQueueSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.paged.items,
|
||||
(queueItems) => {
|
||||
return queueItems;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createFilterBuilderPropsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.paged.filterBuilderProps,
|
||||
(filterBuilderProps) => {
|
||||
return filterBuilderProps;
|
||||
}
|
||||
);
|
||||
}
|
||||
import { setQueueOption } from './queueOptionsStore';
|
||||
import useQueue, { FILTER_BUILDER } from './useQueue';
|
||||
|
||||
type QueueFilterModalProps = FilterModalProps<History>;
|
||||
|
||||
export default function QueueFilterModal(props: QueueFilterModalProps) {
|
||||
const sectionItems = useSelector(createQueueSelector());
|
||||
const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
|
||||
const customFilterType = 'queue';
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { records } = useQueue();
|
||||
|
||||
const dispatchSetFilter = useCallback(
|
||||
(payload: unknown) => {
|
||||
dispatch(setQueueFilter(payload));
|
||||
({ selectedFilterKey }: { selectedFilterKey: string | number }) => {
|
||||
setQueueOption('selectedFilterKey', selectedFilterKey);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterModal
|
||||
{...props}
|
||||
sectionItems={sectionItems}
|
||||
filterBuilderProps={filterBuilderProps}
|
||||
customFilterType={customFilterType}
|
||||
sectionItems={records}
|
||||
filterBuilderProps={FILTER_BUILDER}
|
||||
customFilterType="queue"
|
||||
dispatchSetFilter={dispatchSetFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import {
|
||||
QueueOptions as QueueOptionsType,
|
||||
setQueueOption,
|
||||
useQueueOption,
|
||||
} from './queueOptionsStore';
|
||||
import useQueue from './useQueue';
|
||||
|
||||
function QueueOptions() {
|
||||
const dispatch = useDispatch();
|
||||
const { includeUnknownSeriesItems } = useSelector(
|
||||
(state: AppState) => state.queue.options
|
||||
);
|
||||
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
|
||||
const { goToPage } = useQueue();
|
||||
|
||||
const handleOptionChange = useCallback(
|
||||
({ name, value }: InputChanged<boolean>) => {
|
||||
dispatch(
|
||||
setQueueOption({
|
||||
[name]: value,
|
||||
})
|
||||
);
|
||||
({ name, value }: OptionChanged<QueueOptionsType>) => {
|
||||
setQueueOption(name, value);
|
||||
|
||||
if (name === 'includeUnknownSeriesItems') {
|
||||
dispatch(gotoQueuePage({ page: 1 }));
|
||||
goToPage(1);
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
[goToPage]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -39,6 +36,7 @@ function QueueOptions() {
|
||||
name="includeUnknownSeriesItems"
|
||||
value={includeUnknownSeriesItems}
|
||||
helpText={translate('ShowUnknownSeriesItemsHelpText')}
|
||||
// @ts-expect-error - The typing for inputs needs more work
|
||||
onChange={handleOptionChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import ProgressBar from 'Components/ProgressBar';
|
||||
@@ -15,16 +14,13 @@ import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import EpisodeTitleLink from 'Episode/EpisodeTitleLink';
|
||||
import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||
import useEpisode from 'Episode/useEpisode';
|
||||
import useEpisodes from 'Episode/useEpisodes';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { grabQueueItem, removeQueueItem } from 'Store/Actions/queueActions';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
@@ -36,15 +32,18 @@ import {
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeCellContent from './EpisodeCellContent';
|
||||
import EpisodeTitleCellContent from './EpisodeTitleCellContent';
|
||||
import QueueStatusCell from './QueueStatusCell';
|
||||
import RemoveQueueItemModal, { RemovePressProps } from './RemoveQueueItemModal';
|
||||
import TimeleftCell from './TimeleftCell';
|
||||
import RemoveQueueItemModal from './RemoveQueueItemModal';
|
||||
import TimeLeftCell from './TimeLeftCell';
|
||||
import { useGrabQueueItem, useRemoveQueueItem } from './useQueue';
|
||||
import styles from './QueueRow.css';
|
||||
|
||||
interface QueueRowProps {
|
||||
id: number;
|
||||
seriesId?: number;
|
||||
episodeId?: number;
|
||||
episodeIds: number[];
|
||||
downloadId?: string;
|
||||
title: string;
|
||||
status: string;
|
||||
@@ -58,16 +57,16 @@ interface QueueRowProps {
|
||||
customFormatScore: number;
|
||||
protocol: DownloadProtocol;
|
||||
indexer?: string;
|
||||
isFullSeason: boolean;
|
||||
seasonNumbers: number[];
|
||||
outputPath?: string;
|
||||
downloadClient?: string;
|
||||
downloadClientHasPostImportCategory?: boolean;
|
||||
estimatedCompletionTime?: string;
|
||||
added?: string;
|
||||
timeleft?: string;
|
||||
timeLeft?: string;
|
||||
size: number;
|
||||
sizeleft: number;
|
||||
isGrabbing?: boolean;
|
||||
grabError?: Error;
|
||||
sizeLeft: number;
|
||||
isRemoving?: boolean;
|
||||
isSelected?: boolean;
|
||||
columns: Column[];
|
||||
@@ -79,7 +78,7 @@ function QueueRow(props: QueueRowProps) {
|
||||
const {
|
||||
id,
|
||||
seriesId,
|
||||
episodeId,
|
||||
episodeIds,
|
||||
downloadId,
|
||||
title,
|
||||
status,
|
||||
@@ -97,25 +96,25 @@ function QueueRow(props: QueueRowProps) {
|
||||
downloadClient,
|
||||
downloadClientHasPostImportCategory,
|
||||
estimatedCompletionTime,
|
||||
isFullSeason,
|
||||
seasonNumbers,
|
||||
added,
|
||||
timeleft,
|
||||
timeLeft,
|
||||
size,
|
||||
sizeleft,
|
||||
isGrabbing = false,
|
||||
grabError,
|
||||
isRemoving = false,
|
||||
sizeLeft,
|
||||
isSelected,
|
||||
columns,
|
||||
onSelectedChange,
|
||||
onQueueRowModalOpenOrClose,
|
||||
} = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const series = useSeries(seriesId);
|
||||
const episode = useEpisode(episodeId, 'episodes');
|
||||
const episodes = useEpisodes(episodeIds, 'episodes');
|
||||
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
const { removeQueueItem, isRemoving } = useRemoveQueueItem(id);
|
||||
const { grabQueueItem, isGrabbing, grabError } = useGrabQueueItem(id);
|
||||
|
||||
const [isRemoveQueueItemModalOpen, setIsRemoveQueueItemModalOpen] =
|
||||
useState(false);
|
||||
@@ -124,8 +123,8 @@ function QueueRow(props: QueueRowProps) {
|
||||
useState(false);
|
||||
|
||||
const handleGrabPress = useCallback(() => {
|
||||
dispatch(grabQueueItem({ id }));
|
||||
}, [id, dispatch]);
|
||||
grabQueueItem();
|
||||
}, [grabQueueItem]);
|
||||
|
||||
const handleInteractiveImportPress = useCallback(() => {
|
||||
onQueueRowModalOpenOrClose(true);
|
||||
@@ -142,21 +141,22 @@ function QueueRow(props: QueueRowProps) {
|
||||
setIsRemoveQueueItemModalOpen(true);
|
||||
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
||||
|
||||
const handleRemoveQueueItemModalConfirmed = useCallback(
|
||||
(payload: RemovePressProps) => {
|
||||
onQueueRowModalOpenOrClose(false);
|
||||
dispatch(removeQueueItem({ id, ...payload }));
|
||||
setIsRemoveQueueItemModalOpen(false);
|
||||
},
|
||||
[id, setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose, dispatch]
|
||||
);
|
||||
const handleRemoveQueueItemModalConfirmed = useCallback(() => {
|
||||
onQueueRowModalOpenOrClose(false);
|
||||
removeQueueItem();
|
||||
setIsRemoveQueueItemModalOpen(false);
|
||||
}, [
|
||||
setIsRemoveQueueItemModalOpen,
|
||||
removeQueueItem,
|
||||
onQueueRowModalOpenOrClose,
|
||||
]);
|
||||
|
||||
const handleRemoveQueueItemModalClose = useCallback(() => {
|
||||
onQueueRowModalOpenOrClose(false);
|
||||
setIsRemoveQueueItemModalOpen(false);
|
||||
}, [setIsRemoveQueueItemModalOpen, onQueueRowModalOpenOrClose]);
|
||||
|
||||
const progress = 100 - (sizeleft / size) * 100;
|
||||
const progress = 100 - (sizeLeft / size) * 100;
|
||||
const showInteractiveImport =
|
||||
status === 'completed' && trackedDownloadStatus === 'warning';
|
||||
const isPending =
|
||||
@@ -209,23 +209,12 @@ function QueueRow(props: QueueRowProps) {
|
||||
if (name === 'episode') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
{episode ? (
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={episode.seasonNumber}
|
||||
episodeNumber={episode.episodeNumber}
|
||||
absoluteEpisodeNumber={episode.absoluteEpisodeNumber}
|
||||
seriesType={series?.seriesType}
|
||||
alternateTitles={series?.alternateTitles}
|
||||
sceneSeasonNumber={episode.sceneSeasonNumber}
|
||||
sceneEpisodeNumber={episode.sceneEpisodeNumber}
|
||||
sceneAbsoluteEpisodeNumber={
|
||||
episode.sceneAbsoluteEpisodeNumber
|
||||
}
|
||||
unverifiedSceneNumbering={episode.unverifiedSceneNumbering}
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
<EpisodeCellContent
|
||||
episodes={episodes}
|
||||
isFullSeason={isFullSeason}
|
||||
seasonNumber={seasonNumbers[0]}
|
||||
series={series}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
@@ -233,27 +222,37 @@ function QueueRow(props: QueueRowProps) {
|
||||
if (name === 'episodes.title') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
{series && episode ? (
|
||||
<EpisodeTitleLink
|
||||
episodeId={episode.id}
|
||||
seriesId={series.id}
|
||||
episodeTitle={episode.title}
|
||||
episodeEntity="episodes"
|
||||
showOpenSeriesButton={true}
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
<EpisodeTitleCellContent episodes={episodes} series={series} />
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'episodes.airDateUtc') {
|
||||
if (episode) {
|
||||
return <RelativeDateCell key={name} date={episode.airDateUtc} />;
|
||||
if (episodes.length === 0) {
|
||||
return <TableRowCell key={name}>-</TableRowCell>;
|
||||
}
|
||||
|
||||
return <TableRowCell key={name}>-</TableRowCell>;
|
||||
if (episodes.length === 1) {
|
||||
return (
|
||||
<RelativeDateCell key={name} date={episodes[0].airDateUtc} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
component="span"
|
||||
date={episodes[0].airDateUtc}
|
||||
/>
|
||||
{' - '}
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
component="span"
|
||||
date={episodes[episodes.length - 1].airDateUtc}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'languages') {
|
||||
@@ -325,13 +324,13 @@ function QueueRow(props: QueueRowProps) {
|
||||
|
||||
if (name === 'estimatedCompletionTime') {
|
||||
return (
|
||||
<TimeleftCell
|
||||
<TimeLeftCell
|
||||
key={name}
|
||||
status={status}
|
||||
estimatedCompletionTime={estimatedCompletionTime}
|
||||
timeleft={timeleft}
|
||||
timeLeft={timeLeft}
|
||||
size={size}
|
||||
sizeleft={sizeleft}
|
||||
sizeLeft={sizeLeft}
|
||||
showRelativeDates={showRelativeDates}
|
||||
shortDateFormat={shortDateFormat}
|
||||
timeFormat={timeFormat}
|
||||
|
||||
@@ -90,7 +90,7 @@ function QueueStatus(props: QueueStatusProps) {
|
||||
|
||||
if (trackedDownloadState === 'importing') {
|
||||
title += ` - ${translate('Importing')}`;
|
||||
iconKind = kinds.PURPLE;
|
||||
iconKind = kinds.PRIMARY;
|
||||
}
|
||||
|
||||
if (trackedDownloadState === 'failedPending') {
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { OptionChanged } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import {
|
||||
QueueOptions,
|
||||
setQueueOption,
|
||||
useQueueOption,
|
||||
} from './queueOptionsStore';
|
||||
import styles from './RemoveQueueItemModal.css';
|
||||
|
||||
export interface RemovePressProps {
|
||||
remove: boolean;
|
||||
changeCategory: boolean;
|
||||
blocklist: boolean;
|
||||
skipRedownload: boolean;
|
||||
}
|
||||
|
||||
interface RemoveQueueItemModalProps {
|
||||
isOpen: boolean;
|
||||
sourceTitle?: string;
|
||||
@@ -26,16 +26,10 @@ interface RemoveQueueItemModalProps {
|
||||
canIgnore: boolean;
|
||||
isPending: boolean;
|
||||
selectedCount?: number;
|
||||
onRemovePress(props: RemovePressProps): void;
|
||||
onRemovePress(): void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
type RemovalMethod = 'removeFromClient' | 'changeCategory' | 'ignore';
|
||||
type BlocklistMethod =
|
||||
| 'doNotBlocklist'
|
||||
| 'blocklistAndSearch'
|
||||
| 'blocklistOnly';
|
||||
|
||||
function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
const {
|
||||
isOpen,
|
||||
@@ -49,11 +43,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
} = props;
|
||||
|
||||
const multipleSelected = selectedCount && selectedCount > 1;
|
||||
|
||||
const [removalMethod, setRemovalMethod] =
|
||||
useState<RemovalMethod>('removeFromClient');
|
||||
const [blocklistMethod, setBlocklistMethod] =
|
||||
useState<BlocklistMethod>('doNotBlocklist');
|
||||
const { removalMethod, blocklistMethod } = useQueueOption('removalOptions');
|
||||
|
||||
const { title, message } = useMemo(() => {
|
||||
if (!selectedCount) {
|
||||
@@ -79,7 +69,7 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
}, [sourceTitle, selectedCount]);
|
||||
|
||||
const removalMethodOptions = useMemo(() => {
|
||||
return [
|
||||
const options: EnhancedSelectInputValue<string>[] = [
|
||||
{
|
||||
key: 'removeFromClient',
|
||||
value: translate('RemoveFromDownloadClient'),
|
||||
@@ -106,10 +96,12 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
: translate('IgnoreDownloadHint'),
|
||||
},
|
||||
];
|
||||
|
||||
return options;
|
||||
}, [canChangeCategory, canIgnore, multipleSelected]);
|
||||
|
||||
const blocklistMethodOptions = useMemo(() => {
|
||||
return [
|
||||
const options: EnhancedSelectInputValue<string>[] = [
|
||||
{
|
||||
key: 'doNotBlocklist',
|
||||
value: translate('DoNotBlocklist'),
|
||||
@@ -131,46 +123,28 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
: translate('BlocklistOnlyHint'),
|
||||
},
|
||||
];
|
||||
|
||||
return options;
|
||||
}, [isPending, multipleSelected]);
|
||||
|
||||
const handleRemovalMethodChange = useCallback(
|
||||
({ value }: { value: RemovalMethod }) => {
|
||||
setRemovalMethod(value);
|
||||
const handleRemovalOptionInputChange = useCallback(
|
||||
({ name, value }: OptionChanged<QueueOptions['removalOptions']>) => {
|
||||
setQueueOption('removalOptions', {
|
||||
removalMethod,
|
||||
blocklistMethod,
|
||||
[name]: value,
|
||||
});
|
||||
},
|
||||
[setRemovalMethod]
|
||||
);
|
||||
|
||||
const handleBlocklistMethodChange = useCallback(
|
||||
({ value }: { value: BlocklistMethod }) => {
|
||||
setBlocklistMethod(value);
|
||||
},
|
||||
[setBlocklistMethod]
|
||||
[removalMethod, blocklistMethod]
|
||||
);
|
||||
|
||||
const handleConfirmRemove = useCallback(() => {
|
||||
onRemovePress({
|
||||
remove: removalMethod === 'removeFromClient',
|
||||
changeCategory: removalMethod === 'changeCategory',
|
||||
blocklist: blocklistMethod !== 'doNotBlocklist',
|
||||
skipRedownload: blocklistMethod === 'blocklistOnly',
|
||||
});
|
||||
|
||||
setRemovalMethod('removeFromClient');
|
||||
setBlocklistMethod('doNotBlocklist');
|
||||
}, [
|
||||
removalMethod,
|
||||
blocklistMethod,
|
||||
setRemovalMethod,
|
||||
setBlocklistMethod,
|
||||
onRemovePress,
|
||||
]);
|
||||
onRemovePress();
|
||||
}, [onRemovePress]);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
setRemovalMethod('removeFromClient');
|
||||
setBlocklistMethod('doNotBlocklist');
|
||||
|
||||
onModalClose();
|
||||
}, [setRemovalMethod, setBlocklistMethod, onModalClose]);
|
||||
}, [onModalClose]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={handleModalClose}>
|
||||
@@ -193,7 +167,8 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
helpTextWarning={translate(
|
||||
'RemoveQueueItemRemovalMethodHelpTextWarning'
|
||||
)}
|
||||
onChange={handleRemovalMethodChange}
|
||||
// @ts-expect-error - The typing for inputs needs more work
|
||||
onChange={handleRemovalOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
@@ -211,7 +186,8 @@ function RemoveQueueItemModal(props: RemoveQueueItemModalProps) {
|
||||
value={blocklistMethod}
|
||||
values={blocklistMethodOptions}
|
||||
helpText={translate('BlocklistReleaseHelpText')}
|
||||
onChange={handleBlocklistMethodChange}
|
||||
// @ts-expect-error - The typing for inputs needs more work
|
||||
onChange={handleRemovalOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</ModalBody>
|
||||
|
||||
@@ -1,33 +1,9 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import React from 'react';
|
||||
import PageSidebarStatus from 'Components/Page/Sidebar/PageSidebarStatus';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { fetchQueueStatus } from 'Store/Actions/queueActions';
|
||||
import createQueueStatusSelector from './createQueueStatusSelector';
|
||||
import useQueueStatus from './useQueueStatus';
|
||||
|
||||
function QueueStatus() {
|
||||
const dispatch = useDispatch();
|
||||
const { isConnected, isReconnecting } = useSelector(
|
||||
(state: AppState) => state.app
|
||||
);
|
||||
const { isPopulated, count, errors, warnings } = useSelector(
|
||||
createQueueStatusSelector()
|
||||
);
|
||||
|
||||
const wasReconnecting = usePrevious(isReconnecting);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPopulated) {
|
||||
dispatch(fetchQueueStatus());
|
||||
}
|
||||
}, [isPopulated, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected && wasReconnecting) {
|
||||
dispatch(fetchQueueStatus());
|
||||
}
|
||||
}, [isConnected, wasReconnecting, dispatch]);
|
||||
const { errors, warnings, count } = useQueueStatus();
|
||||
|
||||
return (
|
||||
<PageSidebarStatus count={count} errors={errors} warnings={warnings} />
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
|
||||
function createQueueStatusSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.status.isPopulated,
|
||||
(state: AppState) => state.queue.status.item,
|
||||
(state: AppState) => state.queue.options.includeUnknownSeriesItems,
|
||||
(isPopulated, status, includeUnknownSeriesItems) => {
|
||||
const {
|
||||
errors,
|
||||
warnings,
|
||||
unknownErrors,
|
||||
unknownWarnings,
|
||||
count,
|
||||
totalCount,
|
||||
} = status;
|
||||
|
||||
return {
|
||||
...status,
|
||||
isPopulated,
|
||||
count: includeUnknownSeriesItems ? totalCount : count,
|
||||
errors: includeUnknownSeriesItems ? errors || unknownErrors : errors,
|
||||
warnings: includeUnknownSeriesItems
|
||||
? warnings || unknownWarnings
|
||||
: warnings,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createQueueStatusSelector;
|
||||
54
frontend/src/Activity/Queue/Status/useQueueStatus.ts
Normal file
54
frontend/src/Activity/Queue/Status/useQueueStatus.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import { useQueueOption } from '../queueOptionsStore';
|
||||
|
||||
export interface QueueStatus {
|
||||
totalCount: number;
|
||||
count: number;
|
||||
unknownCount: number;
|
||||
errors: boolean;
|
||||
warnings: boolean;
|
||||
unknownErrors: boolean;
|
||||
unknownWarnings: boolean;
|
||||
}
|
||||
|
||||
export default function useQueueStatus() {
|
||||
const includeUnknownSeriesItems = useQueueOption('includeUnknownSeriesItems');
|
||||
|
||||
const { data } = useApiQuery<QueueStatus>({
|
||||
path: '/queue/status',
|
||||
queryParams: {
|
||||
includeUnknownSeriesItems,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
count: 0,
|
||||
errors: false,
|
||||
warnings: false,
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
errors,
|
||||
warnings,
|
||||
unknownErrors,
|
||||
unknownWarnings,
|
||||
count,
|
||||
totalCount,
|
||||
} = data;
|
||||
|
||||
if (includeUnknownSeriesItems) {
|
||||
return {
|
||||
count: totalCount,
|
||||
errors: errors || unknownErrors,
|
||||
warnings: warnings || unknownWarnings,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
.timeleft {
|
||||
.timeLeft {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 100px;
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'timeleft': string;
|
||||
'timeLeft': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -8,26 +8,26 @@ import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './TimeleftCell.css';
|
||||
import styles from './TimeLeftCell.css';
|
||||
|
||||
interface TimeleftCellProps {
|
||||
interface TimeLeftCellProps {
|
||||
estimatedCompletionTime?: string;
|
||||
timeleft?: string;
|
||||
timeLeft?: string;
|
||||
status: string;
|
||||
size: number;
|
||||
sizeleft: number;
|
||||
sizeLeft: number;
|
||||
showRelativeDates: boolean;
|
||||
shortDateFormat: string;
|
||||
timeFormat: string;
|
||||
}
|
||||
|
||||
function TimeleftCell(props: TimeleftCellProps) {
|
||||
function TimeLeftCell(props: TimeLeftCellProps) {
|
||||
const {
|
||||
estimatedCompletionTime,
|
||||
timeleft,
|
||||
timeLeft,
|
||||
status,
|
||||
size,
|
||||
sizeleft,
|
||||
sizeLeft,
|
||||
showRelativeDates,
|
||||
shortDateFormat,
|
||||
timeFormat,
|
||||
@@ -44,7 +44,7 @@ function TimeleftCell(props: TimeleftCellProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRowCell className={styles.timeleft}>
|
||||
<TableRowCell className={styles.timeLeft}>
|
||||
<Tooltip
|
||||
anchor={<Icon name={icons.INFO} />}
|
||||
tooltip={translate('DelayingDownloadUntil', { date, time })}
|
||||
@@ -66,7 +66,7 @@ function TimeleftCell(props: TimeleftCellProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRowCell className={styles.timeleft}>
|
||||
<TableRowCell className={styles.timeLeft}>
|
||||
<Tooltip
|
||||
anchor={<Icon name={icons.INFO} />}
|
||||
tooltip={translate('RetryingDownloadOn', { date, time })}
|
||||
@@ -77,21 +77,21 @@ function TimeleftCell(props: TimeleftCellProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!timeleft || status === 'completed' || status === 'failed') {
|
||||
return <TableRowCell className={styles.timeleft}>-</TableRowCell>;
|
||||
if (!timeLeft || status === 'completed' || status === 'failed') {
|
||||
return <TableRowCell className={styles.timeLeft}>-</TableRowCell>;
|
||||
}
|
||||
|
||||
const totalSize = formatBytes(size);
|
||||
const remainingSize = formatBytes(sizeleft);
|
||||
const remainingSize = formatBytes(sizeLeft);
|
||||
|
||||
return (
|
||||
<TableRowCell
|
||||
className={styles.timeleft}
|
||||
className={styles.timeLeft}
|
||||
title={`${remainingSize} / ${totalSize}`}
|
||||
>
|
||||
{formatTimeSpan(timeleft)}
|
||||
{formatTimeSpan(timeLeft)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimeleftCell;
|
||||
export default TimeLeftCell;
|
||||
160
frontend/src/Activity/Queue/queueOptionsStore.ts
Normal file
160
frontend/src/Activity/Queue/queueOptionsStore.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import {
|
||||
createOptionsStore,
|
||||
PageableOptions,
|
||||
} from 'Helpers/Hooks/useOptionsStore';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface QueueRemovalOptions {
|
||||
removalMethod: 'changeCategory' | 'ignore' | 'removeFromClient';
|
||||
blocklistMethod: 'blocklistAndSearch' | 'blocklistOnly' | 'doNotBlocklist';
|
||||
}
|
||||
|
||||
export interface QueueOptions extends PageableOptions {
|
||||
includeUnknownSeriesItems: boolean;
|
||||
removalOptions: QueueRemovalOptions;
|
||||
}
|
||||
|
||||
const { useOptions, useOption, setOptions, setOption } =
|
||||
createOptionsStore<QueueOptions>('queue_options', () => {
|
||||
return {
|
||||
includeUnknownSeriesItems: true,
|
||||
pageSize: 20,
|
||||
selectedFilterKey: 'all',
|
||||
sortKey: 'time',
|
||||
sortDirection: 'descending',
|
||||
columns: [
|
||||
{
|
||||
name: 'status',
|
||||
label: '',
|
||||
columnLabel: () => translate('Status'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
{
|
||||
name: 'series.sortTitle',
|
||||
label: () => translate('Series'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'episode',
|
||||
label: () => translate('EpisodeMaybePlural'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'episodes.title',
|
||||
label: () => translate('EpisodeTitleMaybePlural'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'episodes.airDateUtc',
|
||||
label: () => translate('EpisodeAirDate'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: () => translate('Formats'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormatScore',
|
||||
columnLabel: () => translate('CustomFormatScore'),
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.SCORE,
|
||||
title: () => translate('CustomFormatScore'),
|
||||
}),
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'protocol',
|
||||
label: () => translate('Protocol'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'indexer',
|
||||
label: () => translate('Indexer'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'downloadClient',
|
||||
label: () => translate('DownloadClient'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
label: () => translate('ReleaseTitle'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
label: () => translate('Size'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'outputPath',
|
||||
label: () => translate('OutputPath'),
|
||||
isSortable: false,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'estimatedCompletionTime',
|
||||
label: () => translate('TimeLeft'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'added',
|
||||
label: () => translate('Added'),
|
||||
isSortable: true,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
name: 'progress',
|
||||
label: () => translate('Progress'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: '',
|
||||
columnLabel: () => translate('Actions'),
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
},
|
||||
],
|
||||
removalOptions: {
|
||||
removalMethod: 'removeFromClient',
|
||||
blocklistMethod: 'doNotBlocklist',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const useQueueOptions = useOptions;
|
||||
export const setQueueOptions = setOptions;
|
||||
export const useQueueOption = useOption;
|
||||
export const setQueueOption = setOption;
|
||||
203
frontend/src/Activity/Queue/useQueue.ts
Normal file
203
frontend/src/Activity/Queue/useQueue.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { keepPreviousData, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { CustomFilter, Filter, FilterBuilderProp } from 'App/State/AppState';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import usePage from 'Helpers/Hooks/usePage';
|
||||
import usePagedApiQuery from 'Helpers/Hooks/usePagedApiQuery';
|
||||
import { filterBuilderValueTypes } from 'Helpers/Props';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import Queue from 'typings/Queue';
|
||||
import getQueryString from 'Utilities/Fetch/getQueryString';
|
||||
import findSelectedFilters from 'Utilities/Filter/findSelectedFilters';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useQueueOptions } from './queueOptionsStore';
|
||||
|
||||
interface BulkQueueData {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
export const FILTERS: Filter[] = [
|
||||
{
|
||||
key: 'all',
|
||||
label: () => translate('All'),
|
||||
filters: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const FILTER_BUILDER: FilterBuilderProp<Queue>[] = [
|
||||
{
|
||||
name: 'seriesIds',
|
||||
label: () => translate('Series'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.SERIES,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.QUALITY,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
type: 'contains',
|
||||
valueType: filterBuilderValueTypes.LANGUAGE,
|
||||
},
|
||||
{
|
||||
name: 'protocol',
|
||||
label: () => translate('Protocol'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.PROTOCOL,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: () => translate('Status'),
|
||||
type: 'equal',
|
||||
valueType: filterBuilderValueTypes.QUEUE_STATUS,
|
||||
},
|
||||
];
|
||||
|
||||
const useQueue = () => {
|
||||
const { page, goToPage } = usePage('queue');
|
||||
const {
|
||||
includeUnknownSeriesItems,
|
||||
pageSize,
|
||||
selectedFilterKey,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
} = useQueueOptions();
|
||||
const customFilters = useSelector(
|
||||
createCustomFiltersSelector('queue')
|
||||
) as CustomFilter[];
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return findSelectedFilters(selectedFilterKey, FILTERS, customFilters);
|
||||
}, [selectedFilterKey, customFilters]);
|
||||
|
||||
const { refetch, ...query } = usePagedApiQuery<Queue>({
|
||||
path: '/queue',
|
||||
page,
|
||||
pageSize,
|
||||
filters,
|
||||
queryParams: {
|
||||
includeUnknownSeriesItems,
|
||||
},
|
||||
sortKey,
|
||||
sortDirection,
|
||||
queryOptions: {
|
||||
placeholderData: keepPreviousData,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...query,
|
||||
goToPage,
|
||||
page,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
|
||||
export default useQueue;
|
||||
|
||||
export const useFilters = () => {
|
||||
return FILTERS;
|
||||
};
|
||||
|
||||
const useRemovalOptions = () => {
|
||||
const { removalOptions } = useQueueOptions();
|
||||
|
||||
return {
|
||||
remove: removalOptions.removalMethod === 'removeFromClient',
|
||||
changeCategory: removalOptions.removalMethod === 'changeCategory',
|
||||
blocklist: removalOptions.blocklistMethod !== 'doNotBlocklist',
|
||||
skipRedownload: removalOptions.blocklistMethod === 'blocklistOnly',
|
||||
};
|
||||
};
|
||||
|
||||
export const useRemoveQueueItem = (id: number) => {
|
||||
const queryClient = useQueryClient();
|
||||
const removalOptions = useRemovalOptions();
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, void>({
|
||||
path: `/queue/${id}${getQueryString(removalOptions)}`,
|
||||
method: 'DELETE',
|
||||
mutationOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
removeQueueItem: mutate,
|
||||
isRemoving: isPending,
|
||||
};
|
||||
};
|
||||
|
||||
export const useRemoveQueueItems = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const removalOptions = useRemovalOptions();
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
|
||||
path: `/queue/bulk${getQueryString(removalOptions)}`,
|
||||
method: 'DELETE',
|
||||
mutationOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
removeQueueItems: mutate,
|
||||
isRemoving: isPending,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGrabQueueItem = (id: number) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [grabError, setGrabError] = useState<string | null>(null);
|
||||
|
||||
const { mutate, isPending } = useApiMutation<unknown, void>({
|
||||
path: `/queue/grab/${id}`,
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onMutate: () => {
|
||||
setGrabError(null);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
},
|
||||
onError: () => {
|
||||
setGrabError('Error grabbing queue item');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
grabQueueItem: mutate,
|
||||
isGrabbing: isPending,
|
||||
grabError,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGrabQueueItems = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Explicitly define the types for the mutation so we can pass in no arguments to mutate as expected.
|
||||
const { mutate, isPending } = useApiMutation<unknown, BulkQueueData>({
|
||||
path: '/queue/grab/bulk',
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
grabQueueItems: mutate,
|
||||
isGrabbing: isPending,
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AddSeries } from 'App/State/AddSeriesAppState';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Alert from 'Components/Alert';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
@@ -10,7 +9,6 @@ import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import useDebounce from 'Helpers/Hooks/useDebounce';
|
||||
import useQueryParams from 'Helpers/Hooks/useQueryParams';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
@@ -18,6 +16,7 @@ import { InputChanged } from 'typings/inputs';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AddNewSeriesSearchResult from './AddNewSeriesSearchResult';
|
||||
import { useLookupSeries } from './useAddSeries';
|
||||
import styles from './AddNewSeries.css';
|
||||
|
||||
function AddNewSeries() {
|
||||
@@ -48,12 +47,7 @@ function AddNewSeries() {
|
||||
isFetching: isFetchingApi,
|
||||
error,
|
||||
data = [],
|
||||
} = useApiQuery<AddSeries[]>({
|
||||
path: `/series/lookup?term=${query}`,
|
||||
queryOptions: {
|
||||
enabled: !!query,
|
||||
},
|
||||
});
|
||||
} = useLookupSeries(query);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFetching(isFetchingApi);
|
||||
@@ -103,7 +97,9 @@ function AddNewSeries() {
|
||||
{!isFetching && !error && !!data.length ? (
|
||||
<div className={styles.searchResults}>
|
||||
{data.map((item) => {
|
||||
return <AddNewSeriesSearchResult key={item.tvdbId} {...item} />;
|
||||
return (
|
||||
<AddNewSeriesSearchResult key={item.tvdbId} series={item} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AddSeries from 'AddSeries/AddSeries';
|
||||
import {
|
||||
AddSeriesOptions,
|
||||
setAddSeriesOption,
|
||||
useAddSeriesOptions,
|
||||
} from 'AddSeries/addSeriesOptionsStore';
|
||||
import SeriesMonitoringOptionsPopoverContent from 'AddSeries/SeriesMonitoringOptionsPopoverContent';
|
||||
import SeriesTypePopoverContent from 'AddSeries/SeriesTypePopoverContent';
|
||||
import { AddSeries } from 'App/State/AddSeriesAppState';
|
||||
import AppState from 'App/State/AppState';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
@@ -17,46 +21,39 @@ import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { SeriesType } from 'Series/Series';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import { addSeries, setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import useIsWindows from 'System/useIsWindows';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import { useAddSeries } from './useAddSeries';
|
||||
import styles from './AddNewSeriesModalContent.css';
|
||||
|
||||
export interface AddNewSeriesModalContentProps
|
||||
extends Pick<
|
||||
AddSeries,
|
||||
'tvdbId' | 'title' | 'year' | 'overview' | 'images' | 'folder'
|
||||
> {
|
||||
initialSeriesType: string;
|
||||
export interface AddNewSeriesModalContentProps {
|
||||
series: AddSeries;
|
||||
initialSeriesType: SeriesType;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function AddNewSeriesModalContent({
|
||||
tvdbId,
|
||||
title,
|
||||
year,
|
||||
overview,
|
||||
images,
|
||||
folder,
|
||||
series,
|
||||
initialSeriesType,
|
||||
onModalClose,
|
||||
}: AddNewSeriesModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { isAdding, addError, defaults } = useSelector(
|
||||
(state: AppState) => state.addSeries
|
||||
);
|
||||
const { title, year, overview, images, folder } = series;
|
||||
const options = useAddSeriesOptions();
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
const isWindows = useIsWindows();
|
||||
|
||||
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
||||
return selectSettings(defaults, {}, addError);
|
||||
}, [defaults, addError]);
|
||||
const { isAdding, addError, addSeries } = useAddSeries();
|
||||
|
||||
const [seriesType, setSeriesType] = useState(
|
||||
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
||||
return selectSettings(options, {}, addError);
|
||||
}, [options, addError]);
|
||||
|
||||
const [seriesType, setSeriesType] = useState<SeriesType>(
|
||||
initialSeriesType === 'standard'
|
||||
? settings.seriesType.value
|
||||
: initialSeriesType
|
||||
@@ -74,35 +71,33 @@ function AddNewSeriesModalContent({
|
||||
} = settings;
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
dispatch(setAddSeriesDefault({ [name]: value }));
|
||||
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
|
||||
setAddSeriesOption(name as keyof AddSeriesOptions, value);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleQualityProfileIdChange = useCallback(
|
||||
({ value }: InputChanged<string | number>) => {
|
||||
dispatch(setAddSeriesDefault({ qualityProfileId: value }));
|
||||
setAddSeriesOption('qualityProfileId', value as number);
|
||||
},
|
||||
[dispatch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAddSeriesPress = useCallback(() => {
|
||||
dispatch(
|
||||
addSeries({
|
||||
tvdbId,
|
||||
rootFolderPath: rootFolderPath.value,
|
||||
monitor: monitor.value,
|
||||
qualityProfileId: qualityProfileId.value,
|
||||
seriesType,
|
||||
seasonFolder: seasonFolder.value,
|
||||
searchForMissingEpisodes: searchForMissingEpisodes.value,
|
||||
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
|
||||
tags: tags.value,
|
||||
})
|
||||
);
|
||||
addSeries({
|
||||
...series,
|
||||
rootFolderPath: rootFolderPath.value,
|
||||
monitor: monitor.value,
|
||||
qualityProfileId: qualityProfileId.value,
|
||||
seriesType,
|
||||
seasonFolder: seasonFolder.value,
|
||||
searchForMissingEpisodes: searchForMissingEpisodes.value,
|
||||
searchForCutoffUnmetEpisodes: searchForCutoffUnmetEpisodes.value,
|
||||
tags: tags.value,
|
||||
});
|
||||
}, [
|
||||
tvdbId,
|
||||
series,
|
||||
seriesType,
|
||||
rootFolderPath,
|
||||
monitor,
|
||||
@@ -111,7 +106,7 @@ function AddNewSeriesModalContent({
|
||||
searchForMissingEpisodes,
|
||||
searchForCutoffUnmetEpisodes,
|
||||
tags,
|
||||
dispatch,
|
||||
addSeries,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AddSeries } from 'App/State/AddSeriesAppState';
|
||||
import AddSeries from 'AddSeries/AddSeries';
|
||||
import HeartRating from 'Components/HeartRating';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
@@ -16,24 +16,27 @@ import translate from 'Utilities/String/translate';
|
||||
import AddNewSeriesModal from './AddNewSeriesModal';
|
||||
import styles from './AddNewSeriesSearchResult.css';
|
||||
|
||||
type AddNewSeriesSearchResultProps = AddSeries;
|
||||
interface AddNewSeriesSearchResultProps {
|
||||
series: AddSeries;
|
||||
}
|
||||
|
||||
function AddNewSeriesSearchResult({ series }: AddNewSeriesSearchResultProps) {
|
||||
const {
|
||||
tvdbId,
|
||||
titleSlug,
|
||||
title,
|
||||
year,
|
||||
network,
|
||||
originalLanguage,
|
||||
genres = [],
|
||||
status,
|
||||
statistics = {} as Statistics,
|
||||
ratings,
|
||||
overview,
|
||||
seriesType,
|
||||
images,
|
||||
} = series;
|
||||
|
||||
function AddNewSeriesSearchResult({
|
||||
tvdbId,
|
||||
titleSlug,
|
||||
title,
|
||||
year,
|
||||
network,
|
||||
originalLanguage,
|
||||
genres = [],
|
||||
status,
|
||||
statistics = {} as Statistics,
|
||||
ratings,
|
||||
folder,
|
||||
overview,
|
||||
seriesType,
|
||||
images,
|
||||
}: AddNewSeriesSearchResultProps) {
|
||||
const isExistingSeries = useSelector(createExistingSeriesSelector(tvdbId));
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
const [isNewAddSeriesModalOpen, setIsNewAddSeriesModalOpen] = useState(false);
|
||||
@@ -168,13 +171,8 @@ function AddNewSeriesSearchResult({
|
||||
|
||||
<AddNewSeriesModal
|
||||
isOpen={isNewAddSeriesModalOpen && !isExistingSeries}
|
||||
tvdbId={tvdbId}
|
||||
title={title}
|
||||
year={year}
|
||||
overview={overview}
|
||||
folder={folder}
|
||||
series={series}
|
||||
initialSeriesType={seriesType}
|
||||
images={images}
|
||||
onModalClose={handleAddSeriesModalClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
51
frontend/src/AddSeries/AddNewSeries/useAddSeries.ts
Normal file
51
frontend/src/AddSeries/AddNewSeries/useAddSeries.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import AddSeries from 'AddSeries/AddSeries';
|
||||
import { AddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
||||
import useApiMutation from 'Helpers/Hooks/useApiMutation';
|
||||
import useApiQuery from 'Helpers/Hooks/useApiQuery';
|
||||
import Series from 'Series/Series';
|
||||
import { updateItem } from 'Store/Actions/baseActions';
|
||||
|
||||
type AddSeriesPayload = AddSeries & AddSeriesOptions;
|
||||
|
||||
export const useLookupSeries = (query: string) => {
|
||||
return useApiQuery<AddSeries[]>({
|
||||
path: '/series/lookup',
|
||||
queryParams: {
|
||||
term: query,
|
||||
},
|
||||
queryOptions: {
|
||||
enabled: !!query,
|
||||
// Disable refetch on window focus to prevent refetching when the user switch tabs
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useAddSeries = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onAddSuccess = useCallback(
|
||||
(data: Series) => {
|
||||
dispatch(updateItem({ section: 'series', ...data }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const { isPending, error, mutate } = useApiMutation<Series, AddSeriesPayload>(
|
||||
{
|
||||
path: '/series',
|
||||
method: 'POST',
|
||||
mutationOptions: {
|
||||
onSuccess: onAddSuccess,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
isAdding: isPending,
|
||||
addError: error,
|
||||
addSeries: mutate,
|
||||
};
|
||||
};
|
||||
7
frontend/src/AddSeries/AddSeries.ts
Normal file
7
frontend/src/AddSeries/AddSeries.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Series from 'Series/Series';
|
||||
|
||||
interface AddSeries extends Series {
|
||||
folder: string;
|
||||
}
|
||||
|
||||
export default AddSeries;
|
||||
@@ -1,6 +1,10 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
import {
|
||||
setAddSeriesOption,
|
||||
useAddSeriesOption,
|
||||
} from 'AddSeries/addSeriesOptionsStore';
|
||||
import { SelectProvider } from 'App/SelectContext';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Alert from 'Components/Alert';
|
||||
@@ -8,7 +12,6 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
|
||||
import { clearImportSeries } from 'Store/Actions/importSeriesActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -48,9 +51,7 @@ function ImportSeries() {
|
||||
(state: AppState) => state.settings.qualityProfiles.items
|
||||
);
|
||||
|
||||
const defaultQualityProfileId = useSelector(
|
||||
(state: AppState) => state.addSeries.defaults.qualityProfileId
|
||||
);
|
||||
const defaultQualityProfileId = useAddSeriesOption('qualityProfileId');
|
||||
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -76,9 +77,7 @@ function ImportSeries() {
|
||||
!defaultQualityProfileId ||
|
||||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
|
||||
) {
|
||||
dispatch(
|
||||
setAddSeriesDefault({ qualityProfileId: qualityProfiles[0].id })
|
||||
);
|
||||
setAddSeriesOption('qualityProfileId', qualityProfiles[0].id);
|
||||
}
|
||||
}, [defaultQualityProfileId, qualityProfiles, dispatch]);
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
AddSeriesOptions,
|
||||
setAddSeriesOption,
|
||||
useAddSeriesOptions,
|
||||
} from 'AddSeries/addSeriesOptionsStore';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import AppState from 'App/State/AppState';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
@@ -12,7 +17,6 @@ import PageContentFooter from 'Components/Page/PageContentFooter';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||
import { setAddSeriesDefault } from 'Store/Actions/addSeriesActions';
|
||||
import {
|
||||
cancelLookupSeries,
|
||||
importSeries,
|
||||
@@ -33,7 +37,7 @@ function ImportSeriesFooter() {
|
||||
qualityProfileId: defaultQualityProfileId,
|
||||
seriesType: defaultSeriesType,
|
||||
seasonFolder: defaultSeasonFolder,
|
||||
} = useSelector((state: AppState) => state.addSeries.defaults);
|
||||
} = useAddSeriesOptions();
|
||||
|
||||
const { isLookingUpSeries, isImporting, items, importError } = useSelector(
|
||||
(state: AppState) => state.importSeries
|
||||
@@ -110,7 +114,7 @@ function ImportSeriesFooter() {
|
||||
]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
({ name, value }: InputChanged<string | number | boolean | number[]>) => {
|
||||
if (name === 'monitor') {
|
||||
setMonitor(value as SeriesMonitor);
|
||||
} else if (name === 'qualityProfileId') {
|
||||
@@ -121,7 +125,7 @@ function ImportSeriesFooter() {
|
||||
setSeasonFolder(value as boolean);
|
||||
}
|
||||
|
||||
dispatch(setAddSeriesDefault({ [name]: value }));
|
||||
setAddSeriesOption(name as keyof AddSeriesOptions, value);
|
||||
|
||||
selectedIds.forEach((id) => {
|
||||
dispatch(
|
||||
|
||||
@@ -75,11 +75,6 @@ function ImportSeriesRow({ id }: ImportSeriesRowProps) {
|
||||
[selectDispatch]
|
||||
);
|
||||
|
||||
console.info(
|
||||
'\x1b[36m[MarkTest] is selected\x1b[0m',
|
||||
selectState.selectedState[id]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VirtualTableSelectCell
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { RefObject, useCallback, useEffect, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { FixedSizeList, ListChildComponentProps } from 'react-window';
|
||||
import { useAddSeriesOptions } from 'AddSeries/addSeriesOptionsStore';
|
||||
import { useSelect } from 'App/SelectContext';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { ImportSeries } from 'App/State/ImportSeriesAppState';
|
||||
@@ -59,9 +60,8 @@ function ImportSeriesTable({
|
||||
}: ImportSeriesTableProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { monitor, qualityProfileId, seriesType, seasonFolder } = useSelector(
|
||||
(state: AppState) => state.addSeries.defaults
|
||||
);
|
||||
const { monitor, qualityProfileId, seriesType, seasonFolder } =
|
||||
useAddSeriesOptions();
|
||||
|
||||
const items = useSelector((state: AppState) => state.importSeries.items);
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
FloatingPortal,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
} from '@floating-ui/react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
@@ -7,7 +15,6 @@ import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Portal from 'Components/Portal';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import {
|
||||
queueLookupSeries,
|
||||
@@ -47,9 +54,6 @@ function ImportSeriesSelectSeries({
|
||||
// @ts-expect-error - ignoring this for now
|
||||
} = useSelector(createImportSeriesItemSelector(id, { id }));
|
||||
|
||||
const buttonId = useId();
|
||||
const contentId = useId();
|
||||
const updater = useRef<(() => void) | null>(null);
|
||||
const seriesLookupTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const [term, setTerm] = useState('');
|
||||
@@ -57,37 +61,6 @@ function ImportSeriesSelectSeries({
|
||||
|
||||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
const handleWindowClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
const button = document.getElementById(buttonId);
|
||||
const content = document.getElementById(contentId);
|
||||
const eventTarget = event.target as HTMLElement;
|
||||
|
||||
if (!button || !eventTarget.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!button.contains(eventTarget) &&
|
||||
content &&
|
||||
!content.contains(eventTarget) &&
|
||||
isOpen
|
||||
) {
|
||||
setIsOpen(false);
|
||||
window.removeEventListener('click', handleWindowClick);
|
||||
}
|
||||
},
|
||||
[isOpen, buttonId, contentId, setIsOpen]
|
||||
);
|
||||
|
||||
const addListener = useCallback(() => {
|
||||
window.addEventListener('click', handleWindowClick);
|
||||
}, [handleWindowClick]);
|
||||
|
||||
const removeListener = useCallback(() => {
|
||||
window.removeEventListener('click', handleWindowClick);
|
||||
}, [handleWindowClick]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
}, []);
|
||||
@@ -147,157 +120,139 @@ function ImportSeriesSelectSeries({
|
||||
[id, items, dispatch, onInputChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (updater.current) {
|
||||
updater.current();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
addListener();
|
||||
} else {
|
||||
removeListener();
|
||||
}
|
||||
|
||||
return removeListener;
|
||||
}, [isOpen, addListener, removeListener]);
|
||||
|
||||
useEffect(() => {
|
||||
setTerm(itemTerm);
|
||||
}, [itemTerm]);
|
||||
|
||||
const { refs, context, floatingStyles } = useFloating({
|
||||
middleware: [
|
||||
flip({
|
||||
crossAxis: false,
|
||||
mainAxis: true,
|
||||
}),
|
||||
],
|
||||
open: isOpen,
|
||||
placement: 'bottom',
|
||||
whileElementsMounted: autoUpdate,
|
||||
onOpenChange: setIsOpen,
|
||||
});
|
||||
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
click,
|
||||
dismiss,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div ref={ref} id={buttonId}>
|
||||
<Link
|
||||
// ref={ref}
|
||||
className={styles.button}
|
||||
component="div"
|
||||
onPress={handlePress}
|
||||
>
|
||||
{isLookingUpSeries && isQueued && !isPopulated ? (
|
||||
<LoadingIndicator className={styles.loading} size={20} />
|
||||
) : null}
|
||||
<>
|
||||
<div ref={refs.setReference} {...getReferenceProps()}>
|
||||
<Link className={styles.button} component="div" onPress={handlePress}>
|
||||
{isLookingUpSeries && isQueued && !isPopulated ? (
|
||||
<LoadingIndicator className={styles.loading} size={20} />
|
||||
) : null}
|
||||
|
||||
{isPopulated && selectedSeries && isExistingSeries ? (
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
) : null}
|
||||
{isPopulated && selectedSeries && isExistingSeries ? (
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isPopulated && selectedSeries ? (
|
||||
<ImportSeriesTitle
|
||||
title={selectedSeries.title}
|
||||
year={selectedSeries.year}
|
||||
network={selectedSeries.network}
|
||||
isExistingSeries={isExistingSeries}
|
||||
/>
|
||||
) : null}
|
||||
{isPopulated && selectedSeries ? (
|
||||
<ImportSeriesTitle
|
||||
title={selectedSeries.title}
|
||||
year={selectedSeries.year}
|
||||
network={selectedSeries.network}
|
||||
isExistingSeries={isExistingSeries}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !selectedSeries ? (
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
{isPopulated && !selectedSeries ? (
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
|
||||
{translate('NoMatchFound')}
|
||||
</div>
|
||||
) : null}
|
||||
{translate('NoMatchFound')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isFetching && !!error ? (
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
title={errorMessage}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
{!isFetching && !!error ? (
|
||||
<div>
|
||||
<Icon
|
||||
className={styles.warningIcon}
|
||||
title={errorMessage}
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
/>
|
||||
|
||||
{translate('SearchFailedError')}
|
||||
</div>
|
||||
) : null}
|
||||
{translate('SearchFailedError')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.dropdownArrowContainer}>
|
||||
<Icon name={icons.CARET_DOWN} />
|
||||
</div>
|
||||
</Link>
|
||||
<div className={styles.dropdownArrowContainer}>
|
||||
<Icon name={icons.CARET_DOWN} />
|
||||
</div>
|
||||
)}
|
||||
</Reference>
|
||||
|
||||
<Portal>
|
||||
<Popper
|
||||
placement="bottom"
|
||||
modifiers={{
|
||||
preventOverflow: {
|
||||
boundariesElement: 'viewport',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ ref, style, scheduleUpdate }) => {
|
||||
updater.current = scheduleUpdate;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={contentId}
|
||||
className={styles.contentContainer}
|
||||
style={style}
|
||||
>
|
||||
{isOpen ? (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchIconContainer}>
|
||||
<Icon name={icons.SEARCH} />
|
||||
</div>
|
||||
|
||||
<TextInput
|
||||
className={styles.searchInput}
|
||||
name={`${name}_textInput`}
|
||||
value={term}
|
||||
onChange={handleSearchInputChange}
|
||||
/>
|
||||
|
||||
<FormInputButton
|
||||
kind={kinds.DEFAULT}
|
||||
spinnerIcon={icons.REFRESH}
|
||||
canSpin={true}
|
||||
isSpinning={isFetching}
|
||||
onPress={handleRefreshPress}
|
||||
>
|
||||
<Icon name={icons.REFRESH} />
|
||||
</FormInputButton>
|
||||
</div>
|
||||
|
||||
<div className={styles.results}>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<ImportSeriesSearchResult
|
||||
key={item.tvdbId}
|
||||
tvdbId={item.tvdbId}
|
||||
title={item.title}
|
||||
year={item.year}
|
||||
network={item.network}
|
||||
onPress={handleSeriesSelect}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<FloatingPortal id="portal-root">
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
className={styles.contentContainer}
|
||||
style={floatingStyles}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
{isOpen ? (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchIconContainer}>
|
||||
<Icon name={icons.SEARCH} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
className={styles.searchInput}
|
||||
name={`${name}_textInput`}
|
||||
value={term}
|
||||
onChange={handleSearchInputChange}
|
||||
/>
|
||||
|
||||
<FormInputButton
|
||||
kind={kinds.DEFAULT}
|
||||
spinnerIcon={icons.REFRESH}
|
||||
canSpin={true}
|
||||
isSpinning={isFetching}
|
||||
onPress={handleRefreshPress}
|
||||
>
|
||||
<Icon name={icons.REFRESH} />
|
||||
</FormInputButton>
|
||||
</div>
|
||||
|
||||
<div className={styles.results}>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<ImportSeriesSearchResult
|
||||
key={item.tvdbId}
|
||||
tvdbId={item.tvdbId}
|
||||
title={item.title}
|
||||
year={item.year}
|
||||
network={item.network}
|
||||
onPress={handleSeriesSelect}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
) : null}
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
31
frontend/src/AddSeries/addSeriesOptionsStore.ts
Normal file
31
frontend/src/AddSeries/addSeriesOptionsStore.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createOptionsStore } from 'Helpers/Hooks/useOptionsStore';
|
||||
import { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||
|
||||
export interface AddSeriesOptions {
|
||||
rootFolderPath: string;
|
||||
monitor: SeriesMonitor;
|
||||
qualityProfileId: number;
|
||||
seriesType: SeriesType;
|
||||
seasonFolder: boolean;
|
||||
searchForMissingEpisodes: boolean;
|
||||
searchForCutoffUnmetEpisodes: boolean;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
const { useOptions, useOption, setOption } =
|
||||
createOptionsStore<AddSeriesOptions>('add_series_options', () => {
|
||||
return {
|
||||
rootFolderPath: '',
|
||||
monitor: 'all',
|
||||
qualityProfileId: 0,
|
||||
seriesType: 'standard',
|
||||
seasonFolder: true,
|
||||
searchForMissingEpisodes: false,
|
||||
searchForCutoffUnmetEpisodes: false,
|
||||
tags: [],
|
||||
};
|
||||
});
|
||||
|
||||
export const useAddSeriesOptions = useOptions;
|
||||
export const useAddSeriesOption = useOption;
|
||||
export const setAddSeriesOption = setOption;
|
||||
@@ -11,6 +11,7 @@ import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { fetchUpdates } from 'Store/Actions/systemActions';
|
||||
import UpdateChanges from 'System/Updates/UpdateChanges';
|
||||
import useUpdates from 'System/Updates/useUpdates';
|
||||
import Update from 'typings/Update';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AppState from './State/AppState';
|
||||
@@ -65,14 +66,12 @@ interface AppUpdatedModalContentProps {
|
||||
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { version, prevVersion } = useSelector((state: AppState) => state.app);
|
||||
const { isPopulated, error, items } = useSelector(
|
||||
(state: AppState) => state.system.updates
|
||||
);
|
||||
const { isFetched, error, data } = useUpdates();
|
||||
const previousVersion = usePrevious(version);
|
||||
|
||||
const { onModalClose } = props;
|
||||
|
||||
const update = mergeUpdates(items, version, prevVersion);
|
||||
const update = mergeUpdates(data, version, prevVersion);
|
||||
|
||||
const handleSeeChangesPress = useCallback(() => {
|
||||
window.location.href = `${window.Sonarr.urlBase}/system/updates`;
|
||||
@@ -100,7 +99,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPopulated && !error && !!update ? (
|
||||
{isFetched && !error && !!update ? (
|
||||
<div>
|
||||
{update.changes ? (
|
||||
<div className={styles.maintenance}>
|
||||
@@ -126,7 +125,7 @@ function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isPopulated && !error ? <LoadingIndicator /> : null}
|
||||
{!isFetched && !error ? <LoadingIndicator /> : null}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import useTheme from 'Helpers/Hooks/useTheme';
|
||||
import themes from 'Styles/Themes';
|
||||
import AppState from './State/AppState';
|
||||
|
||||
function createThemeSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
|
||||
(theme) => {
|
||||
return theme;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function ApplyTheme() {
|
||||
const theme = useSelector(createThemeSelector());
|
||||
const theme = useTheme();
|
||||
|
||||
const updateCSSVariables = useCallback(() => {
|
||||
Object.entries(themes[theme]).forEach(([key, value]) => {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import AppSectionState, { Error } from 'App/State/AppSectionState';
|
||||
import Series, { SeriesMonitor, SeriesType } from 'Series/Series';
|
||||
|
||||
export interface AddSeries extends Series {
|
||||
folder: string;
|
||||
}
|
||||
|
||||
interface AddSeriesAppState extends AppSectionState<AddSeries> {
|
||||
isAdding: boolean;
|
||||
isAdded: boolean;
|
||||
addError: Error | undefined;
|
||||
|
||||
defaults: {
|
||||
rootFolderPath: string;
|
||||
monitor: SeriesMonitor;
|
||||
qualityProfileId: number;
|
||||
seriesType: SeriesType;
|
||||
seasonFolder: boolean;
|
||||
tags: number[];
|
||||
searchForMissingEpisodes: boolean;
|
||||
searchForCutoffUnmetEpisodes: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default AddSeriesAppState;
|
||||
@@ -1,7 +1,6 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import { FilterBuilderTypes } from 'Helpers/Props/filterBuilderTypes';
|
||||
import { DateFilterValue, FilterType } from 'Helpers/Props/filterTypes';
|
||||
import AddSeriesAppState from './AddSeriesAppState';
|
||||
import { Error } from './AppSectionState';
|
||||
import BlocklistAppState from './BlocklistAppState';
|
||||
import CalendarAppState from './CalendarAppState';
|
||||
@@ -19,7 +18,6 @@ import OrganizePreviewAppState from './OrganizePreviewAppState';
|
||||
import ParseAppState from './ParseAppState';
|
||||
import PathsAppState from './PathsAppState';
|
||||
import ProviderOptionsAppState from './ProviderOptionsAppState';
|
||||
import QueueAppState from './QueueAppState';
|
||||
import ReleasesAppState from './ReleasesAppState';
|
||||
import RootFolderAppState from './RootFolderAppState';
|
||||
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
||||
@@ -50,7 +48,6 @@ export interface PropertyFilter {
|
||||
export interface Filter {
|
||||
key: string;
|
||||
label: string | (() => string);
|
||||
type: string;
|
||||
filters: PropertyFilter[];
|
||||
}
|
||||
|
||||
@@ -83,7 +80,6 @@ export interface AppSectionState {
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
addSeries: AddSeriesAppState;
|
||||
app: AppSectionState;
|
||||
blocklist: BlocklistAppState;
|
||||
calendar: CalendarAppState;
|
||||
@@ -102,7 +98,6 @@ interface AppState {
|
||||
parse: ParseAppState;
|
||||
paths: PathsAppState;
|
||||
providerOptions: ProviderOptionsAppState;
|
||||
queue: QueueAppState;
|
||||
releases: ReleasesAppState;
|
||||
rootFolders: RootFolderAppState;
|
||||
series: SeriesAppState;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import LogEvent from 'typings/LogEvent';
|
||||
|
||||
interface LogsAppState
|
||||
extends AppSectionState<LogEvent>,
|
||||
AppSectionFilterState<LogEvent>,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState {}
|
||||
|
||||
export default LogsAppState;
|
||||
@@ -1,44 +0,0 @@
|
||||
import Queue from 'typings/Queue';
|
||||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
AppSectionItemState,
|
||||
Error,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState,
|
||||
} from './AppSectionState';
|
||||
|
||||
export interface QueueStatus {
|
||||
totalCount: number;
|
||||
count: number;
|
||||
unknownCount: number;
|
||||
errors: boolean;
|
||||
warnings: boolean;
|
||||
unknownErrors: boolean;
|
||||
unknownWarnings: boolean;
|
||||
}
|
||||
|
||||
export interface QueueDetailsAppState extends AppSectionState<Queue> {
|
||||
params: unknown;
|
||||
}
|
||||
|
||||
export interface QueuePagedAppState
|
||||
extends AppSectionState<Queue>,
|
||||
AppSectionFilterState<Queue>,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState {
|
||||
isGrabbing: boolean;
|
||||
grabError: Error;
|
||||
isRemoving: boolean;
|
||||
removeError: Error;
|
||||
}
|
||||
|
||||
interface QueueAppState {
|
||||
status: AppSectionItemState<QueueStatus>;
|
||||
details: QueueDetailsAppState;
|
||||
paged: QueuePagedAppState;
|
||||
options: {
|
||||
includeUnknownSeriesItems: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default QueueAppState;
|
||||
@@ -3,28 +3,23 @@ import Health from 'typings/Health';
|
||||
import LogFile from 'typings/LogFile';
|
||||
import SystemStatus from 'typings/SystemStatus';
|
||||
import Task from 'typings/Task';
|
||||
import Update from 'typings/Update';
|
||||
import AppSectionState, { AppSectionItemState } from './AppSectionState';
|
||||
import BackupAppState from './BackupAppState';
|
||||
import LogsAppState from './LogsAppState';
|
||||
|
||||
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>;
|
||||
export type UpdateAppState = AppSectionState<Update>;
|
||||
|
||||
interface SystemAppState {
|
||||
backups: BackupAppState;
|
||||
diskSpace: DiskSpaceAppState;
|
||||
health: HealthAppState;
|
||||
logFiles: LogFilesAppState;
|
||||
logs: LogsAppState;
|
||||
status: SystemStatusAppState;
|
||||
tasks: TaskAppState;
|
||||
updateLogFiles: LogFilesAppState;
|
||||
updates: UpdateAppState;
|
||||
}
|
||||
|
||||
export default SystemAppState;
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface TagDetail extends ModelBase {
|
||||
indexerIds: number[];
|
||||
notificationIds: number[];
|
||||
restrictionIds: number[];
|
||||
excludedReleaseProfileIds: number[];
|
||||
seriesIds: number[];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import AppState from 'App/State/AppState';
|
||||
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
@@ -13,7 +14,6 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
@@ -57,7 +57,7 @@ function AgendaEvent(props: AgendaEventProps) {
|
||||
|
||||
const series = useSeries(seriesId)!;
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(id));
|
||||
const queueItem = useQueueItemForEpisode(id);
|
||||
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
@@ -17,10 +17,6 @@ import {
|
||||
clearEpisodeFiles,
|
||||
fetchEpisodeFiles,
|
||||
} from 'Store/Actions/episodeFileActions';
|
||||
import {
|
||||
clearQueueDetails,
|
||||
fetchQueueDetails,
|
||||
} from 'Store/Actions/queueActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
@@ -74,7 +70,6 @@ function Calendar() {
|
||||
|
||||
return () => {
|
||||
dispatch(clearCalendar());
|
||||
dispatch(clearQueueDetails());
|
||||
dispatch(clearEpisodeFiles());
|
||||
clearTimeout(updateTimeout.current);
|
||||
};
|
||||
@@ -90,7 +85,6 @@ function Calendar() {
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchQueueDetails({ time, view }));
|
||||
dispatch(fetchCalendar({ time, view }));
|
||||
};
|
||||
|
||||
@@ -125,16 +119,11 @@ function Calendar() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!previousItems || hasDifferentItems(items, previousItems)) {
|
||||
const episodeIds = selectUniqueIds<Episode, number>(items, 'id');
|
||||
const episodeFileIds = selectUniqueIds<Episode, number>(
|
||||
items,
|
||||
'episodeFileId'
|
||||
);
|
||||
|
||||
if (items.length) {
|
||||
dispatch(fetchQueueDetails({ episodeIds }));
|
||||
}
|
||||
|
||||
if (episodeFileIds.length) {
|
||||
dispatch(fetchEpisodeFiles({ episodeFileIds }));
|
||||
}
|
||||
@@ -144,18 +133,15 @@ function Calendar() {
|
||||
return (
|
||||
<div className={styles.calendar}>
|
||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetching && error ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && view === 'agenda' ? (
|
||||
<div className={styles.calendarContent}>
|
||||
<CalendarHeader />
|
||||
<Agenda />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && view !== 'agenda' ? (
|
||||
<div className={styles.calendarContent}>
|
||||
<CalendarHeader />
|
||||
|
||||
78
frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx
Normal file
78
frontend/src/Calendar/CalendarMissingEpisodeSearchButton.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import moment from 'moment';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { useQueueDetails } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import AppState from 'App/State/AppState';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import Queue from 'typings/Queue';
|
||||
import { isCommandExecuting } from 'Utilities/Command';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
function createIsSearchingSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.searchMissingCommandId,
|
||||
createCommandsSelector(),
|
||||
(searchMissingCommandId, commands) => {
|
||||
if (searchMissingCommandId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isCommandExecuting(
|
||||
commands.find((command) => {
|
||||
return command.id === searchMissingCommandId;
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMissingEpisodeIdsSelector(queueDetails: Queue[]) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.start,
|
||||
(state: AppState) => state.calendar.end,
|
||||
(state: AppState) => state.calendar.items,
|
||||
(start, end, episodes) => {
|
||||
return episodes.reduce<number[]>((acc, episode) => {
|
||||
const airDateUtc = episode.airDateUtc;
|
||||
|
||||
if (
|
||||
!episode.episodeFileId &&
|
||||
moment(airDateUtc).isAfter(start) &&
|
||||
moment(airDateUtc).isBefore(end) &&
|
||||
isBefore(episode.airDateUtc) &&
|
||||
!queueDetails.some(
|
||||
(details) => !!details.episode && details.episode.id === episode.id
|
||||
)
|
||||
) {
|
||||
acc.push(episode.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default function CalendarMissingEpisodeSearchButton() {
|
||||
const queueDetails = useQueueDetails();
|
||||
const missingEpisodeIds = useSelector(
|
||||
createMissingEpisodeIdsSelector(queueDetails)
|
||||
);
|
||||
const isSearchingForMissing = useSelector(createIsSearchingSelector());
|
||||
|
||||
const handlePress = useCallback(() => {}, []);
|
||||
|
||||
return (
|
||||
<PageToolbarButton
|
||||
label={translate('SearchForMissing')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!missingEpisodeIds.length}
|
||||
isSpinning={isSearchingForMissing}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import QueueDetails from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
@@ -11,24 +10,23 @@ import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import Episode from 'Episode/Episode';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import NoSeries from 'Series/NoSeries';
|
||||
import {
|
||||
searchMissing,
|
||||
setCalendarDaysCount,
|
||||
setCalendarFilter,
|
||||
} from 'Store/Actions/calendarActions';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
|
||||
import { isCommandExecuting } from 'Utilities/Command';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Calendar from './Calendar';
|
||||
import CalendarFilterModal from './CalendarFilterModal';
|
||||
import CalendarMissingEpisodeSearchButton from './CalendarMissingEpisodeSearchButton';
|
||||
import CalendarLinkModal from './iCal/CalendarLinkModal';
|
||||
import Legend from './Legend/Legend';
|
||||
import CalendarOptionsModal from './Options/CalendarOptionsModal';
|
||||
@@ -36,60 +34,12 @@ import styles from './CalendarPage.css';
|
||||
|
||||
const MINIMUM_DAY_WIDTH = 120;
|
||||
|
||||
function createMissingEpisodeIdsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.start,
|
||||
(state: AppState) => state.calendar.end,
|
||||
(state: AppState) => state.calendar.items,
|
||||
(state: AppState) => state.queue.details.items,
|
||||
(start, end, episodes, queueDetails) => {
|
||||
return episodes.reduce<number[]>((acc, episode) => {
|
||||
const airDateUtc = episode.airDateUtc;
|
||||
|
||||
if (
|
||||
!episode.episodeFileId &&
|
||||
moment(airDateUtc).isAfter(start) &&
|
||||
moment(airDateUtc).isBefore(end) &&
|
||||
isBefore(episode.airDateUtc) &&
|
||||
!queueDetails.some(
|
||||
(details) => !!details.episode && details.episode.id === episode.id
|
||||
)
|
||||
) {
|
||||
acc.push(episode.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createIsSearchingSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.searchMissingCommandId,
|
||||
createCommandsSelector(),
|
||||
(searchMissingCommandId, commands) => {
|
||||
if (searchMissingCommandId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isCommandExecuting(
|
||||
commands.find((command) => {
|
||||
return command.id === searchMissingCommandId;
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarPage() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { selectedFilterKey, filters } = useSelector(
|
||||
const { selectedFilterKey, filters, items } = useSelector(
|
||||
(state: AppState) => state.calendar
|
||||
);
|
||||
const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector());
|
||||
const isSearchingForMissing = useSelector(createIsSearchingSelector());
|
||||
const isRssSyncExecuting = useSelector(
|
||||
createCommandExecutingSelector(commandNames.RSS_SYNC)
|
||||
);
|
||||
@@ -127,10 +77,6 @@ function CalendarPage() {
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSearchMissingPress = useCallback(() => {
|
||||
dispatch(searchMissing({ episodeIds: missingEpisodeIds }));
|
||||
}, [missingEpisodeIds, dispatch]);
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(key: string | number) => {
|
||||
dispatch(setCalendarFilter({ selectedFilterKey: key }));
|
||||
@@ -138,6 +84,10 @@ function CalendarPage() {
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const episodeIds = useMemo(() => {
|
||||
return selectUniqueIds<Episode, number>(items, 'id');
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (width === 0) {
|
||||
return;
|
||||
@@ -152,71 +102,67 @@ function CalendarPage() {
|
||||
}, [width, dispatch]);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('Calendar')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('ICalLink')}
|
||||
iconName={icons.CALENDAR}
|
||||
onPress={handleGetCalendarLinkPress}
|
||||
/>
|
||||
<QueueDetails episodeIds={episodeIds}>
|
||||
<PageContent title={translate('Calendar')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('ICalLink')}
|
||||
iconName={icons.CALENDAR}
|
||||
onPress={handleGetCalendarLinkPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
onPress={handleRssSyncPress}
|
||||
/>
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
onPress={handleRssSyncPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchForMissing')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!missingEpisodeIds.length}
|
||||
isSpinning={isSearchingForMissing}
|
||||
onPress={handleSearchMissingPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
<CalendarMissingEpisodeSearchButton />
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.POSTER}
|
||||
onPress={handleOptionsPress}
|
||||
/>
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.POSTER}
|
||||
onPress={handleOptionsPress}
|
||||
/>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
isDisabled={!hasSeries}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={CalendarFilterModal}
|
||||
onFilterSelect={handleFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
isDisabled={!hasSeries}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={CalendarFilterModal}
|
||||
onFilterSelect={handleFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody
|
||||
ref={pageContentRef}
|
||||
className={styles.calendarPageBody}
|
||||
innerClassName={styles.calendarInnerPageBody}
|
||||
>
|
||||
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
|
||||
{hasSeries && <Legend />}
|
||||
</PageContentBody>
|
||||
<PageContentBody
|
||||
ref={pageContentRef}
|
||||
className={styles.calendarPageBody}
|
||||
innerClassName={styles.calendarInnerPageBody}
|
||||
>
|
||||
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
|
||||
{hasSeries && <Legend />}
|
||||
</PageContentBody>
|
||||
|
||||
<CalendarLinkModal
|
||||
isOpen={isCalendarLinkModalOpen}
|
||||
onModalClose={handleGetCalendarLinkModalClose}
|
||||
/>
|
||||
<CalendarLinkModal
|
||||
isOpen={isCalendarLinkModalOpen}
|
||||
onModalClose={handleGetCalendarLinkModalClose}
|
||||
/>
|
||||
|
||||
<CalendarOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={handleOptionsModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
<CalendarOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={handleOptionsModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
</QueueDetails>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useQueueItemForEpisode } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import AppState from 'App/State/AppState';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
@@ -12,7 +13,6 @@ import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
@@ -58,7 +58,7 @@ function CalendarEvent(props: CalendarEventProps) {
|
||||
|
||||
const series = useSeries(seriesId);
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(id));
|
||||
const queueItem = useQueueItemForEpisode(id);
|
||||
|
||||
const { timeFormat, enableColorImpairedMode } = useSelector(
|
||||
createUISettingsSelector()
|
||||
|
||||
@@ -2,7 +2,7 @@ import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { useIsDownloadingEpisodes } from 'Activity/Queue/Details/QueueDetailsProvider';
|
||||
import AppState from 'App/State/AppState';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
@@ -18,17 +18,6 @@ import translate from 'Utilities/String/translate';
|
||||
import CalendarEvent from './CalendarEvent';
|
||||
import styles from './CalendarEventGroup.css';
|
||||
|
||||
function createIsDownloadingSelector(episodeIds: number[]) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.details,
|
||||
(details) => {
|
||||
return details.items.some((item) => {
|
||||
return !!(item.episodeId && episodeIds.includes(item.episodeId));
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface CalendarEventGroupProps {
|
||||
episodeIds: number[];
|
||||
seriesId: number;
|
||||
@@ -42,7 +31,7 @@ function CalendarEventGroup({
|
||||
events,
|
||||
onEventModalOpenToggle,
|
||||
}: CalendarEventGroupProps) {
|
||||
const isDownloading = useSelector(createIsDownloadingSelector(episodeIds));
|
||||
const isDownloading = useIsDownloadingEpisodes(episodeIds);
|
||||
const series = useSeries(seriesId)!;
|
||||
|
||||
const { timeFormat, enableColorImpairedMode } = useSelector(
|
||||
@@ -61,10 +50,10 @@ function CalendarEventGroup({
|
||||
const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
|
||||
const seasonNumber = firstEpisode.seasonNumber;
|
||||
|
||||
const { allDownloaded, anyQueued, anyMonitored, allAbsoluteEpisodeNumbers } =
|
||||
const { allDownloaded, anyGrabbed, anyMonitored, allAbsoluteEpisodeNumbers } =
|
||||
useMemo(() => {
|
||||
let files = 0;
|
||||
let queued = 0;
|
||||
let grabbed = 0;
|
||||
let monitored = 0;
|
||||
let absoluteEpisodeNumbers = 0;
|
||||
|
||||
@@ -73,8 +62,8 @@ function CalendarEventGroup({
|
||||
files++;
|
||||
}
|
||||
|
||||
if (event.queued) {
|
||||
queued++;
|
||||
if (event.grabbed) {
|
||||
grabbed++;
|
||||
}
|
||||
|
||||
if (series.monitored && event.monitored) {
|
||||
@@ -88,13 +77,13 @@ function CalendarEventGroup({
|
||||
|
||||
return {
|
||||
allDownloaded: files === events.length,
|
||||
anyQueued: queued > 0,
|
||||
anyGrabbed: grabbed > 0,
|
||||
anyMonitored: monitored > 0,
|
||||
allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length,
|
||||
};
|
||||
}, [series, events]);
|
||||
|
||||
const anyDownloading = isDownloading || anyQueued;
|
||||
const anyDownloading = isDownloading || anyGrabbed;
|
||||
|
||||
const statusStyle = getStatusStyle(
|
||||
allDownloaded,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
interface CalendarEventQueueDetailsProps {
|
||||
title: string;
|
||||
size: number;
|
||||
sizeleft: number;
|
||||
sizeLeft: number;
|
||||
estimatedCompletionTime?: string;
|
||||
status: string;
|
||||
trackedDownloadState: QueueTrackedDownloadState;
|
||||
@@ -22,7 +22,7 @@ interface CalendarEventQueueDetailsProps {
|
||||
function CalendarEventQueueDetails({
|
||||
title,
|
||||
size,
|
||||
sizeleft,
|
||||
sizeLeft,
|
||||
estimatedCompletionTime,
|
||||
status,
|
||||
trackedDownloadState,
|
||||
@@ -30,13 +30,13 @@ function CalendarEventQueueDetails({
|
||||
statusMessages,
|
||||
errorMessage,
|
||||
}: CalendarEventQueueDetailsProps) {
|
||||
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
|
||||
const progress = size ? 100 - (sizeLeft / size) * 100 : 0;
|
||||
|
||||
return (
|
||||
<QueueDetails
|
||||
title={title}
|
||||
size={size}
|
||||
sizeleft={sizeleft}
|
||||
sizeLeft={sizeLeft}
|
||||
estimatedCompletionTime={estimatedCompletionTime}
|
||||
status={status}
|
||||
trackedDownloadState={trackedDownloadState}
|
||||
|
||||
@@ -22,7 +22,12 @@ interface CalendarLinkModalContentProps {
|
||||
function CalendarLinkModalContent({
|
||||
onModalClose,
|
||||
}: CalendarLinkModalContentProps) {
|
||||
const [state, setState] = useState({
|
||||
const [state, setState] = useState<{
|
||||
unmonitored: boolean;
|
||||
premieresOnly: boolean;
|
||||
asAllDay: boolean;
|
||||
tags: number[];
|
||||
}>({
|
||||
unmonitored: false,
|
||||
premieresOnly: false,
|
||||
asAllDay: false,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { autoUpdate, flip, size, useFloating } from '@floating-ui/react-dom';
|
||||
import classNames from 'classnames';
|
||||
import React, {
|
||||
FocusEvent,
|
||||
@@ -19,8 +20,6 @@ import Autosuggest, {
|
||||
RenderInputComponentProps,
|
||||
RenderSuggestionsContainerParams,
|
||||
} from 'react-autosuggest';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import Portal from 'Components/Portal';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import styles from './AutoSuggestInput.css';
|
||||
@@ -37,7 +36,6 @@ interface AutoSuggestInputProps<T>
|
||||
hasError?: boolean;
|
||||
hasWarning?: boolean;
|
||||
enforceMaxHeight?: boolean;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
renderInputComponent?: (
|
||||
inputProps: RenderInputComponentProps,
|
||||
@@ -70,7 +68,6 @@ function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
|
||||
enforceMaxHeight = true,
|
||||
hasError,
|
||||
hasWarning,
|
||||
minHeight = 50,
|
||||
maxHeight = 200,
|
||||
getSuggestionValue,
|
||||
renderSuggestion,
|
||||
@@ -89,95 +86,60 @@ function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
|
||||
const updater = useRef<(() => void) | null>(null);
|
||||
const previousSuggestions = usePrevious(suggestions);
|
||||
|
||||
const handleComputeMaxHeight = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(data: any) => {
|
||||
const { top, bottom, width } = data.offsets.reference;
|
||||
|
||||
if (enforceMaxHeight) {
|
||||
data.styles.maxHeight = maxHeight;
|
||||
} else {
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (/^botton/.test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom;
|
||||
} else {
|
||||
data.styles.maxHeight = top;
|
||||
}
|
||||
}
|
||||
|
||||
data.styles.width = width;
|
||||
|
||||
return data;
|
||||
},
|
||||
[enforceMaxHeight, maxHeight]
|
||||
);
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
middleware: [
|
||||
flip({
|
||||
crossAxis: false,
|
||||
mainAxis: true,
|
||||
}),
|
||||
size({
|
||||
apply({ availableHeight, elements, rects }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
minWidth: `${rects.reference.width}px`,
|
||||
maxHeight: `${Math.max(0, availableHeight)}px`,
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
placement: 'bottom-start',
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
|
||||
const createRenderInputComponent = useCallback(
|
||||
(inputProps: RenderInputComponentProps) => {
|
||||
return (
|
||||
<Reference>
|
||||
{({ ref }) => {
|
||||
if (renderInputComponent) {
|
||||
return renderInputComponent(inputProps, ref);
|
||||
}
|
||||
if (renderInputComponent) {
|
||||
return renderInputComponent(inputProps, refs.setReference);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<input {...inputProps} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Reference>
|
||||
return (
|
||||
<div ref={refs.setReference}>
|
||||
<input {...inputProps} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[renderInputComponent]
|
||||
[refs.setReference, renderInputComponent]
|
||||
);
|
||||
|
||||
const renderSuggestionsContainer = useCallback(
|
||||
({ containerProps, children }: RenderSuggestionsContainerParams) => {
|
||||
return (
|
||||
<Portal>
|
||||
<Popper
|
||||
placement="bottom-start"
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: handleComputeMaxHeight,
|
||||
},
|
||||
flip: {
|
||||
padding: minHeight,
|
||||
},
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
className={children ? styles.suggestionsContainerOpen : undefined}
|
||||
>
|
||||
<div
|
||||
{...containerProps}
|
||||
style={{
|
||||
maxHeight: enforceMaxHeight ? maxHeight : undefined,
|
||||
}}
|
||||
>
|
||||
{({ ref: popperRef, style, scheduleUpdate }) => {
|
||||
updater.current = scheduleUpdate;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popperRef}
|
||||
style={style}
|
||||
className={
|
||||
children ? styles.suggestionsContainerOpen : undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
{...containerProps}
|
||||
style={{
|
||||
maxHeight: style.maxHeight,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[minHeight, handleComputeMaxHeight]
|
||||
[enforceMaxHeight, floatingStyles, maxHeight, refs.setFloating]
|
||||
);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
@@ -236,23 +198,21 @@ function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
|
||||
}, [suggestions, previousSuggestions]);
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Autosuggest
|
||||
{...otherProps}
|
||||
ref={forwardedRef}
|
||||
id={name}
|
||||
inputProps={inputProps}
|
||||
theme={theme}
|
||||
suggestions={suggestions}
|
||||
getSuggestionValue={getSuggestionValue}
|
||||
renderInputComponent={createRenderInputComponent}
|
||||
renderSuggestionsContainer={renderSuggestionsContainer}
|
||||
renderSuggestion={renderSuggestion}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
/>
|
||||
</Manager>
|
||||
<Autosuggest
|
||||
{...otherProps}
|
||||
ref={forwardedRef}
|
||||
id={name}
|
||||
inputProps={inputProps}
|
||||
theme={theme}
|
||||
suggestions={suggestions}
|
||||
getSuggestionValue={getSuggestionValue}
|
||||
renderInputComponent={createRenderInputComponent}
|
||||
renderSuggestionsContainer={renderSuggestionsContainer}
|
||||
renderSuggestion={renderSuggestion}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
19
frontend/src/Components/Form/FloatInput.tsx
Normal file
19
frontend/src/Components/Form/FloatInput.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import NumberInput, { NumberInputChanged } from './NumberInput';
|
||||
|
||||
export interface FloatInputProps {
|
||||
name: string;
|
||||
value?: number | null;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onChange: (change: NumberInputChanged) => void;
|
||||
}
|
||||
|
||||
function FloatInput(props: FloatInputProps) {
|
||||
return <NumberInput {...props} isFloat={true} />;
|
||||
}
|
||||
|
||||
export default FloatInput;
|
||||
@@ -7,6 +7,7 @@ import translate from 'Utilities/String/translate';
|
||||
import AutoCompleteInput, { AutoCompleteInputProps } from './AutoCompleteInput';
|
||||
import CaptchaInput, { CaptchaInputProps } from './CaptchaInput';
|
||||
import CheckInput, { CheckInputProps } from './CheckInput';
|
||||
import FloatInput, { FloatInputProps } from './FloatInput';
|
||||
import { FormInputButtonProps } from './FormInputButton';
|
||||
import FormInputHelpText from './FormInputHelpText';
|
||||
import KeyValueListInput, { KeyValueListInputProps } from './KeyValueListInput';
|
||||
@@ -65,7 +66,7 @@ const componentMap: Record<InputType, ElementType> = {
|
||||
downloadClientSelect: DownloadClientSelectInput,
|
||||
dynamicSelect: ProviderDataSelectInput,
|
||||
file: TextInput,
|
||||
float: NumberInput,
|
||||
float: FloatInput,
|
||||
indexerFlagsSelect: IndexerFlagsSelectInput,
|
||||
indexerSelect: IndexerSelectInput,
|
||||
keyValueList: KeyValueListInput,
|
||||
@@ -110,7 +111,7 @@ type PickProps<V, C extends InputType> = C extends 'text'
|
||||
: C extends 'file'
|
||||
? TextInputProps
|
||||
: C extends 'float'
|
||||
? TextInputProps
|
||||
? FloatInputProps
|
||||
: C extends 'indexerFlagsSelect'
|
||||
? IndexerFlagsSelectInputProps
|
||||
: C extends 'indexerSelect'
|
||||
@@ -139,11 +140,11 @@ type PickProps<V, C extends InputType> = C extends 'text'
|
||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
EnhancedSelectInputProps<any, V>
|
||||
: C extends 'seriesTag'
|
||||
? SeriesTagInputProps
|
||||
? SeriesTagInputProps<V>
|
||||
: C extends 'seriesTypeSelect'
|
||||
? SeriesTypeSelectInputProps
|
||||
: C extends 'tag'
|
||||
? SeriesTagInputProps
|
||||
? SeriesTagInputProps<V>
|
||||
: C extends 'tagSelect'
|
||||
? TagSelectInputProps
|
||||
: C extends 'text'
|
||||
@@ -222,7 +223,7 @@ function FormInputGroup<T, C extends InputType>(
|
||||
<div className={containerClassName}>
|
||||
<div className={className}>
|
||||
<div className={styles.inputContainer}>
|
||||
{/* @ts-expect-error - tpyes are validated already */}
|
||||
{/* @ts-expect-error - types are validated already */}
|
||||
<InputComponent
|
||||
className={inputClassName}
|
||||
helpText={helpText}
|
||||
|
||||
@@ -24,13 +24,17 @@ function parseValue(
|
||||
return newValue;
|
||||
}
|
||||
|
||||
export interface NumberInputChanged extends InputChanged<number | null> {
|
||||
isFloat?: boolean;
|
||||
}
|
||||
|
||||
export interface NumberInputProps
|
||||
extends Omit<TextInputProps, 'value' | 'onChange'> {
|
||||
value?: number | null;
|
||||
min?: number;
|
||||
max?: number;
|
||||
isFloat?: boolean;
|
||||
onChange: (input: InputChanged<number | null>) => void;
|
||||
onChange: (change: NumberInputChanged) => void;
|
||||
}
|
||||
|
||||
function NumberInput({
|
||||
@@ -50,11 +54,14 @@ function NumberInput({
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ name, value: newValue }: InputChanged<string>) => {
|
||||
setValue(newValue);
|
||||
const parsedValue = parseValue(newValue, isFloat, min, max);
|
||||
|
||||
setValue(parsedValue == null ? '' : parsedValue.toString());
|
||||
|
||||
onChange({
|
||||
name,
|
||||
value: parseValue(newValue, isFloat, min, max),
|
||||
value: parsedValue,
|
||||
isFloat,
|
||||
});
|
||||
},
|
||||
[isFloat, min, max, onChange, setValue]
|
||||
@@ -75,6 +82,7 @@ function NumberInput({
|
||||
onChange({
|
||||
name,
|
||||
value: parsedValue,
|
||||
isFloat,
|
||||
});
|
||||
|
||||
isFocused.current = false;
|
||||
|
||||
@@ -120,7 +120,7 @@ function ProviderFieldFormGroup<T>({
|
||||
helpTextWarning={helpTextWarning}
|
||||
helpLink={helpLink}
|
||||
placeholder={placeholder}
|
||||
// @ts-expect-error - this isn;'t available on all types
|
||||
// @ts-expect-error - this isn't available on all types
|
||||
selectOptionsProviderAction={selectOptionsProviderAction}
|
||||
value={value}
|
||||
values={selectValues}
|
||||
|
||||
@@ -42,15 +42,10 @@
|
||||
color: var(--disabledInputColor);
|
||||
}
|
||||
|
||||
.optionsContainer {
|
||||
z-index: $popperZIndex;
|
||||
max-height: vh(50);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.options {
|
||||
composes: scroller from '~Components/Scroller/Scroller.css';
|
||||
|
||||
z-index: $popperZIndex;
|
||||
border: 1px solid var(--inputBorderColor);
|
||||
border-radius: 4px;
|
||||
background-color: var(--inputBackgroundColor);
|
||||
|
||||
@@ -13,7 +13,6 @@ interface CssExports {
|
||||
'mobileCloseButton': string;
|
||||
'mobileCloseButtonContainer': string;
|
||||
'options': string;
|
||||
'optionsContainer': string;
|
||||
'optionsInnerModalBody': string;
|
||||
'optionsModal': string;
|
||||
'optionsModalBody': string;
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
FloatingPortal,
|
||||
size,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
} from '@floating-ui/react';
|
||||
import classNames from 'classnames';
|
||||
import React, {
|
||||
ElementType,
|
||||
@@ -6,31 +16,24 @@ import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import Portal from 'Components/Portal';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import ArrayElement from 'typings/Helpers/ArrayElement';
|
||||
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
|
||||
import { isMobile as isMobileUtil } from 'Utilities/browser';
|
||||
import * as keyCodes from 'Utilities/Constants/keyCodes';
|
||||
import getUniqueElementId from 'Utilities/getUniqueElementId';
|
||||
import TextInput from '../TextInput';
|
||||
import HintedSelectInputOption from './HintedSelectInputOption';
|
||||
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
|
||||
import styles from './EnhancedSelectInput.css';
|
||||
|
||||
const MINIMUM_DISTANCE_FROM_EDGE = 30;
|
||||
|
||||
function isArrowKey(keyCode: number) {
|
||||
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
|
||||
}
|
||||
@@ -162,10 +165,6 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
||||
onOpen,
|
||||
} = props;
|
||||
|
||||
const [measureRef, { width }] = useMeasure();
|
||||
const updater = useRef<(() => void) | null>(null);
|
||||
const buttonId = useMemo(() => getUniqueElementId(), []);
|
||||
const optionsId = useMemo(() => getUniqueElementId(), []);
|
||||
const [selectedIndex, setSelectedIndex] = useState(
|
||||
getSelectedIndex(value, values)
|
||||
);
|
||||
@@ -175,6 +174,38 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
||||
const isMultiSelect = Array.isArray(value);
|
||||
const selectedOption = getSelectedOption(selectedIndex, values);
|
||||
|
||||
const { refs, context, floatingStyles } = useFloating({
|
||||
middleware: [
|
||||
flip({
|
||||
crossAxis: false,
|
||||
mainAxis: true,
|
||||
}),
|
||||
size({
|
||||
apply({ availableHeight, elements, rects }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
minWidth: `${rects.reference.width}px`,
|
||||
maxHeight: `${Math.max(
|
||||
0,
|
||||
Math.min(window.innerHeight / 2, availableHeight)
|
||||
)}px`,
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
open: isOpen,
|
||||
placement: 'bottom-start',
|
||||
whileElementsMounted: autoUpdate,
|
||||
onOpenChange: setIsOpen,
|
||||
});
|
||||
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
click,
|
||||
dismiss,
|
||||
]);
|
||||
|
||||
const selectedValue = useMemo(() => {
|
||||
if (values.length) {
|
||||
return value;
|
||||
@@ -189,59 +220,9 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
||||
return '';
|
||||
}, [value, values, isMultiSelect]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleComputeMaxHeight = useCallback((data: any) => {
|
||||
const { top, bottom } = data.offsets.reference;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (/^bottom/.test(data.placement)) {
|
||||
data.styles.maxHeight =
|
||||
windowHeight - bottom - MINIMUM_DISTANCE_FROM_EDGE;
|
||||
} else {
|
||||
data.styles.maxHeight = top - MINIMUM_DISTANCE_FROM_EDGE;
|
||||
}
|
||||
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
const handleWindowClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
const button = document.getElementById(buttonId);
|
||||
const options = document.getElementById(optionsId);
|
||||
const eventTarget = event.target as HTMLElement;
|
||||
|
||||
if (!button || !eventTarget.isConnected || isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!button.contains(eventTarget) &&
|
||||
options &&
|
||||
!options.contains(eventTarget) &&
|
||||
isOpen
|
||||
) {
|
||||
setIsOpen(false);
|
||||
window.removeEventListener('click', handleWindowClick);
|
||||
}
|
||||
},
|
||||
[isMobile, isOpen, buttonId, optionsId, setIsOpen]
|
||||
);
|
||||
|
||||
const addListener = useCallback(() => {
|
||||
window.addEventListener('click', handleWindowClick);
|
||||
}, [handleWindowClick]);
|
||||
|
||||
const removeListener = useCallback(() => {
|
||||
window.removeEventListener('click', handleWindowClick);
|
||||
}, [handleWindowClick]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!isOpen && onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen);
|
||||
}, [isOpen, setIsOpen, onOpen]);
|
||||
setIsOpen((prevIsOpen) => !prevIsOpen);
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(newValue: ArrayElement<V>) => {
|
||||
@@ -298,10 +279,9 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (isOpen) {
|
||||
removeListener();
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [isOpen, setIsOpen, removeListener]);
|
||||
}, [isOpen, setIsOpen]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
@@ -395,172 +375,122 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
||||
[onChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (updater.current) {
|
||||
updater.current();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
addListener();
|
||||
} else {
|
||||
removeListener();
|
||||
onOpen?.();
|
||||
}
|
||||
|
||||
return removeListener;
|
||||
}, [isOpen, addListener, removeListener]);
|
||||
}, [isOpen, onOpen]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div ref={ref} id={buttonId}>
|
||||
<div ref={measureRef}>
|
||||
{isEditable && typeof value === 'string' ? (
|
||||
<div className={styles.editableContainer}>
|
||||
<TextInput
|
||||
className={className}
|
||||
name={name}
|
||||
value={value}
|
||||
readOnly={isDisabled}
|
||||
hasError={hasError}
|
||||
hasWarning={hasWarning}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.dropdownArrowContainerEditable,
|
||||
isDisabled
|
||||
? styles.dropdownArrowContainerDisabled
|
||||
: styles.dropdownArrowContainer
|
||||
)}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{isFetching ? (
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
) : null}
|
||||
<>
|
||||
<div ref={refs.setReference} {...getReferenceProps()}>
|
||||
{isEditable && typeof value === 'string' ? (
|
||||
<div className={styles.editableContainer}>
|
||||
<TextInput
|
||||
className={className}
|
||||
name={name}
|
||||
value={value}
|
||||
readOnly={isDisabled}
|
||||
hasError={hasError}
|
||||
hasWarning={hasWarning}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleEditChange}
|
||||
/>
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.dropdownArrowContainerEditable,
|
||||
isDisabled
|
||||
? styles.dropdownArrowContainerDisabled
|
||||
: styles.dropdownArrowContainer
|
||||
)}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{isFetching ? (
|
||||
<LoadingIndicator className={styles.loading} size={20} />
|
||||
) : null}
|
||||
|
||||
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
isDisabled && disabledClassName
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<SelectedValueComponent
|
||||
values={values}
|
||||
{...selectedValueOptions}
|
||||
selectedValue={selectedValue}
|
||||
isDisabled={isDisabled}
|
||||
isMultiSelect={isMultiSelect}
|
||||
>
|
||||
{selectedOption ? selectedOption.value : selectedValue}
|
||||
</SelectedValueComponent>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDisabled
|
||||
? styles.dropdownArrowContainerDisabled
|
||||
: styles.dropdownArrowContainer
|
||||
}
|
||||
>
|
||||
{isFetching ? (
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Reference>
|
||||
<Portal>
|
||||
<Popper
|
||||
placement="bottom-start"
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: handleComputeMaxHeight,
|
||||
},
|
||||
}}
|
||||
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning,
|
||||
isDisabled && disabledClassName
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{({ ref, style, scheduleUpdate }) => {
|
||||
updater.current = scheduleUpdate;
|
||||
<SelectedValueComponent
|
||||
values={values}
|
||||
{...selectedValueOptions}
|
||||
selectedValue={selectedValue}
|
||||
isDisabled={isDisabled}
|
||||
isMultiSelect={isMultiSelect}
|
||||
>
|
||||
{selectedOption ? selectedOption.value : selectedValue}
|
||||
</SelectedValueComponent>
|
||||
|
||||
<div
|
||||
className={
|
||||
isDisabled
|
||||
? styles.dropdownArrowContainerDisabled
|
||||
: styles.dropdownArrowContainer
|
||||
}
|
||||
>
|
||||
{isFetching ? (
|
||||
<LoadingIndicator className={styles.loading} size={20} />
|
||||
) : null}
|
||||
|
||||
{isFetching ? null : <Icon name={icons.CARET_DOWN} />}
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isMobile && isOpen ? (
|
||||
<FloatingPortal id="portal-root">
|
||||
<Scroller
|
||||
ref={refs.setFloating}
|
||||
className={styles.options}
|
||||
style={floatingStyles}
|
||||
{...getFloatingProps()}
|
||||
>
|
||||
{values.map((v, index) => {
|
||||
const hasParent = v.parentKey !== undefined;
|
||||
const depth = hasParent ? 1 : 0;
|
||||
const parentSelected =
|
||||
v.parentKey !== undefined &&
|
||||
Array.isArray(value) &&
|
||||
value.includes(v.parentKey);
|
||||
|
||||
const { key, ...other } = v;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id={optionsId}
|
||||
className={styles.optionsContainer}
|
||||
style={{
|
||||
...style,
|
||||
minWidth: width,
|
||||
}}
|
||||
<OptionComponent
|
||||
key={v.key}
|
||||
id={v.key}
|
||||
depth={depth}
|
||||
isSelected={isSelectedItem(index, value, values)}
|
||||
isDisabled={parentSelected}
|
||||
isMultiSelect={isMultiSelect}
|
||||
{...valueOptions}
|
||||
{...other}
|
||||
isMobile={false}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{isOpen && !isMobile ? (
|
||||
<Scroller
|
||||
className={styles.options}
|
||||
style={{
|
||||
maxHeight: style.maxHeight,
|
||||
}}
|
||||
>
|
||||
{values.map((v, index) => {
|
||||
const hasParent = v.parentKey !== undefined;
|
||||
const depth = hasParent ? 1 : 0;
|
||||
const parentSelected =
|
||||
v.parentKey !== undefined &&
|
||||
Array.isArray(value) &&
|
||||
value.includes(v.parentKey);
|
||||
|
||||
const { key, ...other } = v;
|
||||
|
||||
return (
|
||||
<OptionComponent
|
||||
key={v.key}
|
||||
id={v.key}
|
||||
depth={depth}
|
||||
isSelected={isSelectedItem(index, value, values)}
|
||||
isDisabled={parentSelected}
|
||||
isMultiSelect={isMultiSelect}
|
||||
{...valueOptions}
|
||||
{...other}
|
||||
isMobile={false}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{v.value}
|
||||
</OptionComponent>
|
||||
);
|
||||
})}
|
||||
</Scroller>
|
||||
) : null}
|
||||
</div>
|
||||
{v.value}
|
||||
</OptionComponent>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
})}
|
||||
</Scroller>
|
||||
</FloatingPortal>
|
||||
) : null}
|
||||
|
||||
{isMobile ? (
|
||||
<Modal
|
||||
@@ -615,7 +545,7 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ function HintedSelectInputOption(props: HintedSelectInputOptionProps) {
|
||||
hint,
|
||||
depth,
|
||||
isSelected = false,
|
||||
isMultiSelect,
|
||||
isMobile,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
@@ -88,13 +88,10 @@ function QualityProfileSelectInput({
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
({ value: newValue }: EnhancedSelectInputChanged<string | number>) => {
|
||||
onChange({
|
||||
name,
|
||||
value: newValue === 'noChange' ? value : newValue,
|
||||
});
|
||||
({ value }: EnhancedSelectInputChanged<string | number>) => {
|
||||
onChange({ name, value });
|
||||
},
|
||||
[name, value, onChange]
|
||||
[name, onChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -21,6 +21,7 @@ const ADD_NEW_KEY = 'addNew';
|
||||
|
||||
export interface RootFolderSelectInputValue
|
||||
extends EnhancedSelectInputValue<string> {
|
||||
freeSpace?: number;
|
||||
isMissing?: boolean;
|
||||
}
|
||||
|
||||
@@ -42,66 +43,58 @@ function createRootFolderOptionsSelector(
|
||||
includeNoChange: boolean,
|
||||
includeNoChangeDisabled: boolean
|
||||
) {
|
||||
return createSelector(
|
||||
createRootFoldersSelector(),
|
||||
|
||||
(rootFolders) => {
|
||||
const values: RootFolderSelectInputValue[] = rootFolders.items.map(
|
||||
(rootFolder) => {
|
||||
return {
|
||||
key: rootFolder.path,
|
||||
value: rootFolder.path,
|
||||
freeSpace: rootFolder.freeSpace,
|
||||
isMissing: false,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
isDisabled: includeNoChangeDisabled,
|
||||
return createSelector(createRootFoldersSelector(), (rootFolders) => {
|
||||
const values: RootFolderSelectInputValue[] = rootFolders.items.map(
|
||||
(rootFolder) => {
|
||||
return {
|
||||
key: rootFolder.path,
|
||||
value: rootFolder.path,
|
||||
freeSpace: rootFolder.freeSpace,
|
||||
isMissing: false,
|
||||
});
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
if (!values.length) {
|
||||
values.push({
|
||||
key: '',
|
||||
value: '',
|
||||
isDisabled: true,
|
||||
isHidden: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
includeMissingValue &&
|
||||
value &&
|
||||
!values.find((v) => v.key === value)
|
||||
) {
|
||||
values.push({
|
||||
key: value,
|
||||
value,
|
||||
isMissing: true,
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
values.push({
|
||||
key: ADD_NEW_KEY,
|
||||
value: translate('AddANewPath'),
|
||||
if (includeNoChange) {
|
||||
values.unshift({
|
||||
key: 'noChange',
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
isDisabled: includeNoChangeDisabled,
|
||||
isMissing: false,
|
||||
});
|
||||
|
||||
return {
|
||||
values,
|
||||
isSaving: rootFolders.isSaving,
|
||||
saveError: rootFolders.saveError,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
if (!values.length) {
|
||||
values.push({
|
||||
key: '',
|
||||
value: '',
|
||||
isDisabled: true,
|
||||
isHidden: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeMissingValue && value && !values.find((v) => v.key === value)) {
|
||||
values.push({
|
||||
key: value,
|
||||
value,
|
||||
isMissing: true,
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
values.push({
|
||||
key: ADD_NEW_KEY,
|
||||
value: translate('AddANewPath'),
|
||||
});
|
||||
|
||||
return {
|
||||
values,
|
||||
isSaving: rootFolders.isSaving,
|
||||
saveError: rootFolders.saveError,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function RootFolderSelectInput({
|
||||
|
||||
@@ -18,18 +18,16 @@ interface RootFolderSelectInputOptionProps
|
||||
isWindows?: boolean;
|
||||
}
|
||||
|
||||
function RootFolderSelectInputOption(props: RootFolderSelectInputOptionProps) {
|
||||
const {
|
||||
id,
|
||||
value,
|
||||
freeSpace,
|
||||
isMissing,
|
||||
seriesFolder,
|
||||
isMobile,
|
||||
isWindows,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
function RootFolderSelectInputOption({
|
||||
id,
|
||||
value,
|
||||
freeSpace,
|
||||
isMissing,
|
||||
seriesFolder,
|
||||
isMobile,
|
||||
isWindows,
|
||||
...otherProps
|
||||
}: RootFolderSelectInputOptionProps) {
|
||||
const slashCharacter = isWindows ? '\\' : '/';
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,3 +30,11 @@
|
||||
text-align: right;
|
||||
font-size: $smallFontSize;
|
||||
}
|
||||
|
||||
.isMissing {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 15px;
|
||||
color: var(--dangerColor);
|
||||
text-align: right;
|
||||
font-size: $smallFontSize;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'freeSpace': string;
|
||||
'isMissing': string;
|
||||
'path': string;
|
||||
'pathContainer': string;
|
||||
'selectedValue': string;
|
||||
|
||||
@@ -8,27 +8,23 @@ import styles from './RootFolderSelectInputSelectedValue.css';
|
||||
interface RootFolderSelectInputSelectedValueProps {
|
||||
selectedValue: string;
|
||||
values: RootFolderSelectInputValue[];
|
||||
freeSpace?: number;
|
||||
seriesFolder?: string;
|
||||
isWindows?: boolean;
|
||||
includeFreeSpace?: boolean;
|
||||
}
|
||||
|
||||
function RootFolderSelectInputSelectedValue(
|
||||
props: RootFolderSelectInputSelectedValueProps
|
||||
) {
|
||||
const {
|
||||
selectedValue,
|
||||
values,
|
||||
freeSpace,
|
||||
seriesFolder,
|
||||
includeFreeSpace = true,
|
||||
isWindows,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
function RootFolderSelectInputSelectedValue({
|
||||
selectedValue,
|
||||
values,
|
||||
seriesFolder,
|
||||
includeFreeSpace = true,
|
||||
isWindows,
|
||||
...otherProps
|
||||
}: RootFolderSelectInputSelectedValueProps) {
|
||||
const slashCharacter = isWindows ? '\\' : '/';
|
||||
const value = values.find((v) => v.key === selectedValue)?.value;
|
||||
const { value, freeSpace, isMissing } =
|
||||
values.find((v) => v.key === selectedValue) ||
|
||||
({} as RootFolderSelectInputValue);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInputSelectedValue
|
||||
@@ -53,6 +49,10 @@ function RootFolderSelectInputSelectedValue(
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isMissing ? (
|
||||
<div className={styles.isMissing}>{translate('Missing')}</div>
|
||||
) : null}
|
||||
</EnhancedSelectInputSelectedValue>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import React, { SyntheticEvent } from 'react';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
import EnhancedSelectInput, {
|
||||
EnhancedSelectInputValue,
|
||||
} from './EnhancedSelectInput';
|
||||
import styles from './UMaskInput.css';
|
||||
|
||||
const umaskOptions = [
|
||||
const umaskOptions: EnhancedSelectInputValue<string>[] = [
|
||||
{
|
||||
key: '755',
|
||||
get value() {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { ChangeEvent, SyntheticEvent, useCallback } from 'react';
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
ComponentProps,
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import styles from './SelectInput.css';
|
||||
|
||||
interface SelectInputOption {
|
||||
export interface SelectInputOption
|
||||
extends Pick<ComponentProps<'option'>, 'disabled'> {
|
||||
key: string | number;
|
||||
value: string | number | (() => string | number);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { addTag } from 'Store/Actions/tagActions';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import TagInput, { TagBase } from './TagInput';
|
||||
import TagInput, { TagBase, TagInputProps } from './TagInput';
|
||||
|
||||
interface SeriesTag extends TagBase {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SeriesTagInputProps {
|
||||
export interface SeriesTagInputProps<V>
|
||||
extends Omit<
|
||||
TagInputProps<SeriesTag>,
|
||||
'tags' | 'tagList' | 'onTagAdd' | 'onTagDelete' | 'onChange'
|
||||
> {
|
||||
name: string;
|
||||
value: number[];
|
||||
onChange: (change: InputChanged<number[]>) => void;
|
||||
value: V;
|
||||
onChange: (change: InputChanged<V>) => void;
|
||||
}
|
||||
|
||||
const VALID_TAG_REGEX = new RegExp('[^-_a-z0-9]', 'i');
|
||||
@@ -59,28 +63,49 @@ function createSeriesTagsSelector(tags: number[]) {
|
||||
});
|
||||
}
|
||||
|
||||
export default function SeriesTagInput({
|
||||
export default function SeriesTagInput<V extends number | number[]>({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
}: SeriesTagInputProps) {
|
||||
...otherProps
|
||||
}: SeriesTagInputProps<V>) {
|
||||
const dispatch = useDispatch();
|
||||
const isArray = Array.isArray(value);
|
||||
|
||||
const arrayValue = useMemo(() => {
|
||||
if (isArray) {
|
||||
return value as number[];
|
||||
}
|
||||
|
||||
return value === 0 ? [] : [value as number];
|
||||
}, [isArray, value]);
|
||||
|
||||
const { tags, tagList, allTags } = useSelector(
|
||||
createSeriesTagsSelector(value)
|
||||
createSeriesTagsSelector(arrayValue)
|
||||
);
|
||||
|
||||
const handleTagCreated = useCallback(
|
||||
(tag: SeriesTag) => {
|
||||
onChange({ name, value: [...value, tag.id] });
|
||||
if (isArray) {
|
||||
onChange({ name, value: [...value, tag.id] as V });
|
||||
} else {
|
||||
onChange({
|
||||
name,
|
||||
value: tag.id as V,
|
||||
});
|
||||
}
|
||||
},
|
||||
[name, value, onChange]
|
||||
[name, value, isArray, onChange]
|
||||
);
|
||||
|
||||
const handleTagAdd = useCallback(
|
||||
(newTag: SeriesTag) => {
|
||||
if (newTag.id) {
|
||||
onChange({ name, value: [...value, newTag.id] });
|
||||
if (isArray) {
|
||||
onChange({ name, value: [...value, newTag.id] as V });
|
||||
} else {
|
||||
onChange({ name, value: newTag.id as V });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -96,21 +121,26 @@ export default function SeriesTagInput({
|
||||
);
|
||||
}
|
||||
},
|
||||
[name, value, allTags, handleTagCreated, onChange, dispatch]
|
||||
[name, value, isArray, allTags, handleTagCreated, onChange, dispatch]
|
||||
);
|
||||
|
||||
const handleTagDelete = useCallback(
|
||||
({ index }: { index: number }) => {
|
||||
const newValue = value.slice();
|
||||
newValue.splice(index, 1);
|
||||
if (isArray) {
|
||||
const newValue = value.slice();
|
||||
newValue.splice(index, 1);
|
||||
|
||||
onChange({ name, value: newValue });
|
||||
onChange({ name, value: newValue as V });
|
||||
} else {
|
||||
onChange({ name, value: 0 as V });
|
||||
}
|
||||
},
|
||||
[name, value, onChange]
|
||||
[name, value, isArray, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<TagInput
|
||||
{...otherProps}
|
||||
name={name}
|
||||
tags={tags}
|
||||
tagList={tagList}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
RenderSuggestion,
|
||||
SuggestionsFetchRequestedParams,
|
||||
} from 'react-autosuggest';
|
||||
import useDebouncedCallback from 'Helpers/Hooks/useDebouncedCallback';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import AutoSuggestInput from '../AutoSuggestInput';
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
color: var(--warningColor);
|
||||
}
|
||||
|
||||
.primary {
|
||||
color: var(--primaryColor);
|
||||
}
|
||||
|
||||
.purple {
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
1
frontend/src/Components/Icon.css.d.ts
vendored
1
frontend/src/Components/Icon.css.d.ts
vendored
@@ -6,6 +6,7 @@ interface CssExports {
|
||||
'disabled': string;
|
||||
'info': string;
|
||||
'pink': string;
|
||||
'primary': string;
|
||||
'purple': string;
|
||||
'success': string;
|
||||
'warning': string;
|
||||
|
||||
@@ -1,41 +1,22 @@
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
FloatingPortal,
|
||||
shift,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
} from '@floating-ui/react';
|
||||
import React, {
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Manager, Popper, PopperProps, Reference } from 'react-popper';
|
||||
import Portal from 'Components/Portal';
|
||||
import styles from './Menu.css';
|
||||
|
||||
const sharedPopperOptions = {
|
||||
modifiers: {
|
||||
preventOverflow: {
|
||||
padding: 0,
|
||||
},
|
||||
flip: {
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const popperOptions: {
|
||||
right: Partial<PopperProps>;
|
||||
left: Partial<PopperProps>;
|
||||
} = {
|
||||
right: {
|
||||
...sharedPopperOptions,
|
||||
placement: 'bottom-end',
|
||||
},
|
||||
|
||||
left: {
|
||||
...sharedPopperOptions,
|
||||
placement: 'bottom-start',
|
||||
},
|
||||
};
|
||||
|
||||
interface MenuProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
@@ -49,9 +30,7 @@ function Menu({
|
||||
alignMenu = 'left',
|
||||
enforceMaxHeight = true,
|
||||
}: MenuProps) {
|
||||
const updater = useRef<(() => void) | null>(null);
|
||||
const menuButtonId = useId();
|
||||
const menuContentId = useId();
|
||||
const [maxHeight, setMaxHeight] = useState(0);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
@@ -70,45 +49,14 @@ function Menu({
|
||||
setMaxHeight(height);
|
||||
}, [menuButtonId]);
|
||||
|
||||
const handleWindowClick = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
const menuButton = document.getElementById(menuButtonId);
|
||||
const handleMenuButtonPress = useCallback(() => {
|
||||
setIsMenuOpen((isOpen) => !isOpen);
|
||||
}, []);
|
||||
|
||||
if (!menuButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!menuButton.contains(event.target as Node)) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
},
|
||||
[menuButtonId]
|
||||
);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(event: TouchEvent) => {
|
||||
const menuButton = document.getElementById(menuButtonId);
|
||||
const menuContent = document.getElementById(menuContentId);
|
||||
|
||||
if (!menuButton || !menuContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.targetTouches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.targetTouches[0].target;
|
||||
|
||||
if (
|
||||
!menuButton.contains(target as Node) &&
|
||||
!menuContent.contains(target as Node)
|
||||
) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
},
|
||||
[menuButtonId, menuContentId]
|
||||
);
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
const button = React.cloneElement(childrenArray[0] as ReactElement, {
|
||||
onPress: handleMenuButtonPress,
|
||||
});
|
||||
|
||||
const handleWindowResize = useCallback(() => {
|
||||
updateMaxHeight();
|
||||
@@ -120,32 +68,15 @@ function Menu({
|
||||
}
|
||||
}, [isMenuOpen, updateMaxHeight]);
|
||||
|
||||
const handleMenuButtonPress = useCallback(() => {
|
||||
setIsMenuOpen((isOpen) => !isOpen);
|
||||
}, []);
|
||||
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
const button = React.cloneElement(childrenArray[0] as ReactElement, {
|
||||
onPress: handleMenuButtonPress,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (enforceMaxHeight) {
|
||||
updateMaxHeight();
|
||||
}
|
||||
}, [enforceMaxHeight, updateMaxHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (updater.current && isMenuOpen) {
|
||||
updater.current();
|
||||
}
|
||||
}, [isMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen to resize events on the window and scroll events
|
||||
// on all elements to ensure the menu is the best size possible.
|
||||
// Listen for click events on the window to support closing the
|
||||
// menu on clicks outside.
|
||||
|
||||
if (!isMenuOpen) {
|
||||
return;
|
||||
@@ -153,52 +84,88 @@ function Menu({
|
||||
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
window.addEventListener('scroll', handleWindowScroll, { capture: true });
|
||||
window.addEventListener('click', handleWindowClick);
|
||||
window.addEventListener('touchstart', handleTouchStart);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
window.removeEventListener('scroll', handleWindowScroll, {
|
||||
capture: true,
|
||||
});
|
||||
window.removeEventListener('click', handleWindowClick);
|
||||
window.removeEventListener('touchstart', handleTouchStart);
|
||||
};
|
||||
}, [
|
||||
isMenuOpen,
|
||||
handleWindowResize,
|
||||
handleWindowScroll,
|
||||
handleWindowClick,
|
||||
handleTouchStart,
|
||||
}, [isMenuOpen, handleWindowResize, handleWindowScroll]);
|
||||
|
||||
const { refs, context, floatingStyles } = useFloating({
|
||||
middleware: [
|
||||
flip({
|
||||
crossAxis: false,
|
||||
mainAxis: true,
|
||||
}),
|
||||
// offset({ mainAxis: 10 }),
|
||||
shift(),
|
||||
],
|
||||
open: isMenuOpen,
|
||||
placement: alignMenu === 'left' ? 'bottom-start' : 'bottom-end',
|
||||
whileElementsMounted: autoUpdate,
|
||||
onOpenChange: setIsMenuOpen,
|
||||
});
|
||||
|
||||
const handleFloaterPress = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (
|
||||
refs.reference &&
|
||||
(refs.reference.current as HTMLElement).contains(
|
||||
event.target as HTMLElement
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: Menu items should handle closing when they are clicked.
|
||||
// This is handled before the menu item click event is handled, so wait 100ms before closing.
|
||||
setTimeout(() => {
|
||||
setIsMenuOpen(false);
|
||||
}, 100);
|
||||
|
||||
return true;
|
||||
},
|
||||
[refs.reference]
|
||||
);
|
||||
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context, {
|
||||
outsidePressEvent: 'click',
|
||||
outsidePress: handleFloaterPress,
|
||||
});
|
||||
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
click,
|
||||
dismiss,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<div ref={ref} id={menuButtonId} className={className}>
|
||||
{button}
|
||||
</div>
|
||||
)}
|
||||
</Reference>
|
||||
<>
|
||||
<div
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
id={menuButtonId}
|
||||
className={className}
|
||||
>
|
||||
{button}
|
||||
</div>
|
||||
|
||||
<Portal>
|
||||
<Popper {...popperOptions[alignMenu]}>
|
||||
{({ ref, style, scheduleUpdate }) => {
|
||||
updater.current = scheduleUpdate;
|
||||
|
||||
return React.cloneElement(childrenArray[1] as ReactElement, {
|
||||
forwardedRef: ref,
|
||||
style: {
|
||||
...style,
|
||||
maxHeight,
|
||||
},
|
||||
isOpen: isMenuOpen,
|
||||
});
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
{isMenuOpen ? (
|
||||
<FloatingPortal id="portal-root">
|
||||
{React.cloneElement(childrenArray[1] as ReactElement, {
|
||||
forwardedRef: refs.setFloating,
|
||||
style: {
|
||||
maxHeight,
|
||||
...floatingStyles,
|
||||
},
|
||||
isOpen: isMenuOpen,
|
||||
...getFloatingProps(),
|
||||
})}
|
||||
</FloatingPortal>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
.modal {
|
||||
position: relative;
|
||||
display: flex;
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
border-radius: 6px;
|
||||
opacity: 1;
|
||||
@@ -88,13 +89,6 @@
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.modal.small,
|
||||
.modal.medium {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.modalContainer {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
.header {
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -13,10 +13,10 @@ import React, {
|
||||
import Autosuggest from 'react-autosuggest';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Icon from 'Components/Icon';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import useDebouncedCallback from 'Helpers/Hooks/useDebouncedCallback';
|
||||
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Series from 'Series/Series';
|
||||
@@ -316,7 +316,7 @@ function SeriesSearchInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If an suggestion is not selected go to the first series,
|
||||
// If a suggestion is not selected go to the first series,
|
||||
// otherwise go to the selected series.
|
||||
|
||||
const selectedSuggestion =
|
||||
|
||||
@@ -7,6 +7,40 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebarHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: $headerHeight;
|
||||
}
|
||||
|
||||
.logoContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.logoLink {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.sidebarCloseButton {
|
||||
composes: button from '~Components/Link/IconButton.css';
|
||||
|
||||
margin-right: 15px;
|
||||
color: #e1e2e3;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--sonarrBlue);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'logo': string;
|
||||
'logoContainer': string;
|
||||
'logoLink': string;
|
||||
'sidebar': string;
|
||||
'sidebarCloseButton': string;
|
||||
'sidebarContainer': string;
|
||||
'sidebarHeader': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import classNames from 'classnames';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -11,6 +10,8 @@ import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router';
|
||||
import QueueStatus from 'Activity/Queue/Status/QueueStatus';
|
||||
import { IconName } from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import OverlayScroller from 'Components/Scroller/OverlayScroller';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
@@ -230,10 +231,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
||||
transition: 'none',
|
||||
transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1,
|
||||
});
|
||||
const [sidebarStyle, setSidebarStyle] = useState({
|
||||
top: dimensions.headerHeight,
|
||||
height: `${window.innerHeight - HEADER_HEIGHT}px`,
|
||||
});
|
||||
|
||||
const urlBase = window.Sonarr.urlBase;
|
||||
const pathname = urlBase
|
||||
@@ -299,22 +296,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
||||
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleWindowScroll = useCallback(() => {
|
||||
const windowScroll =
|
||||
window.scrollY == null
|
||||
? document.documentElement.scrollTop
|
||||
: window.scrollY;
|
||||
const sidebarTop = Math.max(HEADER_HEIGHT - windowScroll, 0);
|
||||
const sidebarHeight = window.innerHeight - sidebarTop;
|
||||
|
||||
if (isSmallScreen) {
|
||||
setSidebarStyle({
|
||||
top: `${sidebarTop}px`,
|
||||
height: `${sidebarHeight}px`,
|
||||
});
|
||||
}
|
||||
}, [isSmallScreen]);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(event: TouchEvent) => {
|
||||
const touches = event.touches;
|
||||
@@ -359,44 +340,50 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback((event: TouchEvent) => {
|
||||
const touches = event.changedTouches;
|
||||
const currentTouch = touches[0].pageX;
|
||||
const handleTouchEnd = useCallback(
|
||||
(event: TouchEvent) => {
|
||||
const touches = event.changedTouches;
|
||||
const currentTouch = touches[0].pageX;
|
||||
|
||||
if (!touchStartX.current) {
|
||||
return;
|
||||
}
|
||||
if (!touchStartX.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTouch > touchStartX.current && currentTouch > 50) {
|
||||
setSidebarTransform({
|
||||
transition: 'none',
|
||||
transform: 0,
|
||||
});
|
||||
} else if (currentTouch < touchStartX.current && currentTouch < 80) {
|
||||
setSidebarTransform({
|
||||
transition: 'transform 50ms ease-in-out',
|
||||
transform: SIDEBAR_WIDTH * -1,
|
||||
});
|
||||
} else {
|
||||
setSidebarTransform({
|
||||
transition: 'none',
|
||||
transform: 0,
|
||||
});
|
||||
}
|
||||
if (currentTouch > touchStartX.current && currentTouch > 50) {
|
||||
setSidebarTransform({
|
||||
transition: 'none',
|
||||
transform: 0,
|
||||
});
|
||||
} else if (currentTouch < touchStartX.current && currentTouch < 80) {
|
||||
setSidebarTransform({
|
||||
transition: 'transform 50ms ease-in-out',
|
||||
transform: SIDEBAR_WIDTH * -1,
|
||||
});
|
||||
} else {
|
||||
setSidebarTransform({
|
||||
transition: 'none',
|
||||
transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1,
|
||||
});
|
||||
}
|
||||
|
||||
touchStartX.current = null;
|
||||
touchStartY.current = null;
|
||||
}, []);
|
||||
touchStartX.current = null;
|
||||
touchStartY.current = null;
|
||||
},
|
||||
[isSidebarVisible]
|
||||
);
|
||||
|
||||
const handleTouchCancel = useCallback(() => {
|
||||
touchStartX.current = null;
|
||||
touchStartY.current = null;
|
||||
}, []);
|
||||
|
||||
const handleSidebarClosePress = useCallback(() => {
|
||||
dispatch(setIsSidebarVisible({ isSidebarVisible: false }));
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSmallScreen) {
|
||||
window.addEventListener('click', handleWindowClick, { capture: true });
|
||||
window.addEventListener('scroll', handleWindowScroll);
|
||||
window.addEventListener('touchstart', handleTouchStart);
|
||||
window.addEventListener('touchmove', handleTouchMove);
|
||||
window.addEventListener('touchend', handleTouchEnd);
|
||||
@@ -405,7 +392,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', handleWindowClick, { capture: true });
|
||||
window.removeEventListener('scroll', handleWindowScroll);
|
||||
window.removeEventListener('touchstart', handleTouchStart);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
window.removeEventListener('touchend', handleTouchEnd);
|
||||
@@ -414,7 +400,6 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
||||
}, [
|
||||
isSmallScreen,
|
||||
handleWindowClick,
|
||||
handleWindowScroll,
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
@@ -453,13 +438,37 @@ function PageSidebar({ isSidebarVisible, isSmallScreen }: PageSidebarProps) {
|
||||
return (
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={classNames(styles.sidebarContainer)}
|
||||
className={styles.sidebarContainer}
|
||||
style={containerStyle}
|
||||
>
|
||||
{isSmallScreen ? (
|
||||
<div className={styles.sidebarHeader}>
|
||||
<div className={styles.logoContainer}>
|
||||
<Link className={styles.logoLink} to="/">
|
||||
<img
|
||||
className={styles.logo}
|
||||
src={`${window.Sonarr.urlBase}/Content/Images/logo.svg`}
|
||||
alt="Sonarr Logo"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
className={styles.sidebarCloseButton}
|
||||
name={icons.CLOSE}
|
||||
aria-label={translate('Close')}
|
||||
size={20}
|
||||
onPress={handleSidebarClosePress}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ScrollerComponent
|
||||
className={styles.sidebar}
|
||||
scrollDirection="vertical"
|
||||
style={sidebarStyle}
|
||||
style={{
|
||||
height: `${window.innerHeight - HEADER_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{LINKS.map((link) => {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
composes: link;
|
||||
|
||||
padding: 10px 24px;
|
||||
padding-left: 35px;
|
||||
}
|
||||
|
||||
.isActiveLink {
|
||||
@@ -41,10 +42,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.noIcon {
|
||||
margin-left: 25px;
|
||||
}
|
||||
|
||||
.status {
|
||||
float: right;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ interface CssExports {
|
||||
'isActiveParentLink': string;
|
||||
'item': string;
|
||||
'link': string;
|
||||
'noIcon': string;
|
||||
'status': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
|
||||
@@ -54,9 +54,7 @@ function PageSidebarItem({
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={isChildItem ? styles.noIcon : undefined}>
|
||||
{typeof title === 'function' ? title() : title}
|
||||
</span>
|
||||
{typeof title === 'function' ? title() : title}
|
||||
|
||||
{!!StatusComponent && (
|
||||
<span className={styles.status}>
|
||||
|
||||
@@ -22,11 +22,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 0 3px;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
color: var(--toolbarLabelColor);
|
||||
font-size: $extraSmallFontSize;
|
||||
line-height: calc($extraSmallFontSize + 1px);
|
||||
|
||||
@@ -31,6 +31,7 @@ function PageToolbarButton({
|
||||
isDisabled && styles.isDisabled
|
||||
)}
|
||||
isDisabled={isDisabled || isSpinning}
|
||||
title={label}
|
||||
{...otherProps}
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -80,8 +80,12 @@ function PageToolbarSection({
|
||||
if (buttonCount - 1 === maxButtons) {
|
||||
const overflowItems: PageToolbarButtonProps[] = [];
|
||||
|
||||
const buttonsWithoutSeparators = validChildren.filter(
|
||||
(child) => Object.keys(child.props).length > 0
|
||||
);
|
||||
|
||||
return {
|
||||
buttons: validChildren,
|
||||
buttons: buttonsWithoutSeparators,
|
||||
buttonCount,
|
||||
overflowItems,
|
||||
};
|
||||
|
||||
@@ -3,19 +3,18 @@ import {
|
||||
HubConnectionBuilder,
|
||||
LogLevel,
|
||||
} from '@microsoft/signalr';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Command from 'Commands/Command';
|
||||
import { setAppValue, setVersion } from 'Store/Actions/appActions';
|
||||
import { removeItem, update, updateItem } from 'Store/Actions/baseActions';
|
||||
import { removeItem, updateItem } from 'Store/Actions/baseActions';
|
||||
import {
|
||||
fetchCommands,
|
||||
finishCommand,
|
||||
updateCommand,
|
||||
} from 'Store/Actions/commandActions';
|
||||
import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import { fetchSeries } from 'Store/Actions/seriesActions';
|
||||
import { fetchQualityDefinitions } from 'Store/Actions/settingsActions';
|
||||
@@ -33,15 +32,13 @@ interface SignalRMessage {
|
||||
resource: ModelBase;
|
||||
version: string;
|
||||
};
|
||||
version: number | undefined;
|
||||
}
|
||||
|
||||
function SignalRListener() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isQueuePopulated = useSelector(
|
||||
(state: AppState) => state.queue.paged.isPopulated
|
||||
);
|
||||
|
||||
const connection = useRef<HubConnection | null>(null);
|
||||
|
||||
const handleStartFail = useRef((error: unknown) => {
|
||||
@@ -97,9 +94,14 @@ function SignalRListener() {
|
||||
});
|
||||
|
||||
const handleReceiveMessage = useRef((message: SignalRMessage) => {
|
||||
console.debug('[signalR] received', message.name, message.body);
|
||||
console.debug(
|
||||
`[signalR] received ${message.name}${
|
||||
message.version ? ` v${message.version}` : ''
|
||||
}`,
|
||||
message.body
|
||||
);
|
||||
|
||||
const { name, body } = message;
|
||||
const { name, body, version = 0 } = message;
|
||||
|
||||
if (name === 'calendar') {
|
||||
if (body.action === 'updated') {
|
||||
@@ -235,20 +237,36 @@ function SignalRListener() {
|
||||
}
|
||||
|
||||
if (name === 'queue') {
|
||||
if (isQueuePopulated) {
|
||||
dispatch(fetchQueue());
|
||||
if (version < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue'] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'queue/details') {
|
||||
dispatch(fetchQueueDetails());
|
||||
if (version < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['/queue/details'] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'queue/status') {
|
||||
dispatch(update({ section: 'queue.status', data: body.resource }));
|
||||
if (version < 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusDetails = queryClient.getQueriesData({
|
||||
queryKey: ['/queue/status'],
|
||||
});
|
||||
|
||||
statusDetails.forEach(([queryKey]) => {
|
||||
queryClient.setQueryData(queryKey, () => body.resource);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ function RelativeDateCell(props: RelativeDateCellProps) {
|
||||
date,
|
||||
includeSeconds = false,
|
||||
includeTime = false,
|
||||
|
||||
component: Component = TableRowCell,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
line-height: 1.52857143;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user