mirror of
https://github.com/Radarr/Radarr.git
synced 2026-03-05 13:21:25 -05:00
Compare commits
216 Commits
v5.22.1.98
...
v6.0.0.102
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3429fe0696 | ||
|
|
100e121afc | ||
|
|
24be516fdb | ||
|
|
f49c35563d | ||
|
|
6e23750705 | ||
|
|
30fc50e049 | ||
|
|
8000abc2be | ||
|
|
62a05e2765 | ||
|
|
f04bff8e91 | ||
|
|
84593502a3 | ||
|
|
d478b404df | ||
|
|
80a9fa68de | ||
|
|
8eb9fc71b8 | ||
|
|
6b1567ddae | ||
|
|
265e931451 | ||
|
|
2a886fb26a | ||
|
|
2235823af3 | ||
|
|
f99162b8ee | ||
|
|
a00ee08750 | ||
|
|
54cbbe05d9 | ||
|
|
57f602eb02 | ||
|
|
e841c9b764 | ||
|
|
81bbaf8946 | ||
|
|
8b4288fa18 | ||
|
|
9aa3061e8e | ||
|
|
308c58f729 | ||
|
|
d38492188a | ||
|
|
50e75e1362 | ||
|
|
f36845c251 | ||
|
|
110a338fb6 | ||
|
|
3fcbaf9259 | ||
|
|
576eff1890 | ||
|
|
b0284bda07 | ||
|
|
c78666009d | ||
|
|
b51d1beaaa | ||
|
|
4d22bf1ceb | ||
|
|
f9562b9b76 | ||
|
|
6851c26328 | ||
|
|
e29be26fc9 | ||
|
|
f6bd2f52d5 | ||
|
|
8bef9b4da7 | ||
|
|
787c387036 | ||
|
|
0525256115 | ||
|
|
5767e181b7 | ||
|
|
1cf3ef5dff | ||
|
|
b6bad2398c | ||
|
|
16308e4b1c | ||
|
|
bd7465fae4 | ||
|
|
c0d70485c3 | ||
|
|
c743383912 | ||
|
|
d93c1d7808 | ||
|
|
0e2e7e4259 | ||
|
|
e6b27512c9 | ||
|
|
dae5e86b2c | ||
|
|
71f032d175 | ||
|
|
5a6db29dbd | ||
|
|
2dac2dd35b | ||
|
|
b829638a77 | ||
|
|
b6b7f13839 | ||
|
|
a9ad197b75 | ||
|
|
1b28116a7e | ||
|
|
5870c88e1c | ||
|
|
0629832bd0 | ||
|
|
430897c710 | ||
|
|
9c42246eef | ||
|
|
489a86b253 | ||
|
|
9c8d3b679d | ||
|
|
b2e51d1613 | ||
|
|
a95b1f2992 | ||
|
|
ac33b15048 | ||
|
|
d28f03af28 | ||
|
|
73b99d0be2 | ||
|
|
15c34a61de | ||
|
|
b99c536306 | ||
|
|
2ebf391f85 | ||
|
|
3945a2eeb8 | ||
|
|
e6980df590 | ||
|
|
187dd79b9c | ||
|
|
22ef334de6 | ||
|
|
c9eb9b8b98 | ||
|
|
9c74c40fc6 | ||
|
|
8911cbe872 | ||
|
|
7e541d4653 | ||
|
|
1cc2237ac0 | ||
|
|
470963921d | ||
|
|
36f9ec4ea7 | ||
|
|
9df2368601 | ||
|
|
e7d76350ec | ||
|
|
fd3828ff5d | ||
|
|
368e1fead8 | ||
|
|
5b357faf16 | ||
|
|
3f35b7c782 | ||
|
|
7d29deb93c | ||
|
|
d0bfdce9c5 | ||
|
|
5d0cd78667 | ||
|
|
afbe0ebcd4 | ||
|
|
bfbb7532a2 | ||
|
|
c92d8c08f1 | ||
|
|
358ce92f85 | ||
|
|
3ec5a4b78a | ||
|
|
cb59ce891a | ||
|
|
4d3d46d796 | ||
|
|
0941e51d27 | ||
|
|
ff393a3f65 | ||
|
|
f5faf52469 | ||
|
|
b5b4d4b971 | ||
|
|
873299701b | ||
|
|
d14cca30d7 | ||
|
|
5af61b5900 | ||
|
|
a10759c7e9 | ||
|
|
ac2d92007e | ||
|
|
09cfdc3fa2 | ||
|
|
04f26dbff7 | ||
|
|
159f5df8cc | ||
|
|
b823ad8e65 | ||
|
|
cc8bffc272 | ||
|
|
e0b93a03fd | ||
|
|
f7f5837d49 | ||
|
|
c3ee8b3c90 | ||
|
|
4de78e3bab | ||
|
|
426538c8af | ||
|
|
c82404c75b | ||
|
|
9bee9841c1 | ||
|
|
010959d915 | ||
|
|
a600728916 | ||
|
|
bbfb8c7cc2 | ||
|
|
32418ea521 | ||
|
|
2c5c99e9b7 | ||
|
|
a5e5a63e45 | ||
|
|
31b44d2c2e | ||
|
|
da8e8a12de | ||
|
|
6506c97ce1 | ||
|
|
5303a1992c | ||
|
|
042308c319 | ||
|
|
2e97e09f44 | ||
|
|
ccfb9c0dad | ||
|
|
b655d97e9e | ||
|
|
3afcb91db6 | ||
|
|
704e2d6176 | ||
|
|
8314c37b1d | ||
|
|
c2c3dfe917 | ||
|
|
c58a9b3f2c | ||
|
|
65a532a7fd | ||
|
|
704d920dab | ||
|
|
025cb0788f | ||
|
|
82c21d8bb1 | ||
|
|
96f973c961 | ||
|
|
a1ed440945 | ||
|
|
8caa839d99 | ||
|
|
9228e5dea0 | ||
|
|
371ac0921d | ||
|
|
937557e214 | ||
|
|
7fdaf41325 | ||
|
|
577eb4f4ca | ||
|
|
311f41b306 | ||
|
|
78f3b1f403 | ||
|
|
4dc02dcb80 | ||
|
|
2f649e413d | ||
|
|
107ddd3826 | ||
|
|
dfdd2cba99 | ||
|
|
c57d68c3dd | ||
|
|
6cc02b734e | ||
|
|
c5fa09dd86 | ||
|
|
29d59315b2 | ||
|
|
981a3c2db3 | ||
|
|
3f2ea56bf9 | ||
|
|
1679ed1327 | ||
|
|
69a1c1b21b | ||
|
|
5bd51832a0 | ||
|
|
52a69b662d | ||
|
|
7e34d89069 | ||
|
|
b0024b28a5 | ||
|
|
ae5450f75d | ||
|
|
1d1aca1a04 | ||
|
|
3a55316ada | ||
|
|
9ef7c2a0b4 | ||
|
|
e759f3fd0b | ||
|
|
03429db877 | ||
|
|
bb5f421e38 | ||
|
|
7dd3ed815a | ||
|
|
cc56482819 | ||
|
|
40f41847fd | ||
|
|
8485fc8c75 | ||
|
|
f3026df65d | ||
|
|
cfd25e974f | ||
|
|
c52f9c5ec4 | ||
|
|
b91517afd5 | ||
|
|
ee8aaadb29 | ||
|
|
0694f2fa76 | ||
|
|
2c81f3be0f | ||
|
|
8fb2f64e98 | ||
|
|
efd2b80e10 | ||
|
|
a9bbe06966 | ||
|
|
4c6f80b308 | ||
|
|
c8299f7e57 | ||
|
|
445babbca8 | ||
|
|
e5137d13e9 | ||
|
|
fb8f8f4dd3 | ||
|
|
2b8ca4746a | ||
|
|
9231a0e526 | ||
|
|
9fa75f0539 | ||
|
|
76b5568129 | ||
|
|
27efe506a7 | ||
|
|
d9be54575a | ||
|
|
a825b96518 | ||
|
|
221b7a4300 | ||
|
|
1ac784e323 | ||
|
|
aae34f4c43 | ||
|
|
7219648fea | ||
|
|
b7be80744c | ||
|
|
29ca18d3f3 | ||
|
|
d9704a999d | ||
|
|
a23983032a | ||
|
|
99d68cfd91 | ||
|
|
9c009a84f2 | ||
|
|
e8ca64fabc |
@@ -2,7 +2,7 @@
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/dotnet
|
||||
{
|
||||
"name": "Radarr",
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet:1-6.0",
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet:1-8.0",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"nodeGypDependencies": true,
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -165,15 +165,12 @@ Thumbs.db
|
||||
/tools/Addins/*
|
||||
packages.config.md5sum
|
||||
|
||||
|
||||
# Common IntelliJ Platform excludes
|
||||
|
||||
# Ignore Rider projects completely for now
|
||||
.idea/
|
||||
|
||||
# ignore node_modules symlink
|
||||
node_modules
|
||||
node_modules.nosync
|
||||
|
||||
# API doc generation
|
||||
.config/
|
||||
|
||||
# Ignore Jetbrains IntelliJ Workspace Directories
|
||||
.idea/
|
||||
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -10,7 +10,7 @@
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build dotnet",
|
||||
// If you have changed target frameworks, make sure to update the program path.
|
||||
"program": "${workspaceFolder}/_output/net6.0/Radarr",
|
||||
"program": "${workspaceFolder}/_output/net8.0/Radarr",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
|
||||
|
||||
@@ -9,13 +9,13 @@ variables:
|
||||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '5.22.1'
|
||||
majorVersion: '6.0.0'
|
||||
minorVersion: $[counter('minorVersion', 2000)]
|
||||
radarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
|
||||
sentryOrg: 'servarr'
|
||||
sentryUrl: 'https://sentry.servarr.com'
|
||||
dotnetVersion: '6.0.427'
|
||||
dotnetVersion: '8.0.405'
|
||||
nodeVersion: '20.X'
|
||||
innoVersion: '6.2.2'
|
||||
windowsImage: 'windows-2022'
|
||||
@@ -106,7 +106,7 @@ stages:
|
||||
echo "Extra platforms already enabled"
|
||||
else
|
||||
echo "Enabling extra platform support"
|
||||
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64;linux-x86/' $BUNDLEDVERSIONS
|
||||
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64/' "$BUNDLEDVERSIONS"
|
||||
fi
|
||||
displayName: Enable Extra Platform Support
|
||||
- bash: ./build.sh --backend --enable-extra-platforms
|
||||
@@ -122,27 +122,23 @@ stages:
|
||||
artifact: '$(osName)Backend'
|
||||
displayName: Publish Backend
|
||||
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
|
||||
- publish: '$(testsFolder)/net6.0/win-x64/publish'
|
||||
- publish: '$(testsFolder)/net8.0/win-x64/publish'
|
||||
artifact: win-x64-tests
|
||||
displayName: Publish win-x64 Test Package
|
||||
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
|
||||
- publish: '$(testsFolder)/net6.0/linux-x64/publish'
|
||||
- publish: '$(testsFolder)/net8.0/linux-x64/publish'
|
||||
artifact: linux-x64-tests
|
||||
displayName: Publish linux-x64 Test Package
|
||||
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
|
||||
- publish: '$(testsFolder)/net6.0/linux-x86/publish'
|
||||
artifact: linux-x86-tests
|
||||
displayName: Publish linux-x86 Test Package
|
||||
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
|
||||
- publish: '$(testsFolder)/net6.0/linux-musl-x64/publish'
|
||||
- publish: '$(testsFolder)/net8.0/linux-musl-x64/publish'
|
||||
artifact: linux-musl-x64-tests
|
||||
displayName: Publish linux-musl-x64 Test Package
|
||||
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
|
||||
- publish: '$(testsFolder)/net6.0/freebsd-x64/publish'
|
||||
- publish: '$(testsFolder)/net8.0/freebsd-x64/publish'
|
||||
artifact: freebsd-x64-tests
|
||||
displayName: Publish freebsd-x64 Test Package
|
||||
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
|
||||
- publish: '$(testsFolder)/net6.0/osx-x64/publish'
|
||||
- publish: '$(testsFolder)/net8.0/osx-x64/publish'
|
||||
artifact: osx-x64-tests
|
||||
displayName: Publish osx-x64 Test Package
|
||||
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
|
||||
@@ -189,7 +185,7 @@ stages:
|
||||
artifact: '$(osName)Frontend'
|
||||
displayName: Publish Frontend
|
||||
condition: and(succeeded(), eq(variables['osName'], 'Windows'))
|
||||
|
||||
|
||||
- stage: Installer
|
||||
dependsOn:
|
||||
- Build_Backend
|
||||
@@ -260,21 +256,21 @@ stages:
|
||||
archiveFile: '$(Build.ArtifactStagingDirectory)/Radarr.$(buildName).windows-core-x64.zip'
|
||||
archiveType: 'zip'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/win-x64/net6.0
|
||||
rootFolderOrFile: $(artifactsFolder)/win-x64/net8.0
|
||||
- task: ArchiveFiles@2
|
||||
displayName: Create win-x86 zip
|
||||
inputs:
|
||||
archiveFile: '$(Build.ArtifactStagingDirectory)/Radarr.$(buildName).windows-core-x86.zip'
|
||||
archiveType: 'zip'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/win-x86/net6.0
|
||||
rootFolderOrFile: $(artifactsFolder)/win-x86/net8.0
|
||||
- task: ArchiveFiles@2
|
||||
displayName: Create osx-x64 app
|
||||
inputs:
|
||||
archiveFile: '$(Build.ArtifactStagingDirectory)/Radarr.$(buildName).osx-app-core-x64.zip'
|
||||
archiveType: 'zip'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/osx-x64-app/net6.0
|
||||
rootFolderOrFile: $(artifactsFolder)/osx-x64-app/net8.0
|
||||
- task: ArchiveFiles@2
|
||||
displayName: Create osx-x64 tar
|
||||
inputs:
|
||||
@@ -282,14 +278,14 @@ stages:
|
||||
archiveType: 'tar'
|
||||
tarCompression: 'gz'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/osx-x64/net6.0
|
||||
rootFolderOrFile: $(artifactsFolder)/osx-x64/net8.0
|
||||
- task: ArchiveFiles@2
|
||||
displayName: Create osx-arm64 app
|
||||
inputs:
|
||||
archiveFile: '$(Build.ArtifactStagingDirectory)/Radarr.$(buildName).osx-app-core-arm64.zip'
|
||||
archiveType: 'zip'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/osx-arm64-app/net6.0
|
||||
rootFolderOrFile: $(artifactsFolder)/osx-arm64-app/net8.0
|
||||
- task: ArchiveFiles@2
|
||||
displayName: Create osx-arm64 tar
|
||||
inputs:
|
||||
@@ -297,7 +293,7 @@ stages:
|
||||
archiveType: 'tar'
|
||||
tarCompression: 'gz'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/osx-arm64/net6.0
|
||||
rootFolderOrFile: $(artifactsFolder)/osx-arm64/net8.0
|
||||
- task: ArchiveFiles@2
|
||||
displayName: Create linux-x64 tar
|
||||
inputs:
|
||||
@@ -305,7 +301,7 @@ stages:
|
||||
archiveType: 'tar'
|
||||
tarCompression: 'gz'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/linux-x64/net6.0
|
||||
rootFolderOrFile: $(artifactsFolder)/linux-x64/net8.0
|
||||
- task: ArchiveFiles@2
|
||||
displayName: Create linux-musl-x64 tar
|
||||
inputs:
|
||||
@@ -313,15 +309,7 @@ stages:
|
||||
archiveType: 'tar'
|
||||
tarCompression: 'gz'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/linux-musl-x64/net6.0
|
||||
- task: ArchiveFiles@2
|
||||
displayName: Create linux-x86 tar
|
||||
inputs:
|
||||
archiveFile: '$(Build.ArtifactStagingDirectory)/Radarr.$(buildName).linux-core-x86.tar.gz'
|
||||
archiveType: 'tar'
|
||||
tarCompression: 'gz'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/linux-x86/net6.0
|
||||
rootFolderOrFile: $(artifactsFolder)/linux-musl-x64/net8.0
|
||||
- task: ArchiveFiles@2
|
||||
displayName: Create linux-arm tar
|
||||
inputs:
|
||||
@@ -329,7 +317,7 @@ stages:
|
||||
archiveType: 'tar'
|
||||
tarCompression: 'gz'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/linux-arm/net6.0
|
||||
rootFolderOrFile: $(artifactsFolder)/linux-arm/net8.0
|
||||
- task: ArchiveFiles@2
|
||||
displayName: Create linux-musl-arm tar
|
||||
inputs:
|
||||
@@ -337,7 +325,7 @@ stages:
|
||||
archiveType: 'tar'
|
||||
tarCompression: 'gz'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/linux-musl-arm/net6.0
|
||||
rootFolderOrFile: $(artifactsFolder)/linux-musl-arm/net8.0
|
||||
- task: ArchiveFiles@2
|
||||
displayName: Create linux-arm64 tar
|
||||
inputs:
|
||||
@@ -345,7 +333,7 @@ stages:
|
||||
archiveType: 'tar'
|
||||
tarCompression: 'gz'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/linux-arm64/net6.0
|
||||
rootFolderOrFile: $(artifactsFolder)/linux-arm64/net8.0
|
||||
- task: ArchiveFiles@2
|
||||
displayName: Create linux-musl-arm64 tar
|
||||
inputs:
|
||||
@@ -353,7 +341,7 @@ stages:
|
||||
archiveType: 'tar'
|
||||
tarCompression: 'gz'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/linux-musl-arm64/net6.0
|
||||
rootFolderOrFile: $(artifactsFolder)/linux-musl-arm64/net8.0
|
||||
- task: ArchiveFiles@2
|
||||
displayName: Create freebsd-x64 tar
|
||||
inputs:
|
||||
@@ -361,7 +349,7 @@ stages:
|
||||
archiveType: 'tar'
|
||||
tarCompression: 'gz'
|
||||
includeRootFolder: false
|
||||
rootFolderOrFile: $(artifactsFolder)/freebsd-x64/net6.0
|
||||
rootFolderOrFile: $(artifactsFolder)/freebsd-x64/net8.0
|
||||
- publish: $(Build.ArtifactStagingDirectory)
|
||||
artifact: 'Packages'
|
||||
displayName: Publish Packages
|
||||
@@ -392,7 +380,7 @@ stages:
|
||||
SENTRY_AUTH_TOKEN: $(sentryAuthTokenServarr)
|
||||
SENTRY_ORG: $(sentryOrg)
|
||||
SENTRY_URL: $(sentryUrl)
|
||||
|
||||
|
||||
- stage: Unit_Test
|
||||
displayName: Unit Tests
|
||||
dependsOn: Build_Backend
|
||||
@@ -481,6 +469,7 @@ stages:
|
||||
testResultsFiles: '**/TestResult.xml'
|
||||
testRunTitle: '$(testName) Unit Tests'
|
||||
failTaskOnFailedTests: true
|
||||
failTaskOnMissingResultsFile: ne(variables['testName'], 'freebsd-x64')
|
||||
|
||||
- job: Unit_Docker
|
||||
displayName: Unit Docker
|
||||
@@ -492,29 +481,19 @@ stages:
|
||||
testName: 'Musl Net Core'
|
||||
artifactName: linux-musl-x64-tests
|
||||
containerImage: ghcr.io/servarr/testimages:alpine
|
||||
linux-x86:
|
||||
testName: 'linux-x86'
|
||||
artifactName: linux-x86-tests
|
||||
containerImage: ghcr.io/servarr/testimages:linux-x86
|
||||
|
||||
pool:
|
||||
vmImage: ${{ variables.linuxImage }}
|
||||
|
||||
|
||||
container: $[ variables['containerImage'] ]
|
||||
|
||||
timeoutInMinutes: 10
|
||||
|
||||
|
||||
steps:
|
||||
- task: UseDotNet@2
|
||||
displayName: 'Install .NET'
|
||||
inputs:
|
||||
version: $(dotnetVersion)
|
||||
condition: and(succeeded(), ne(variables['testName'], 'linux-x86'))
|
||||
- bash: |
|
||||
SDKURL=$(curl -s https://api.github.com/repos/Servarr/dotnet-linux-x86/releases | jq -rc '.[].assets[].browser_download_url' | grep sdk-${DOTNETVERSION}.*gz$)
|
||||
curl -fsSL $SDKURL | tar xzf - -C /opt/dotnet
|
||||
displayName: 'Install .NET'
|
||||
condition: and(succeeded(), eq(variables['testName'], 'linux-x86'))
|
||||
- checkout: none
|
||||
- task: DownloadPipelineArtifact@2
|
||||
displayName: Download Test Artifact
|
||||
@@ -540,7 +519,8 @@ stages:
|
||||
testResultsFiles: '**/TestResult.xml'
|
||||
testRunTitle: '$(testName) Unit Tests'
|
||||
failTaskOnFailedTests: true
|
||||
|
||||
failTaskOnMissingResultsFile: true
|
||||
|
||||
- job: Unit_LinuxCore_Postgres14
|
||||
displayName: Unit Native LinuxCore with Postgres14 Database
|
||||
dependsOn: Prepare
|
||||
@@ -557,7 +537,7 @@ stages:
|
||||
vmImage: ${{ variables.linuxImage }}
|
||||
|
||||
timeoutInMinutes: 10
|
||||
|
||||
|
||||
steps:
|
||||
- task: UseDotNet@2
|
||||
displayName: 'Install .net core'
|
||||
@@ -596,6 +576,7 @@ stages:
|
||||
testResultsFiles: '**/TestResult.xml'
|
||||
testRunTitle: 'LinuxCore Postgres14 Unit Tests'
|
||||
failTaskOnFailedTests: true
|
||||
failTaskOnMissingResultsFile: true
|
||||
|
||||
- job: Unit_LinuxCore_Postgres15
|
||||
displayName: Unit Native LinuxCore with Postgres15 Database
|
||||
@@ -608,12 +589,12 @@ stages:
|
||||
Radarr__Postgres__Port: '5432'
|
||||
Radarr__Postgres__User: 'radarr'
|
||||
Radarr__Postgres__Password: 'radarr'
|
||||
|
||||
|
||||
pool:
|
||||
vmImage: ${{ variables.linuxImage }}
|
||||
|
||||
timeoutInMinutes: 10
|
||||
|
||||
|
||||
steps:
|
||||
- task: UseDotNet@2
|
||||
displayName: 'Install .net core'
|
||||
@@ -652,6 +633,7 @@ stages:
|
||||
testResultsFiles: '**/TestResult.xml'
|
||||
testRunTitle: 'LinuxCore Postgres15 Unit Tests'
|
||||
failTaskOnFailedTests: true
|
||||
failTaskOnMissingResultsFile: true
|
||||
|
||||
- stage: Integration
|
||||
displayName: Integration
|
||||
@@ -695,7 +677,7 @@ stages:
|
||||
|
||||
pool:
|
||||
vmImage: $(imageName)
|
||||
|
||||
|
||||
steps:
|
||||
- task: UseDotNet@2
|
||||
displayName: 'Install .net core'
|
||||
@@ -717,7 +699,7 @@ stages:
|
||||
targetPath: $(Build.ArtifactStagingDirectory)
|
||||
- task: ExtractFiles@1
|
||||
inputs:
|
||||
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
|
||||
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
|
||||
destinationFolder: '$(Build.ArtifactStagingDirectory)/bin'
|
||||
displayName: Extract Package
|
||||
- bash: |
|
||||
@@ -734,6 +716,7 @@ stages:
|
||||
testResultsFiles: '**/TestResult.xml'
|
||||
testRunTitle: '$(testName) Integration Tests'
|
||||
failTaskOnFailedTests: true
|
||||
failTaskOnMissingResultsFile: true
|
||||
displayName: Publish Test Results
|
||||
|
||||
- job: Integration_LinuxCore_Postgres14
|
||||
@@ -771,7 +754,7 @@ stages:
|
||||
targetPath: $(Build.ArtifactStagingDirectory)
|
||||
- task: ExtractFiles@1
|
||||
inputs:
|
||||
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
|
||||
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
|
||||
destinationFolder: '$(Build.ArtifactStagingDirectory)/bin'
|
||||
displayName: Extract Package
|
||||
- bash: |
|
||||
@@ -796,6 +779,7 @@ stages:
|
||||
testResultsFiles: '**/TestResult.xml'
|
||||
testRunTitle: 'Integration LinuxCore Postgres14 Database Integration Tests'
|
||||
failTaskOnFailedTests: true
|
||||
failTaskOnMissingResultsFile: true
|
||||
displayName: Publish Test Results
|
||||
|
||||
|
||||
@@ -834,7 +818,7 @@ stages:
|
||||
targetPath: $(Build.ArtifactStagingDirectory)
|
||||
- task: ExtractFiles@1
|
||||
inputs:
|
||||
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
|
||||
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
|
||||
destinationFolder: '$(Build.ArtifactStagingDirectory)/bin'
|
||||
displayName: Extract Package
|
||||
- bash: |
|
||||
@@ -859,6 +843,7 @@ stages:
|
||||
testResultsFiles: '**/TestResult.xml'
|
||||
testRunTitle: 'Integration LinuxCore Postgres15 Database Integration Tests'
|
||||
failTaskOnFailedTests: true
|
||||
failTaskOnMissingResultsFile: true
|
||||
displayName: Publish Test Results
|
||||
|
||||
- job: Integration_FreeBSD
|
||||
@@ -905,6 +890,7 @@ stages:
|
||||
testResultsFiles: '**/TestResult.xml'
|
||||
testRunTitle: 'FreeBSD Integration Tests'
|
||||
failTaskOnFailedTests: true
|
||||
failTaskOnMissingResultsFile: false
|
||||
displayName: Publish Test Results
|
||||
|
||||
- job: Integration_Docker
|
||||
@@ -918,29 +904,18 @@ stages:
|
||||
artifactName: linux-musl-x64-tests
|
||||
containerImage: ghcr.io/servarr/testimages:alpine
|
||||
pattern: 'Radarr.*.linux-musl-core-x64.tar.gz'
|
||||
linux-x86:
|
||||
testName: 'linux-x86'
|
||||
artifactName: linux-x86-tests
|
||||
containerImage: ghcr.io/servarr/testimages:linux-x86
|
||||
pattern: 'Radarr.*.linux-core-x86.tar.gz'
|
||||
pool:
|
||||
vmImage: ${{ variables.linuxImage }}
|
||||
|
||||
container: $[ variables['containerImage'] ]
|
||||
|
||||
timeoutInMinutes: 15
|
||||
|
||||
|
||||
steps:
|
||||
- task: UseDotNet@2
|
||||
displayName: 'Install .NET'
|
||||
inputs:
|
||||
version: $(dotnetVersion)
|
||||
condition: and(succeeded(), ne(variables['testName'], 'linux-x86'))
|
||||
- bash: |
|
||||
SDKURL=$(curl -s https://api.github.com/repos/Servarr/dotnet-linux-x86/releases | jq -rc '.[].assets[].browser_download_url' | grep sdk-${DOTNETVERSION}.*gz$)
|
||||
curl -fsSL $SDKURL | tar xzf - -C /opt/dotnet
|
||||
displayName: 'Install .NET'
|
||||
condition: and(succeeded(), eq(variables['testName'], 'linux-x86'))
|
||||
- checkout: none
|
||||
- task: DownloadPipelineArtifact@2
|
||||
displayName: Download Test Artifact
|
||||
@@ -957,7 +932,7 @@ stages:
|
||||
targetPath: $(Build.ArtifactStagingDirectory)
|
||||
- task: ExtractFiles@1
|
||||
inputs:
|
||||
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
|
||||
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
|
||||
destinationFolder: '$(Build.ArtifactStagingDirectory)/bin'
|
||||
displayName: Extract Package
|
||||
- bash: |
|
||||
@@ -974,12 +949,13 @@ stages:
|
||||
testResultsFiles: '**/TestResult.xml'
|
||||
testRunTitle: '$(testName) Integration Tests'
|
||||
failTaskOnFailedTests: true
|
||||
failTaskOnMissingResultsFile: true
|
||||
displayName: Publish Test Results
|
||||
|
||||
- stage: Automation
|
||||
displayName: Automation
|
||||
dependsOn: Packages
|
||||
|
||||
|
||||
jobs:
|
||||
- job: Automation
|
||||
strategy:
|
||||
@@ -1005,7 +981,7 @@ stages:
|
||||
|
||||
pool:
|
||||
vmImage: $(imageName)
|
||||
|
||||
|
||||
steps:
|
||||
- task: UseDotNet@2
|
||||
displayName: 'Install .net core'
|
||||
@@ -1027,7 +1003,7 @@ stages:
|
||||
targetPath: $(Build.ArtifactStagingDirectory)
|
||||
- task: ExtractFiles@1
|
||||
inputs:
|
||||
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
|
||||
archiveFilePatterns: '$(Build.ArtifactStagingDirectory)/**/$(pattern)'
|
||||
destinationFolder: '$(Build.ArtifactStagingDirectory)/bin'
|
||||
displayName: Extract Package
|
||||
- bash: |
|
||||
@@ -1055,6 +1031,7 @@ stages:
|
||||
testResultsFiles: '**/TestResult.xml'
|
||||
testRunTitle: '$(osName) Automation Tests'
|
||||
failTaskOnFailedTests: $(failBuild)
|
||||
failTaskOnMissingResultsFile: $(failBuild)
|
||||
displayName: Publish Test Results
|
||||
|
||||
- stage: Analyze
|
||||
@@ -1151,7 +1128,7 @@ stages:
|
||||
- checkout: self
|
||||
submodules: true
|
||||
persistCredentials: true
|
||||
fetchDepth: 1
|
||||
fetchDepth: 1
|
||||
- bash: ./docs.sh Windows
|
||||
displayName: Create openapi.json
|
||||
- bash: |
|
||||
@@ -1220,13 +1197,13 @@ stages:
|
||||
sonar.cs.opencover.reportsPaths=$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml
|
||||
sonar.cs.nunit.reportsPaths=$(Build.SourcesDirectory)/TestResult.xml
|
||||
- bash: |
|
||||
./build.sh --backend -f net6.0 -r win-x64
|
||||
TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage
|
||||
./build.sh --backend -f net8.0 -r win-x64
|
||||
TEST_DIR=_tests/net8.0/win-x64/publish/ ./test.sh Windows Unit Coverage
|
||||
displayName: Coverage Unit Tests
|
||||
- task: SonarCloudAnalyze@3
|
||||
condition: eq(variables['System.PullRequest.IsFork'], 'False')
|
||||
displayName: Publish SonarCloud Results
|
||||
- task: reportgenerator@5.3.11
|
||||
- task: reportgenerator@5
|
||||
displayName: Generate Coverage Report
|
||||
inputs:
|
||||
reports: '$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml'
|
||||
@@ -1264,4 +1241,3 @@ stages:
|
||||
DISCORDCHANNELID: $(discordChannelId)
|
||||
DISCORDWEBHOOKKEY: $(discordWebhookKey)
|
||||
DISCORDTHREADID: $(discordThreadId)
|
||||
|
||||
|
||||
52
build.sh
52
build.sh
@@ -33,14 +33,14 @@ EnableExtraPlatformsInSDK()
|
||||
echo "Extra platforms already enabled"
|
||||
else
|
||||
echo "Enabling extra platform support"
|
||||
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64;linux-x86/' $BUNDLEDVERSIONS
|
||||
sed -i.ORI 's/osx-x64/osx-x64;freebsd-x64/' "$BUNDLEDVERSIONS"
|
||||
fi
|
||||
}
|
||||
|
||||
EnableExtraPlatforms()
|
||||
{
|
||||
if grep -qv freebsd-x64 src/Directory.Build.props; then
|
||||
sed -i'' -e "s^<RuntimeIdentifiers>\(.*\)</RuntimeIdentifiers>^<RuntimeIdentifiers>\1;freebsd-x64;linux-x86</RuntimeIdentifiers>^g" src/Directory.Build.props
|
||||
sed -i'' -e "s^<RuntimeIdentifiers>\(.*\)</RuntimeIdentifiers>^<RuntimeIdentifiers>\1;freebsd-x64</RuntimeIdentifiers>^g" src/Directory.Build.props
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -79,9 +79,9 @@ Build()
|
||||
|
||||
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
|
||||
then
|
||||
dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -t:PublishAllRids
|
||||
dotnet msbuild -restore $slnFile -p:SelfContained=True -p:Configuration=Release -p:Platform=$platform -t:PublishAllRids
|
||||
else
|
||||
dotnet msbuild -restore $slnFile -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$RID -t:PublishAllRids
|
||||
dotnet msbuild -restore $slnFile -p:SelfContained=True -p:Configuration=Release -p:Platform=$platform -p:RuntimeIdentifiers=$RID -t:PublishAllRids
|
||||
fi
|
||||
|
||||
ProgressEnd 'Build'
|
||||
@@ -137,7 +137,7 @@ PackageLinux()
|
||||
|
||||
echo "Adding Radarr.Mono to UpdatePackage"
|
||||
cp $folder/Radarr.Mono.* $folder/Radarr.Update
|
||||
if [ "$framework" = "net6.0" ]; then
|
||||
if [ "$framework" = "net8.0" ]; then
|
||||
cp $folder/Mono.Posix.NETStandard.* $folder/Radarr.Update
|
||||
cp $folder/libMonoPosixHelper.* $folder/Radarr.Update
|
||||
fi
|
||||
@@ -165,7 +165,7 @@ PackageMacOS()
|
||||
|
||||
echo "Adding Radarr.Mono to UpdatePackage"
|
||||
cp $folder/Radarr.Mono.* $folder/Radarr.Update
|
||||
if [ "$framework" = "net6.0" ]; then
|
||||
if [ "$framework" = "net8.0" ]; then
|
||||
cp $folder/Mono.Posix.NETStandard.* $folder/Radarr.Update
|
||||
cp $folder/libMonoPosixHelper.* $folder/Radarr.Update
|
||||
fi
|
||||
@@ -377,15 +377,14 @@ then
|
||||
Build
|
||||
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
|
||||
then
|
||||
PackageTests "net6.0" "win-x64"
|
||||
PackageTests "net6.0" "win-x86"
|
||||
PackageTests "net6.0" "linux-x64"
|
||||
PackageTests "net6.0" "linux-musl-x64"
|
||||
PackageTests "net6.0" "osx-x64"
|
||||
PackageTests "net8.0" "win-x64"
|
||||
PackageTests "net8.0" "win-x86"
|
||||
PackageTests "net8.0" "linux-x64"
|
||||
PackageTests "net8.0" "linux-musl-x64"
|
||||
PackageTests "net8.0" "osx-x64"
|
||||
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
|
||||
then
|
||||
PackageTests "net6.0" "freebsd-x64"
|
||||
PackageTests "net6.0" "linux-x86"
|
||||
PackageTests "net8.0" "freebsd-x64"
|
||||
fi
|
||||
else
|
||||
PackageTests "$FRAMEWORK" "$RID"
|
||||
@@ -413,20 +412,19 @@ then
|
||||
|
||||
if [[ -z "$RID" || -z "$FRAMEWORK" ]];
|
||||
then
|
||||
Package "net6.0" "win-x64"
|
||||
Package "net6.0" "win-x86"
|
||||
Package "net6.0" "linux-x64"
|
||||
Package "net6.0" "linux-musl-x64"
|
||||
Package "net6.0" "linux-arm64"
|
||||
Package "net6.0" "linux-musl-arm64"
|
||||
Package "net6.0" "linux-arm"
|
||||
Package "net6.0" "linux-musl-arm"
|
||||
Package "net6.0" "osx-x64"
|
||||
Package "net6.0" "osx-arm64"
|
||||
Package "net8.0" "win-x64"
|
||||
Package "net8.0" "win-x86"
|
||||
Package "net8.0" "linux-x64"
|
||||
Package "net8.0" "linux-musl-x64"
|
||||
Package "net8.0" "linux-arm64"
|
||||
Package "net8.0" "linux-musl-arm64"
|
||||
Package "net8.0" "linux-arm"
|
||||
Package "net8.0" "linux-musl-arm"
|
||||
Package "net8.0" "osx-x64"
|
||||
Package "net8.0" "osx-arm64"
|
||||
if [ "$ENABLE_EXTRA_PLATFORMS" = "YES" ];
|
||||
then
|
||||
Package "net6.0" "freebsd-x64"
|
||||
Package "net6.0" "linux-x86"
|
||||
Package "net8.0" "freebsd-x64"
|
||||
fi
|
||||
else
|
||||
Package "$FRAMEWORK" "$RID"
|
||||
@@ -436,7 +434,7 @@ fi
|
||||
if [ "$INSTALLER" = "YES" ];
|
||||
then
|
||||
InstallInno
|
||||
BuildInstaller "net6.0" "win-x64"
|
||||
BuildInstaller "net6.0" "win-x86"
|
||||
BuildInstaller "net8.0" "win-x64"
|
||||
BuildInstaller "net8.0" "win-x86"
|
||||
RemoveInno
|
||||
fi
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
input1 = """Prometheus.Special.Edition.Fan Edit.2012..BRRip.x264.AAC-m2g
|
||||
Star Wars Episode IV - A New Hope (Despecialized) 1999.mkv
|
||||
Prometheus.(Special.Edition.Remastered).2012.[Bluray-1080p].mkv
|
||||
Prometheus Extended 2012
|
||||
Prometheus Extended Directors Cut Fan Edit 2012
|
||||
Prometheus Director's Cut 2012
|
||||
Prometheus Directors Cut 2012
|
||||
Prometheus.(Extended.Theatrical.Version.IMAX).BluRay.1080p.2012.asdf
|
||||
2001 A Space Odyssey Director's Cut (1968).mkv
|
||||
2001: A Space Odyssey (Extended Directors Cut FanEdit) Bluray 1080p 1968
|
||||
A Fake Movie 2035 Directors 2012.mkv
|
||||
Blade Runner Director's Cut 2049.mkv
|
||||
Prometheus 50th Anniversary Edition 2012.mkv
|
||||
Movie 2in1 2012.mkv
|
||||
Movie IMAX 2012.mkv"""
|
||||
|
||||
output1 = """Special.Edition.Fan Edit BRRip.x264.AAC-m2g
|
||||
Despecialized mkv
|
||||
Special.Edition.Remastered Bluray-1080p].mkv
|
||||
Extended mkv
|
||||
Extended Directors Cut Fan Edit mkv
|
||||
Director's Cut mkv
|
||||
Directors Cut mkv
|
||||
Extended.Theatrical.Version.IMAX asdf
|
||||
Director's Cut mkv
|
||||
Extended Directors Cut FanEdit mkv
|
||||
Directors mkv
|
||||
Director's Cut mkv
|
||||
50th Anniversary Edition mkv
|
||||
2in1 mkv
|
||||
IMAX mkv"""
|
||||
|
||||
inputs = input1.split("\n")
|
||||
outputs = output1.split("\n")
|
||||
real_o = []
|
||||
for output in outputs:
|
||||
real_o.append(output.split(" ")[0].replace(".", " ").strip())
|
||||
|
||||
count = 0
|
||||
|
||||
for inp in inputs:
|
||||
o = real_o[count]
|
||||
print "[TestCase(\"{0}\", \"{1}\")]".format(inp, o)
|
||||
count += 1
|
||||
4
docs.sh
4
docs.sh
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
FRAMEWORK="net6.0"
|
||||
FRAMEWORK="net8.0"
|
||||
PLATFORM=$1
|
||||
ARCHITECTURE="${2:-x64}"
|
||||
|
||||
@@ -38,7 +38,7 @@ dotnet clean $slnFile -c Release
|
||||
dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p:RuntimeIdentifiers=$RUNTIME -t:PublishAllRids
|
||||
|
||||
dotnet new tool-manifest
|
||||
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli
|
||||
dotnet tool install --version 8.1.4 Swashbuckle.AspNetCore.Cli
|
||||
|
||||
dotnet tool run swagger tofile --output ./src/Radarr.Api.V3/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v3 &
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ module.exports = (env) => {
|
||||
const srcFolder = path.join(frontendFolder, 'src');
|
||||
const isProduction = !!env.production;
|
||||
const isProfiling = isProduction && !!env.profile;
|
||||
const inlineWebWorkers = 'no-fallback';
|
||||
|
||||
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
|
||||
|
||||
@@ -160,16 +159,6 @@ module.exports = (env) => {
|
||||
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.worker\.js$/,
|
||||
use: {
|
||||
loader: 'worker-loader',
|
||||
options: {
|
||||
filename: '[name].js',
|
||||
inline: inlineWebWorkers
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
test: [/\.jsx?$/, /\.tsx?$/],
|
||||
exclude: /(node_modules|JsLibraries)/,
|
||||
@@ -187,7 +176,7 @@ module.exports = (env) => {
|
||||
loose: true,
|
||||
debug: false,
|
||||
useBuiltIns: 'entry',
|
||||
corejs: '3.39'
|
||||
corejs: '3.42'
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -145,7 +145,7 @@ function Blocklist() {
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string) => {
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setBlocklistFilter({ selectedFilterKey }));
|
||||
},
|
||||
[dispatch]
|
||||
|
||||
@@ -26,7 +26,7 @@ function BlocklistDetailsModal(props: BlocklistDetailsModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>Details</ModalHeader>
|
||||
<ModalHeader>{translate('Details')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<DescriptionList>
|
||||
|
||||
@@ -77,7 +77,7 @@ function History() {
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string) => {
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setHistoryFilter({ selectedFilterKey }));
|
||||
},
|
||||
[dispatch]
|
||||
|
||||
@@ -183,7 +183,7 @@ function Queue() {
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string) => {
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setQueueFilter({ selectedFilterKey }));
|
||||
},
|
||||
[dispatch]
|
||||
@@ -304,7 +304,7 @@ function Queue() {
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label="Refresh"
|
||||
label={translate('Refresh')}
|
||||
iconName={icons.REFRESH}
|
||||
isSpinning={isRefreshing}
|
||||
onPress={handleRefreshPress}
|
||||
|
||||
@@ -6,7 +6,7 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
function QueueOptions() {
|
||||
@@ -16,7 +16,7 @@ function QueueOptions() {
|
||||
);
|
||||
|
||||
const handleOptionChange = useCallback(
|
||||
({ name, value }: CheckInputChanged) => {
|
||||
({ name, value }: InputChanged<boolean>) => {
|
||||
dispatch(
|
||||
setQueueOption({
|
||||
[name]: value,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import Icon, { IconKind } from 'Components/Icon';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
|
||||
@@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) {
|
||||
|
||||
// status === 'downloading'
|
||||
let iconName = icons.DOWNLOADING;
|
||||
let iconKind: IconProps['kind'] = kinds.DEFAULT;
|
||||
let iconKind: IconKind = kinds.DEFAULT;
|
||||
let title = translate('Downloading');
|
||||
|
||||
if (status === 'paused') {
|
||||
@@ -90,7 +90,7 @@ function QueueStatus(props: QueueStatusProps) {
|
||||
|
||||
if (trackedDownloadState === 'importing') {
|
||||
title += ` - ${translate('Importing')}`;
|
||||
iconKind = kinds.PURPLE;
|
||||
iconKind = kinds.PRIMARY;
|
||||
}
|
||||
|
||||
if (trackedDownloadState === 'failedPending') {
|
||||
|
||||
@@ -79,9 +79,9 @@ class AddNewMovieModalContent extends Component {
|
||||
}
|
||||
|
||||
<div className={styles.info}>
|
||||
<div className={styles.overview}>
|
||||
{overview}
|
||||
</div>
|
||||
{overview ? (
|
||||
<div className={styles.overview}>{overview}</div>
|
||||
) : null}
|
||||
|
||||
<Form>
|
||||
<FormGroup>
|
||||
@@ -98,7 +98,9 @@ class AddNewMovieModalContent extends Component {
|
||||
movieFolder: folder,
|
||||
isWindows
|
||||
}}
|
||||
helpText={translate('SubfolderWillBeCreatedAutomaticallyInterp', [folder])}
|
||||
helpText={translate('AddNewMovieRootFolderHelpText', {
|
||||
folder
|
||||
})}
|
||||
onChange={onInputChange}
|
||||
{...rootFolderPath}
|
||||
/>
|
||||
@@ -110,7 +112,7 @@ class AddNewMovieModalContent extends Component {
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MOVIE_MONITORED_SELECT}
|
||||
type={inputTypes.MONITOR_MOVIES_SELECT}
|
||||
name="monitor"
|
||||
onChange={onInputChange}
|
||||
{...monitor}
|
||||
|
||||
@@ -108,7 +108,7 @@ class ImportMovie extends Component {
|
||||
{
|
||||
!rootFoldersFetching && !!rootFoldersError ?
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('UnableToLoadRootFolders')}
|
||||
{translate('RootFoldersLoadError')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
.inputContainer {
|
||||
margin-right: 20px;
|
||||
min-width: 150px;
|
||||
|
||||
div {
|
||||
margin-top: 10px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 3px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ class ImportMovieFooter extends Component {
|
||||
</div>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MOVIE_MONITORED_SELECT}
|
||||
type={inputTypes.MONITOR_MOVIES_SELECT}
|
||||
name="monitor"
|
||||
value={monitor}
|
||||
isDisabled={!selectedCount}
|
||||
|
||||
@@ -44,7 +44,7 @@ function ImportMovieRow(props) {
|
||||
|
||||
<VirtualTableRowCell className={styles.monitor}>
|
||||
<FormInputGroup
|
||||
type={inputTypes.MOVIE_MONITORED_SELECT}
|
||||
type={inputTypes.MONITOR_MOVIES_SELECT}
|
||||
name="monitor"
|
||||
value={monitor}
|
||||
onChange={onInputChange}
|
||||
|
||||
@@ -93,7 +93,7 @@ class ImportMovieSelectFolder extends Component {
|
||||
{
|
||||
!isFetching && error ?
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('UnableToLoadRootFolders')}
|
||||
{translate('RootFoldersLoadError')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
|
||||
import React from 'react';
|
||||
import DocumentTitle from 'react-document-title';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Store } from 'redux';
|
||||
import PageConnector from 'Components/Page/PageConnector';
|
||||
import Page from 'Components/Page/Page';
|
||||
import ApplyTheme from './ApplyTheme';
|
||||
import AppRoutes from './AppRoutes';
|
||||
|
||||
@@ -12,17 +13,21 @@ interface AppProps {
|
||||
history: ConnectedRouterProps['history'];
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function App({ store, history }: AppProps) {
|
||||
return (
|
||||
<DocumentTitle title={window.Radarr.instanceName}>
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<ApplyTheme />
|
||||
<PageConnector>
|
||||
<AppRoutes />
|
||||
</PageConnector>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<ApplyTheme />
|
||||
<Page>
|
||||
<AppRoutes />
|
||||
</Page>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</DocumentTitle>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import History from 'Activity/History/History';
|
||||
import Queue from 'Activity/Queue/Queue';
|
||||
import AddNewMovieConnector from 'AddMovie/AddNewMovie/AddNewMovieConnector';
|
||||
import ImportMovies from 'AddMovie/ImportMovie/ImportMovies';
|
||||
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
|
||||
import CalendarPage from 'Calendar/CalendarPage';
|
||||
import CollectionConnector from 'Collection/CollectionConnector';
|
||||
import NotFound from 'Components/NotFound';
|
||||
import Switch from 'Components/Router/Switch';
|
||||
@@ -15,9 +15,9 @@ import MovieIndex from 'Movie/Index/MovieIndex';
|
||||
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
|
||||
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
|
||||
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
|
||||
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
|
||||
import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
|
||||
import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
|
||||
import ImportListSettings from 'Settings/ImportLists/ImportListSettings';
|
||||
import IndexerSettings from 'Settings/Indexers/IndexerSettings';
|
||||
import MediaManagement from 'Settings/MediaManagement/MediaManagement';
|
||||
import MetadataSettings from 'Settings/Metadata/MetadataSettings';
|
||||
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
|
||||
import Profiles from 'Settings/Profiles/Profiles';
|
||||
@@ -32,8 +32,8 @@ import Status from 'System/Status/Status';
|
||||
import Tasks from 'System/Tasks/Tasks';
|
||||
import Updates from 'System/Updates/Updates';
|
||||
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
|
||||
import MissingConnector from 'Wanted/Missing/MissingConnector';
|
||||
import CutoffUnmet from 'Wanted/CutoffUnmet/CutoffUnmet';
|
||||
import Missing from 'Wanted/Missing/Missing';
|
||||
|
||||
function RedirectWithUrlBase() {
|
||||
return <Redirect to={getPathWithUrlBase('/')} />;
|
||||
@@ -73,7 +73,7 @@ function AppRoutes() {
|
||||
Calendar
|
||||
*/}
|
||||
|
||||
<Route path="/calendar" component={CalendarPageConnector} />
|
||||
<Route path="/calendar" component={CalendarPage} />
|
||||
|
||||
{/*
|
||||
Activity
|
||||
@@ -89,9 +89,9 @@ function AppRoutes() {
|
||||
Wanted
|
||||
*/}
|
||||
|
||||
<Route path="/wanted/missing" component={MissingConnector} />
|
||||
<Route path="/wanted/missing" component={Missing} />
|
||||
|
||||
<Route path="/wanted/cutoffunmet" component={CutoffUnmetConnector} />
|
||||
<Route path="/wanted/cutoffunmet" component={CutoffUnmet} />
|
||||
|
||||
{/*
|
||||
Settings
|
||||
@@ -99,10 +99,7 @@ function AppRoutes() {
|
||||
|
||||
<Route exact={true} path="/settings" component={Settings} />
|
||||
|
||||
<Route
|
||||
path="/settings/mediamanagement"
|
||||
component={MediaManagementConnector}
|
||||
/>
|
||||
<Route path="/settings/mediamanagement" component={MediaManagement} />
|
||||
|
||||
<Route path="/settings/profiles" component={Profiles} />
|
||||
|
||||
@@ -113,17 +110,14 @@ function AppRoutes() {
|
||||
component={CustomFormatSettingsPage}
|
||||
/>
|
||||
|
||||
<Route path="/settings/indexers" component={IndexerSettingsConnector} />
|
||||
<Route path="/settings/indexers" component={IndexerSettings} />
|
||||
|
||||
<Route
|
||||
path="/settings/downloadclients"
|
||||
component={DownloadClientSettingsConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/importlists"
|
||||
component={ImportListSettingsConnector}
|
||||
/>
|
||||
<Route path="/settings/importlists" component={ImportListSettings} />
|
||||
|
||||
<Route path="/settings/connect" component={NotificationSettings} />
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ export type SelectContextAction =
|
||||
| { type: 'unselectAll' }
|
||||
| {
|
||||
type: 'toggleSelected';
|
||||
id: number;
|
||||
isSelected: boolean;
|
||||
id: number | string;
|
||||
isSelected: boolean | null;
|
||||
shiftKey: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'removeItem';
|
||||
id: number;
|
||||
id: number | string;
|
||||
}
|
||||
| {
|
||||
type: 'updateItems';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Column from 'Components/Table/Column';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import { ValidationFailure } from 'typings/pending';
|
||||
import { FilterBuilderProp, PropertyFilter } from './AppState';
|
||||
import { Filter, FilterBuilderProp } from './AppState';
|
||||
|
||||
export interface Error {
|
||||
status?: number;
|
||||
@@ -35,7 +35,7 @@ export interface TableAppSectionState {
|
||||
|
||||
export interface AppSectionFilterState<T> {
|
||||
selectedFilterKey: string;
|
||||
filters: PropertyFilter[];
|
||||
filters: Filter[];
|
||||
filterBuilderProps: FilterBuilderProp<T>[];
|
||||
}
|
||||
|
||||
@@ -43,9 +43,15 @@ export interface AppSectionSchemaState<T> {
|
||||
isSchemaFetching: boolean;
|
||||
isSchemaPopulated: boolean;
|
||||
schemaError: Error;
|
||||
schema: {
|
||||
items: T[];
|
||||
};
|
||||
schema: T[];
|
||||
selectedSchema?: T;
|
||||
}
|
||||
|
||||
export interface AppSectionItemSchemaState<T> {
|
||||
isSchemaFetching: boolean;
|
||||
isSchemaPopulated: boolean;
|
||||
schemaError: Error;
|
||||
schema: T;
|
||||
}
|
||||
|
||||
export interface AppSectionItemState<T> {
|
||||
@@ -61,9 +67,10 @@ export interface AppSectionProviderState<T>
|
||||
AppSectionSaveState {
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
isTesting?: boolean;
|
||||
error: Error;
|
||||
items: T[];
|
||||
pendingChanges: Partial<T>;
|
||||
pendingChanges?: Partial<T>;
|
||||
}
|
||||
|
||||
interface AppSectionState<T> {
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { Error } from './AppSectionState';
|
||||
import BlocklistAppState from './BlocklistAppState';
|
||||
import CalendarAppState from './CalendarAppState';
|
||||
import CaptchaAppState from './CaptchaAppState';
|
||||
import CommandAppState from './CommandAppState';
|
||||
import CustomFiltersAppState from './CustomFiltersAppState';
|
||||
import ExtraFilesAppState from './ExtraFilesAppState';
|
||||
import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState';
|
||||
import InteractiveImportAppState from './InteractiveImportAppState';
|
||||
import MessagesAppState from './MessagesAppState';
|
||||
import MovieBlocklistAppState from './MovieBlocklistAppState';
|
||||
import MovieCollectionAppState from './MovieCollectionAppState';
|
||||
import MovieCreditAppState from './MovieCreditAppState';
|
||||
import MovieFilesAppState from './MovieFilesAppState';
|
||||
import MoviesAppState, { MovieIndexAppState } from './MoviesAppState';
|
||||
import OAuthAppState from './OAuthAppState';
|
||||
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 SettingsAppState from './SettingsAppState';
|
||||
import SystemAppState from './SystemAppState';
|
||||
import TagsAppState from './TagsAppState';
|
||||
import WantedAppState from './WantedAppState';
|
||||
|
||||
interface FilterBuilderPropOption {
|
||||
id: string;
|
||||
@@ -40,35 +47,45 @@ export interface PropertyFilter {
|
||||
|
||||
export interface Filter {
|
||||
key: string;
|
||||
label: string;
|
||||
filers: PropertyFilter[];
|
||||
label: string | (() => string);
|
||||
filters: PropertyFilter[];
|
||||
}
|
||||
|
||||
export interface CustomFilter {
|
||||
id: number;
|
||||
type: string;
|
||||
label: string;
|
||||
filers: PropertyFilter[];
|
||||
filters: PropertyFilter[];
|
||||
}
|
||||
|
||||
export interface AppSectionState {
|
||||
isUpdated: boolean;
|
||||
isConnected: boolean;
|
||||
isDisconnected: boolean;
|
||||
isReconnecting: boolean;
|
||||
isSidebarVisible: boolean;
|
||||
version: string;
|
||||
prevVersion?: string;
|
||||
dimensions: {
|
||||
isSmallScreen: boolean;
|
||||
isLargeScreen: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
translations: {
|
||||
error?: Error;
|
||||
isPopulated: boolean;
|
||||
};
|
||||
messages: MessagesAppState;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
app: AppSectionState;
|
||||
blocklist: BlocklistAppState;
|
||||
calendar: CalendarAppState;
|
||||
captcha: CaptchaAppState;
|
||||
commands: CommandAppState;
|
||||
customFilters: CustomFiltersAppState;
|
||||
extraFiles: ExtraFilesAppState;
|
||||
history: HistoryAppState;
|
||||
interactiveImport: InteractiveImportAppState;
|
||||
@@ -79,15 +96,18 @@ interface AppState {
|
||||
movieHistory: MovieHistoryAppState;
|
||||
movieIndex: MovieIndexAppState;
|
||||
movies: MoviesAppState;
|
||||
oAuth: OAuthAppState;
|
||||
organizePreview: OrganizePreviewAppState;
|
||||
parse: ParseAppState;
|
||||
paths: PathsAppState;
|
||||
providerOptions: ProviderOptionsAppState;
|
||||
queue: QueueAppState;
|
||||
releases: ReleasesAppState;
|
||||
rootFolders: RootFolderAppState;
|
||||
settings: SettingsAppState;
|
||||
system: SystemAppState;
|
||||
tags: TagsAppState;
|
||||
wanted: WantedAppState;
|
||||
}
|
||||
|
||||
export default AppState;
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import moment from 'moment';
|
||||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Movie from 'Movie/Movie';
|
||||
import { CalendarView } from 'Calendar/calendarViews';
|
||||
import { CalendarItem } from 'typings/Calendar';
|
||||
|
||||
interface CalendarOptions {
|
||||
showMovieInformation: boolean;
|
||||
showCinemaRelease: boolean;
|
||||
showDigitalRelease: boolean;
|
||||
showPhysicalRelease: boolean;
|
||||
showCutoffUnmetIcon: boolean;
|
||||
fullColorEvents: boolean;
|
||||
}
|
||||
|
||||
interface CalendarAppState
|
||||
extends AppSectionState<Movie>,
|
||||
AppSectionFilterState<Movie> {}
|
||||
extends AppSectionState<CalendarItem>,
|
||||
AppSectionFilterState<CalendarItem> {
|
||||
searchMissingCommandId: number | null;
|
||||
start: moment.Moment;
|
||||
end: moment.Moment;
|
||||
dates: string[];
|
||||
time: string;
|
||||
view: CalendarView;
|
||||
options: CalendarOptions;
|
||||
}
|
||||
|
||||
export default CalendarAppState;
|
||||
|
||||
11
frontend/src/App/State/CaptchaAppState.ts
Normal file
11
frontend/src/App/State/CaptchaAppState.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
interface CaptchaAppState {
|
||||
refreshing: false;
|
||||
token: string;
|
||||
siteKey: unknown;
|
||||
secretToken: unknown;
|
||||
ray: unknown;
|
||||
stoken: unknown;
|
||||
responseUrl: unknown;
|
||||
}
|
||||
|
||||
export default CaptchaAppState;
|
||||
15
frontend/src/App/State/MessagesAppState.ts
Normal file
15
frontend/src/App/State/MessagesAppState.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
|
||||
export type MessageType = 'error' | 'info' | 'success' | 'warning';
|
||||
|
||||
export interface Message extends ModelBase {
|
||||
hideAfter: number;
|
||||
message: string;
|
||||
name: string;
|
||||
type: MessageType;
|
||||
}
|
||||
|
||||
type MessagesAppState = AppSectionState<Message>;
|
||||
|
||||
export default MessagesAppState;
|
||||
@@ -1,12 +1,20 @@
|
||||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
AppSectionSaveState,
|
||||
Error,
|
||||
} from 'App/State/AppSectionState';
|
||||
import MovieCollection from 'typings/MovieCollection';
|
||||
|
||||
interface MovieCollectionAppState
|
||||
extends AppSectionState<MovieCollection>,
|
||||
AppSectionFilterState<MovieCollection>,
|
||||
AppSectionSaveState {
|
||||
itemMap: Record<number, number>;
|
||||
|
||||
isAdding: boolean;
|
||||
addError: Error;
|
||||
|
||||
pendingChanges: Partial<MovieCollection>;
|
||||
}
|
||||
|
||||
export default MovieCollectionAppState;
|
||||
|
||||
9
frontend/src/App/State/OAuthAppState.ts
Normal file
9
frontend/src/App/State/OAuthAppState.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Error } from './AppSectionState';
|
||||
|
||||
interface OAuthAppState {
|
||||
authorizing: boolean;
|
||||
result: Record<string, unknown> | null;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
export default OAuthAppState;
|
||||
22
frontend/src/App/State/ProviderOptionsAppState.ts
Normal file
22
frontend/src/App/State/ProviderOptionsAppState.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
import Field, { FieldSelectOption } from 'typings/Field';
|
||||
|
||||
export interface ProviderOptions {
|
||||
fields?: Field[];
|
||||
}
|
||||
|
||||
interface ProviderOptionsDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ProviderOptionsAppState {
|
||||
devices: AppSectionState<ProviderOptionsDevice>;
|
||||
servers: AppSectionState<FieldSelectOption<unknown>>;
|
||||
newznabCategories: AppSectionState<FieldSelectOption<unknown>>;
|
||||
getProfiles: AppSectionState<FieldSelectOption<unknown>>;
|
||||
getTags: AppSectionState<FieldSelectOption<unknown>>;
|
||||
getRootFolders: AppSectionState<FieldSelectOption<unknown>>;
|
||||
}
|
||||
|
||||
export default ProviderOptionsAppState;
|
||||
@@ -1,12 +1,15 @@
|
||||
import AppSectionState, {
|
||||
AppSectionDeleteState,
|
||||
AppSectionItemSchemaState,
|
||||
AppSectionItemState,
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState,
|
||||
PagedAppSectionState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Language from 'Language/Language';
|
||||
import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import DelayProfile from 'typings/DelayProfile';
|
||||
import DownloadClient from 'typings/DownloadClient';
|
||||
import ImportList from 'typings/ImportList';
|
||||
import ImportListExclusion from 'typings/ImportListExclusion';
|
||||
@@ -16,12 +19,34 @@ import IndexerFlag from 'typings/IndexerFlag';
|
||||
import Notification from 'typings/Notification';
|
||||
import QualityProfile from 'typings/QualityProfile';
|
||||
import General from 'typings/Settings/General';
|
||||
import IndexerOptions from 'typings/Settings/IndexerOptions';
|
||||
import MediaManagement from 'typings/Settings/MediaManagement';
|
||||
import NamingConfig from 'typings/Settings/NamingConfig';
|
||||
import NamingExample from 'typings/Settings/NamingExample';
|
||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||
import UiSettings from 'typings/Settings/UiSettings';
|
||||
import MetadataAppState from './MetadataAppState';
|
||||
|
||||
type Presets<T> = T & {
|
||||
presets: T[];
|
||||
};
|
||||
|
||||
export interface AutoTaggingAppState
|
||||
extends AppSectionState<AutoTagging>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface AutoTaggingSpecificationAppState
|
||||
extends AppSectionState<AutoTaggingSpecification>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState<AutoTaggingSpecification> {}
|
||||
|
||||
export interface DelayProfileAppState
|
||||
extends AppSectionState<DelayProfile>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface DownloadClientAppState
|
||||
extends AppSectionState<DownloadClient>,
|
||||
AppSectionDeleteState,
|
||||
@@ -33,6 +58,10 @@ export interface GeneralAppState
|
||||
extends AppSectionItemState<General>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface MediaManagementAppState
|
||||
extends AppSectionItemState<MediaManagement>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface NamingAppState
|
||||
extends AppSectionItemState<NamingConfig>,
|
||||
AppSectionSaveState {}
|
||||
@@ -42,12 +71,20 @@ export type NamingExamplesAppState = AppSectionItemState<NamingExample>;
|
||||
export interface ImportListAppState
|
||||
extends AppSectionState<ImportList>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState<Presets<ImportList>> {
|
||||
isTestingAll: boolean;
|
||||
}
|
||||
|
||||
export interface IndexerOptionsAppState
|
||||
extends AppSectionItemState<IndexerOptions>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface IndexerAppState
|
||||
extends AppSectionState<Indexer>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {
|
||||
AppSectionSaveState,
|
||||
AppSectionSchemaState<Presets<Indexer>> {
|
||||
isTestingAll: boolean;
|
||||
}
|
||||
|
||||
@@ -57,7 +94,7 @@ export interface NotificationAppState
|
||||
|
||||
export interface QualityProfilesAppState
|
||||
extends AppSectionState<QualityProfile>,
|
||||
AppSectionSchemaState<QualityProfile> {}
|
||||
AppSectionItemSchemaState<QualityProfile> {}
|
||||
|
||||
export interface ReleaseProfilesAppState
|
||||
extends AppSectionState<ReleaseProfile>,
|
||||
@@ -88,15 +125,20 @@ export type UiSettingsAppState = AppSectionItemState<UiSettings>;
|
||||
|
||||
interface SettingsAppState {
|
||||
advancedSettings: boolean;
|
||||
autoTaggings: AutoTaggingAppState;
|
||||
autoTaggingSpecifications: AutoTaggingSpecificationAppState;
|
||||
customFormats: CustomFormatAppState;
|
||||
delayProfiles: DelayProfileAppState;
|
||||
downloadClients: DownloadClientAppState;
|
||||
general: GeneralAppState;
|
||||
importListExclusions: ImportListExclusionsSettingsAppState;
|
||||
importListOptions: ImportListOptionsSettingsAppState;
|
||||
importLists: ImportListAppState;
|
||||
indexerFlags: IndexerFlagSettingsAppState;
|
||||
indexerOptions: IndexerOptionsAppState;
|
||||
indexers: IndexerAppState;
|
||||
languages: LanguageSettingsAppState;
|
||||
mediaManagement: MediaManagementAppState;
|
||||
metadata: MetadataAppState;
|
||||
naming: NamingAppState;
|
||||
namingExamples: NamingExamplesAppState;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import DiskSpace from 'typings/DiskSpace';
|
||||
import Health from 'typings/Health';
|
||||
import LogFile from 'typings/LogFile';
|
||||
import SystemStatus from 'typings/SystemStatus';
|
||||
import Task from 'typings/Task';
|
||||
import Update from 'typings/Update';
|
||||
@@ -9,13 +10,16 @@ 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 {
|
||||
diskSpace: DiskSpaceAppState;
|
||||
health: HealthAppState;
|
||||
logFiles: LogFilesAppState;
|
||||
status: SystemStatusAppState;
|
||||
tasks: TaskAppState;
|
||||
updateLogFiles: LogFilesAppState;
|
||||
updates: UpdateAppState;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface TagDetail extends ModelBase {
|
||||
indexerIds: number[];
|
||||
movieIds: number[];
|
||||
notificationIds: number[];
|
||||
restrictionIds: number[];
|
||||
releaseProfileIds: number[];
|
||||
}
|
||||
|
||||
export interface TagDetailAppState
|
||||
|
||||
29
frontend/src/App/State/WantedAppState.ts
Normal file
29
frontend/src/App/State/WantedAppState.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Movie from 'Movie/Movie';
|
||||
|
||||
interface WantedMovie extends Movie {
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
interface WantedCutoffUnmetAppState
|
||||
extends AppSectionState<WantedMovie>,
|
||||
AppSectionFilterState<WantedMovie>,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState {}
|
||||
|
||||
interface WantedMissingAppState
|
||||
extends AppSectionState<WantedMovie>,
|
||||
AppSectionFilterState<WantedMovie>,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState {}
|
||||
|
||||
interface WantedAppState {
|
||||
cutoffUnmet: WantedCutoffUnmetAppState;
|
||||
missing: WantedMissingAppState;
|
||||
}
|
||||
|
||||
export default WantedAppState;
|
||||
@@ -1,69 +0,0 @@
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import AgendaEventConnector from './AgendaEventConnector';
|
||||
import styles from './Agenda.css';
|
||||
|
||||
function Agenda(props) {
|
||||
const {
|
||||
items,
|
||||
start,
|
||||
end
|
||||
} = props;
|
||||
|
||||
const startDateParsed = Date.parse(start);
|
||||
const endDateParsed = Date.parse(end);
|
||||
|
||||
items.forEach((item) => {
|
||||
const cinemaDateParsed = Date.parse(item.inCinemas);
|
||||
const digitalDateParsed = Date.parse(item.digitalRelease);
|
||||
const physicalDateParsed = Date.parse(item.physicalRelease);
|
||||
const dates = [];
|
||||
|
||||
if (cinemaDateParsed > 0 && cinemaDateParsed >= startDateParsed && cinemaDateParsed <= endDateParsed) {
|
||||
dates.push(cinemaDateParsed);
|
||||
}
|
||||
if (digitalDateParsed > 0 && digitalDateParsed >= startDateParsed && digitalDateParsed <= endDateParsed) {
|
||||
dates.push(digitalDateParsed);
|
||||
}
|
||||
if (physicalDateParsed > 0 && physicalDateParsed >= startDateParsed && physicalDateParsed <= endDateParsed) {
|
||||
dates.push(physicalDateParsed);
|
||||
}
|
||||
|
||||
item.sortDate = Math.min(...dates);
|
||||
item.cinemaDateParsed = cinemaDateParsed;
|
||||
item.digitalDateParsed = digitalDateParsed;
|
||||
item.physicalDateParsed = physicalDateParsed;
|
||||
});
|
||||
|
||||
items.sort((a, b) => ((a.sortDate > b.sortDate) ? 1 : -1));
|
||||
|
||||
return (
|
||||
<div className={styles.agenda}>
|
||||
{
|
||||
items.map((item, index) => {
|
||||
const momentDate = moment(item.sortDate);
|
||||
const showDate = index === 0 ||
|
||||
!moment(items[index - 1].sortDate).isSame(momentDate, 'day');
|
||||
|
||||
return (
|
||||
<AgendaEventConnector
|
||||
key={item.id}
|
||||
movieId={item.id}
|
||||
showDate={showDate}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Agenda.propTypes = {
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
start: PropTypes.string.isRequired,
|
||||
end: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default Agenda;
|
||||
81
frontend/src/Calendar/Agenda/Agenda.tsx
Normal file
81
frontend/src/Calendar/Agenda/Agenda.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import moment from 'moment';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Movie from 'Movie/Movie';
|
||||
import AgendaEvent from './AgendaEvent';
|
||||
import styles from './Agenda.css';
|
||||
|
||||
interface AgendaMovie extends Movie {
|
||||
sortDate: moment.Moment;
|
||||
}
|
||||
|
||||
function Agenda() {
|
||||
const { start, end, items } = useSelector(
|
||||
(state: AppState) => state.calendar
|
||||
);
|
||||
|
||||
const events = useMemo(() => {
|
||||
const result = items.map((item): AgendaMovie => {
|
||||
const { inCinemas, digitalRelease, physicalRelease } = item;
|
||||
|
||||
const dates = [];
|
||||
|
||||
if (inCinemas) {
|
||||
const inCinemasMoment = moment(inCinemas);
|
||||
|
||||
if (inCinemasMoment.isAfter(start) && inCinemasMoment.isBefore(end)) {
|
||||
dates.push(inCinemasMoment);
|
||||
}
|
||||
}
|
||||
|
||||
if (digitalRelease) {
|
||||
const digitalReleaseMoment = moment(digitalRelease);
|
||||
|
||||
if (
|
||||
digitalReleaseMoment.isAfter(start) &&
|
||||
digitalReleaseMoment.isBefore(end)
|
||||
) {
|
||||
dates.push(digitalReleaseMoment);
|
||||
}
|
||||
}
|
||||
|
||||
if (physicalRelease) {
|
||||
const physicalReleaseMoment = moment(physicalRelease);
|
||||
|
||||
if (
|
||||
physicalReleaseMoment.isAfter(start) &&
|
||||
physicalReleaseMoment.isBefore(end)
|
||||
) {
|
||||
dates.push(physicalReleaseMoment);
|
||||
}
|
||||
}
|
||||
|
||||
const sortDate = moment.min(...dates);
|
||||
|
||||
return {
|
||||
...item,
|
||||
sortDate,
|
||||
};
|
||||
});
|
||||
|
||||
result.sort((a, b) => (a.sortDate > b.sortDate ? 1 : -1));
|
||||
|
||||
return result;
|
||||
}, [items, start, end]);
|
||||
|
||||
return (
|
||||
<div className={styles.agenda}>
|
||||
{events.map((item, index) => {
|
||||
const momentDate = moment(item.sortDate);
|
||||
const showDate =
|
||||
index === 0 ||
|
||||
!moment(events[index - 1].sortDate).isSame(momentDate, 'day');
|
||||
|
||||
return <AgendaEvent key={item.id} showDate={showDate} {...item} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Agenda;
|
||||
@@ -1,14 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import Agenda from './Agenda';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
(calendar) => {
|
||||
return calendar;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(Agenda);
|
||||
@@ -53,6 +53,13 @@
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.releaseIcon {
|
||||
margin-right: 20px;
|
||||
width: 25px;
|
||||
cursor: default;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.statusIcon {
|
||||
margin-left: 3px;
|
||||
cursor: default;
|
||||
@@ -107,8 +114,3 @@
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.releaseIcon {
|
||||
margin-right: 20px;
|
||||
width: 25px;
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AgendaEvent.css';
|
||||
|
||||
class AgendaEvent extends Component {
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isDetailsModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
this.setState({ isDetailsModalOpen: true });
|
||||
};
|
||||
|
||||
onDetailsModalClose = () => {
|
||||
this.setState({ isDetailsModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
movieFile,
|
||||
title,
|
||||
titleSlug,
|
||||
genres,
|
||||
isAvailable,
|
||||
inCinemas,
|
||||
digitalRelease,
|
||||
physicalRelease,
|
||||
monitored,
|
||||
hasFile,
|
||||
grabbed,
|
||||
queueItem,
|
||||
showDate,
|
||||
showMovieInformation,
|
||||
showCutoffUnmetIcon,
|
||||
longDateFormat,
|
||||
colorImpairedMode,
|
||||
cinemaDateParsed,
|
||||
digitalDateParsed,
|
||||
physicalDateParsed,
|
||||
sortDate
|
||||
} = this.props;
|
||||
|
||||
let startTime = null;
|
||||
let releaseIcon = null;
|
||||
|
||||
if (physicalDateParsed === sortDate) {
|
||||
startTime = physicalRelease;
|
||||
releaseIcon = icons.DISC;
|
||||
}
|
||||
|
||||
if (digitalDateParsed === sortDate) {
|
||||
startTime = digitalRelease;
|
||||
releaseIcon = icons.MOVIE_FILE;
|
||||
}
|
||||
|
||||
if (cinemaDateParsed === sortDate) {
|
||||
startTime = inCinemas;
|
||||
releaseIcon = icons.IN_CINEMAS;
|
||||
}
|
||||
|
||||
startTime = moment(startTime);
|
||||
const downloading = !!(queueItem || grabbed);
|
||||
const isMonitored = monitored;
|
||||
const statusStyle = getStatusStyle(hasFile, downloading, isMonitored, isAvailable);
|
||||
const joinedGenres = genres.slice(0, 2).join(', ');
|
||||
const link = `/movie/${titleSlug}`;
|
||||
|
||||
return (
|
||||
<div className={styles.event}>
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
to={link}
|
||||
/>
|
||||
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.date}>
|
||||
{showDate ? startTime.format(longDateFormat) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.releaseIcon}>
|
||||
<Icon
|
||||
name={releaseIcon}
|
||||
kind={kinds.DEFAULT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
styles.eventWrapper,
|
||||
styles[statusStyle],
|
||||
colorImpairedMode && 'colorImpaired'
|
||||
)}
|
||||
>
|
||||
<div className={styles.movieTitle}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{
|
||||
showMovieInformation &&
|
||||
<div className={styles.genres}>
|
||||
{joinedGenres}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!!queueItem &&
|
||||
<span className={styles.statusIcon}>
|
||||
<CalendarEventQueueDetails
|
||||
{...queueItem}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
|
||||
{
|
||||
!queueItem && grabbed &&
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.DOWNLOADING}
|
||||
title={translate('MovieIsDownloading')}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
showCutoffUnmetIcon && !!movieFile && movieFile.qualityCutoffNotMet &&
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.MOVIE_FILE}
|
||||
kind={kinds.WARNING}
|
||||
title={translate('QualityCutoffNotMet')}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AgendaEvent.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
movieFile: PropTypes.object,
|
||||
title: PropTypes.string.isRequired,
|
||||
titleSlug: PropTypes.string.isRequired,
|
||||
genres: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
isAvailable: PropTypes.bool.isRequired,
|
||||
inCinemas: PropTypes.string,
|
||||
digitalRelease: PropTypes.string,
|
||||
physicalRelease: PropTypes.string,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
hasFile: PropTypes.bool.isRequired,
|
||||
grabbed: PropTypes.bool,
|
||||
queueItem: PropTypes.object,
|
||||
showDate: PropTypes.bool.isRequired,
|
||||
showMovieInformation: PropTypes.bool.isRequired,
|
||||
showCutoffUnmetIcon: PropTypes.bool.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired,
|
||||
longDateFormat: PropTypes.string.isRequired,
|
||||
colorImpairedMode: PropTypes.bool.isRequired,
|
||||
cinemaDateParsed: PropTypes.number,
|
||||
digitalDateParsed: PropTypes.number,
|
||||
physicalDateParsed: PropTypes.number,
|
||||
sortDate: PropTypes.number
|
||||
};
|
||||
|
||||
AgendaEvent.defaultProps = {
|
||||
genres: []
|
||||
};
|
||||
|
||||
export default AgendaEvent;
|
||||
160
frontend/src/Calendar/Agenda/AgendaEvent.tsx
Normal file
160
frontend/src/Calendar/Agenda/AgendaEvent.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useMovieFile from 'MovieFile/useMovieFile';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AgendaEvent.css';
|
||||
|
||||
interface AgendaEventProps {
|
||||
id: number;
|
||||
movieFileId: number;
|
||||
title: string;
|
||||
titleSlug: string;
|
||||
genres: string[];
|
||||
inCinemas?: string;
|
||||
digitalRelease?: string;
|
||||
physicalRelease?: string;
|
||||
sortDate: moment.Moment;
|
||||
isAvailable: boolean;
|
||||
monitored: boolean;
|
||||
hasFile: boolean;
|
||||
grabbed?: boolean;
|
||||
showDate: boolean;
|
||||
}
|
||||
|
||||
function AgendaEvent({
|
||||
id,
|
||||
movieFileId,
|
||||
title,
|
||||
titleSlug,
|
||||
genres = [],
|
||||
inCinemas,
|
||||
digitalRelease,
|
||||
physicalRelease,
|
||||
sortDate,
|
||||
isAvailable,
|
||||
monitored: isMonitored,
|
||||
hasFile,
|
||||
grabbed,
|
||||
showDate,
|
||||
}: AgendaEventProps) {
|
||||
const movieFile = useMovieFile(movieFileId);
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(id));
|
||||
const { longDateFormat, enableColorImpairedMode } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
const { showMovieInformation, showCutoffUnmetIcon } = useSelector(
|
||||
(state: AppState) => state.calendar.options
|
||||
);
|
||||
|
||||
const { eventDate, eventTitle, releaseIcon } = useMemo(() => {
|
||||
if (physicalRelease && sortDate.isSame(moment(physicalRelease), 'day')) {
|
||||
return {
|
||||
eventDate: physicalRelease,
|
||||
eventTitle: translate('PhysicalRelease'),
|
||||
releaseIcon: icons.DISC,
|
||||
};
|
||||
}
|
||||
|
||||
if (digitalRelease && sortDate.isSame(moment(digitalRelease), 'day')) {
|
||||
return {
|
||||
eventDate: digitalRelease,
|
||||
eventTitle: translate('DigitalRelease'),
|
||||
releaseIcon: icons.MOVIE_FILE,
|
||||
};
|
||||
}
|
||||
|
||||
if (inCinemas && sortDate.isSame(moment(inCinemas), 'day')) {
|
||||
return {
|
||||
eventDate: inCinemas,
|
||||
eventTitle: translate('InCinemas'),
|
||||
releaseIcon: icons.IN_CINEMAS,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
eventDate: null,
|
||||
eventTitle: null,
|
||||
releaseIcon: null,
|
||||
};
|
||||
}, [inCinemas, digitalRelease, physicalRelease, sortDate]);
|
||||
|
||||
const downloading = !!(queueItem || grabbed);
|
||||
const statusStyle = getStatusStyle(
|
||||
hasFile,
|
||||
downloading,
|
||||
isMonitored,
|
||||
isAvailable
|
||||
);
|
||||
const joinedGenres = genres.slice(0, 2).join(', ');
|
||||
const link = `/movie/${titleSlug}`;
|
||||
|
||||
return (
|
||||
<div className={styles.event}>
|
||||
<Link className={styles.underlay} to={link} />
|
||||
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.date}>
|
||||
{showDate && eventDate
|
||||
? moment(eventDate).format(longDateFormat)
|
||||
: null}
|
||||
</div>
|
||||
|
||||
<div className={styles.releaseIcon}>
|
||||
{releaseIcon ? (
|
||||
<Icon name={releaseIcon} kind={kinds.DEFAULT} title={eventTitle} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
styles.eventWrapper,
|
||||
styles[statusStyle],
|
||||
enableColorImpairedMode && 'colorImpaired'
|
||||
)}
|
||||
>
|
||||
<div className={styles.movieTitle}>{title}</div>
|
||||
|
||||
{showMovieInformation ? (
|
||||
<div className={styles.genres}>{joinedGenres}</div>
|
||||
) : null}
|
||||
|
||||
{queueItem ? (
|
||||
<span className={styles.statusIcon}>
|
||||
<CalendarEventQueueDetails {...queueItem} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{!queueItem && grabbed ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.DOWNLOADING}
|
||||
title={translate('MovieIsDownloading')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showCutoffUnmetIcon && movieFile && movieFile.qualityCutoffNotMet ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.MOVIE_FILE}
|
||||
kind={kinds.WARNING}
|
||||
title={translate('QualityCutoffNotMet')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgendaEvent;
|
||||
@@ -1,30 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector';
|
||||
import createMovieSelector from 'Store/Selectors/createMovieSelector';
|
||||
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import AgendaEvent from './AgendaEvent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.options,
|
||||
createMovieSelector(),
|
||||
createMovieFileSelector(),
|
||||
createQueueItemSelector(),
|
||||
createUISettingsSelector(),
|
||||
(calendarOptions, movie, movieFile, queueItem, uiSettings) => {
|
||||
return {
|
||||
movie,
|
||||
movieFile,
|
||||
queueItem,
|
||||
...calendarOptions,
|
||||
timeFormat: uiSettings.timeFormat,
|
||||
longDateFormat: uiSettings.longDateFormat,
|
||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(AgendaEvent);
|
||||
@@ -1,67 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AgendaConnector from './Agenda/AgendaConnector';
|
||||
import * as calendarViews from './calendarViews';
|
||||
import CalendarDaysConnector from './Day/CalendarDaysConnector';
|
||||
import DaysOfWeekConnector from './Day/DaysOfWeekConnector';
|
||||
import CalendarHeaderConnector from './Header/CalendarHeaderConnector';
|
||||
import styles from './Calendar.css';
|
||||
|
||||
class Calendar extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
view
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.calendar}>
|
||||
{
|
||||
isFetching && !isPopulated &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !!error &&
|
||||
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
!error && isPopulated && view === calendarViews.AGENDA &&
|
||||
<div className={styles.calendarContent}>
|
||||
<CalendarHeaderConnector />
|
||||
<AgendaConnector />
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!error && isPopulated && view !== calendarViews.AGENDA &&
|
||||
<div className={styles.calendarContent}>
|
||||
<CalendarHeaderConnector />
|
||||
<DaysOfWeekConnector />
|
||||
<CalendarDaysConnector />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Calendar.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
view: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
164
frontend/src/Calendar/Calendar.tsx
Normal file
164
frontend/src/Calendar/Calendar.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import Movie from 'Movie/Movie';
|
||||
import {
|
||||
clearCalendar,
|
||||
fetchCalendar,
|
||||
gotoCalendarToday,
|
||||
} from 'Store/Actions/calendarActions';
|
||||
import {
|
||||
clearMovieFiles,
|
||||
fetchMovieFiles,
|
||||
} from 'Store/Actions/movieFileActions';
|
||||
import {
|
||||
clearQueueDetails,
|
||||
fetchQueueDetails,
|
||||
} from 'Store/Actions/queueActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import {
|
||||
registerPagePopulator,
|
||||
unregisterPagePopulator,
|
||||
} from 'Utilities/pagePopulator';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Agenda from './Agenda/Agenda';
|
||||
import CalendarDays from './Day/CalendarDays';
|
||||
import DaysOfWeek from './Day/DaysOfWeek';
|
||||
import CalendarHeader from './Header/CalendarHeader';
|
||||
import styles from './Calendar.css';
|
||||
|
||||
const UPDATE_DELAY = 3600000; // 1 hour
|
||||
|
||||
function Calendar() {
|
||||
const dispatch = useDispatch();
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const { isFetching, isPopulated, error, items, time, view } = useSelector(
|
||||
(state: AppState) => state.calendar
|
||||
);
|
||||
|
||||
const isRefreshingMovie = useSelector(
|
||||
createCommandExecutingSelector(commandNames.REFRESH_MOVIE)
|
||||
);
|
||||
|
||||
const firstDayOfWeek = useSelector(
|
||||
(state: AppState) => state.settings.ui.item.firstDayOfWeek
|
||||
);
|
||||
|
||||
const wasRefreshingMovie = usePrevious(isRefreshingMovie);
|
||||
const previousFirstDayOfWeek = usePrevious(firstDayOfWeek);
|
||||
const previousItems = usePrevious(items);
|
||||
|
||||
const handleScheduleUpdate = useCallback(() => {
|
||||
clearTimeout(updateTimeout.current);
|
||||
|
||||
function updateCalendar() {
|
||||
dispatch(gotoCalendarToday());
|
||||
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
|
||||
}
|
||||
|
||||
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
handleScheduleUpdate();
|
||||
|
||||
return () => {
|
||||
dispatch(clearCalendar());
|
||||
dispatch(clearQueueDetails());
|
||||
dispatch(clearMovieFiles());
|
||||
clearTimeout(updateTimeout.current);
|
||||
};
|
||||
}, [dispatch, handleScheduleUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchCalendar());
|
||||
} else {
|
||||
dispatch(gotoCalendarToday());
|
||||
}
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchQueueDetails({ time, view }));
|
||||
dispatch(fetchCalendar({ time, view }));
|
||||
};
|
||||
|
||||
registerPagePopulator(repopulate, ['movieFileUpdated', 'movieFileDeleted']);
|
||||
|
||||
return () => {
|
||||
unregisterPagePopulator(repopulate);
|
||||
};
|
||||
}, [time, view, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
handleScheduleUpdate();
|
||||
}, [time, handleScheduleUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
previousFirstDayOfWeek != null &&
|
||||
firstDayOfWeek !== previousFirstDayOfWeek
|
||||
) {
|
||||
dispatch(fetchCalendar({ time, view }));
|
||||
}
|
||||
}, [time, view, firstDayOfWeek, previousFirstDayOfWeek, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasRefreshingMovie && !isRefreshingMovie) {
|
||||
dispatch(fetchCalendar({ time, view }));
|
||||
}
|
||||
}, [time, view, isRefreshingMovie, wasRefreshingMovie, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previousItems || hasDifferentItems(items, previousItems)) {
|
||||
const movieIds = selectUniqueIds<Movie, number>(items, 'id');
|
||||
const movieFileIds = selectUniqueIds<Movie, number>(items, 'movieFileId');
|
||||
|
||||
if (items.length) {
|
||||
dispatch(fetchQueueDetails({ movieIds }));
|
||||
}
|
||||
|
||||
if (movieFileIds.length) {
|
||||
dispatch(fetchMovieFiles({ movieFileIds }));
|
||||
}
|
||||
}
|
||||
}, [items, previousItems, dispatch]);
|
||||
|
||||
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 />
|
||||
<DaysOfWeek />
|
||||
<CalendarDays />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Calendar;
|
||||
@@ -1,195 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import * as calendarActions from 'Store/Actions/calendarActions';
|
||||
import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions';
|
||||
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
|
||||
import Calendar from './Calendar';
|
||||
|
||||
const UPDATE_DELAY = 3600000; // 1 hour
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
(state) => state.settings.ui.item.firstDayOfWeek,
|
||||
createCommandExecutingSelector(commandNames.REFRESH_MOVIE),
|
||||
(calendar, firstDayOfWeek, isRefreshingMovie) => {
|
||||
return {
|
||||
...calendar,
|
||||
isRefreshingMovie,
|
||||
firstDayOfWeek
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
...calendarActions,
|
||||
fetchMovieFiles,
|
||||
clearMovieFiles,
|
||||
fetchQueueDetails,
|
||||
clearQueueDetails
|
||||
};
|
||||
|
||||
class CalendarConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.updateTimeoutId = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
useCurrentPage,
|
||||
fetchCalendar,
|
||||
gotoCalendarToday
|
||||
} = this.props;
|
||||
|
||||
registerPagePopulator(this.repopulate, ['movieFileUpdated', 'movieFileDeleted']);
|
||||
|
||||
if (useCurrentPage) {
|
||||
fetchCalendar();
|
||||
} else {
|
||||
gotoCalendarToday();
|
||||
}
|
||||
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
items,
|
||||
time,
|
||||
view,
|
||||
isRefreshingMovie,
|
||||
firstDayOfWeek
|
||||
} = this.props;
|
||||
|
||||
if (hasDifferentItems(prevProps.items, items)) {
|
||||
const movieFileIds = selectUniqueIds(items, 'movieFileId');
|
||||
|
||||
if (movieFileIds.length) {
|
||||
this.props.fetchMovieFiles({ movieFileIds });
|
||||
}
|
||||
|
||||
if (items.length) {
|
||||
this.props.fetchQueueDetails();
|
||||
}
|
||||
}
|
||||
|
||||
if (prevProps.time !== time) {
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
|
||||
if (prevProps.firstDayOfWeek !== firstDayOfWeek) {
|
||||
this.props.fetchCalendar({ time, view });
|
||||
}
|
||||
|
||||
if (prevProps.isRefreshingMovie && !isRefreshingMovie) {
|
||||
this.props.fetchCalendar({ time, view });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
unregisterPagePopulator(this.repopulate);
|
||||
this.props.clearCalendar();
|
||||
this.props.clearQueueDetails();
|
||||
this.props.clearMovieFiles();
|
||||
this.clearUpdateTimeout();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
repopulate = () => {
|
||||
const {
|
||||
time,
|
||||
view
|
||||
} = this.props;
|
||||
|
||||
this.props.fetchQueueDetails({ time, view });
|
||||
this.props.fetchCalendar({ time, view });
|
||||
};
|
||||
|
||||
scheduleUpdate = () => {
|
||||
this.clearUpdateTimeout();
|
||||
|
||||
this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY);
|
||||
};
|
||||
|
||||
clearUpdateTimeout = () => {
|
||||
if (this.updateTimeoutId) {
|
||||
clearTimeout(this.updateTimeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
updateCalendar = () => {
|
||||
this.props.gotoCalendarToday();
|
||||
this.scheduleUpdate();
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onCalendarViewChange = (view) => {
|
||||
this.props.setCalendarView({ view });
|
||||
};
|
||||
|
||||
onTodayPress = () => {
|
||||
this.props.gotoCalendarToday();
|
||||
};
|
||||
|
||||
onPreviousPress = () => {
|
||||
this.props.gotoCalendarPreviousRange();
|
||||
};
|
||||
|
||||
onNextPress = () => {
|
||||
this.props.gotoCalendarNextRange();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Calendar
|
||||
{...this.props}
|
||||
onCalendarViewChange={this.onCalendarViewChange}
|
||||
onTodayPress={this.onTodayPress}
|
||||
onPreviousPress={this.onPreviousPress}
|
||||
onNextPress={this.onNextPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarConnector.propTypes = {
|
||||
useCurrentPage: PropTypes.bool.isRequired,
|
||||
time: PropTypes.string,
|
||||
view: PropTypes.string.isRequired,
|
||||
firstDayOfWeek: PropTypes.number.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isRefreshingMovie: PropTypes.bool.isRequired,
|
||||
setCalendarView: PropTypes.func.isRequired,
|
||||
gotoCalendarToday: PropTypes.func.isRequired,
|
||||
gotoCalendarPreviousRange: PropTypes.func.isRequired,
|
||||
gotoCalendarNextRange: PropTypes.func.isRequired,
|
||||
clearCalendar: PropTypes.func.isRequired,
|
||||
fetchCalendar: PropTypes.func.isRequired,
|
||||
fetchMovieFiles: PropTypes.func.isRequired,
|
||||
clearMovieFiles: PropTypes.func.isRequired,
|
||||
fetchQueueDetails: PropTypes.func.isRequired,
|
||||
clearQueueDetails: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector);
|
||||
@@ -1,224 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Measure from 'Components/Measure';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import NoMovie from 'Movie/NoMovie';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CalendarConnector from './CalendarConnector';
|
||||
import CalendarFilterModal from './CalendarFilterModal';
|
||||
import CalendarLinkModal from './iCal/CalendarLinkModal';
|
||||
import LegendConnector from './Legend/LegendConnector';
|
||||
import CalendarOptionsModal from './Options/CalendarOptionsModal';
|
||||
import styles from './CalendarPage.css';
|
||||
|
||||
const MINIMUM_DAY_WIDTH = 120;
|
||||
|
||||
class CalendarPage extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isCalendarLinkModalOpen: false,
|
||||
isOptionsModalOpen: false,
|
||||
width: 0
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMeasure = ({ width }) => {
|
||||
this.setState({ width });
|
||||
const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)));
|
||||
|
||||
this.props.onDaysCountChange(days);
|
||||
};
|
||||
|
||||
onGetCalendarLinkPress = () => {
|
||||
this.setState({ isCalendarLinkModalOpen: true });
|
||||
};
|
||||
|
||||
onGetCalendarLinkModalClose = () => {
|
||||
this.setState({ isCalendarLinkModalOpen: false });
|
||||
};
|
||||
|
||||
onOptionsPress = () => {
|
||||
this.setState({ isOptionsModalOpen: true });
|
||||
};
|
||||
|
||||
onOptionsModalClose = () => {
|
||||
this.setState({ isOptionsModalOpen: false });
|
||||
};
|
||||
|
||||
onSearchMissingPress = () => {
|
||||
const {
|
||||
missingMovieIds,
|
||||
onSearchMissingPress
|
||||
} = this.props;
|
||||
|
||||
onSearchMissingPress(missingMovieIds);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
hasMovie,
|
||||
movieError,
|
||||
movieIsFetching,
|
||||
movieIsPopulated,
|
||||
missingMovieIds,
|
||||
customFilters,
|
||||
isRssSyncExecuting,
|
||||
isSearchingForMissing,
|
||||
useCurrentPage,
|
||||
onRssSyncPress,
|
||||
onFilterSelect
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
isCalendarLinkModalOpen,
|
||||
isOptionsModalOpen
|
||||
} = this.state;
|
||||
|
||||
const isMeasured = this.state.width > 0;
|
||||
|
||||
return (
|
||||
<PageContent title={translate('Calendar')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('ICalLink')}
|
||||
iconName={icons.CALENDAR}
|
||||
onPress={this.onGetCalendarLinkPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
onPress={onRssSyncPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchForMissing')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!missingMovieIds.length}
|
||||
isSpinning={isSearchingForMissing}
|
||||
onPress={this.onSearchMissingPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.POSTER}
|
||||
onPress={this.onOptionsPress}
|
||||
/>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
isDisabled={!hasMovie}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={CalendarFilterModal}
|
||||
onFilterSelect={onFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody
|
||||
className={styles.calendarPageBody}
|
||||
innerClassName={styles.calendarInnerPageBody}
|
||||
>
|
||||
{
|
||||
movieIsFetching && !movieIsPopulated &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
movieError &&
|
||||
<div className={styles.errorMessage}>
|
||||
{getErrorMessage(movieError, 'Failed to load movies from API')}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!movieError && movieIsPopulated && hasMovie &&
|
||||
<Measure
|
||||
whitelist={['width']}
|
||||
onMeasure={this.onMeasure}
|
||||
>
|
||||
{
|
||||
isMeasured ?
|
||||
<CalendarConnector
|
||||
useCurrentPage={useCurrentPage}
|
||||
/> :
|
||||
<div />
|
||||
}
|
||||
</Measure>
|
||||
}
|
||||
|
||||
{
|
||||
!movieError && movieIsPopulated && !hasMovie &&
|
||||
<NoMovie totalItems={0} />
|
||||
}
|
||||
|
||||
{
|
||||
hasMovie && !movieError &&
|
||||
<LegendConnector />
|
||||
}
|
||||
</PageContentBody>
|
||||
|
||||
<CalendarLinkModal
|
||||
isOpen={isCalendarLinkModalOpen}
|
||||
onModalClose={this.onGetCalendarLinkModalClose}
|
||||
/>
|
||||
|
||||
<CalendarOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={this.onOptionsModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarPage.propTypes = {
|
||||
selectedFilterKey: PropTypes.string.isRequired,
|
||||
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
hasMovie: PropTypes.bool.isRequired,
|
||||
movieError: PropTypes.object,
|
||||
movieIsFetching: PropTypes.bool.isRequired,
|
||||
movieIsPopulated: PropTypes.bool.isRequired,
|
||||
missingMovieIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isRssSyncExecuting: PropTypes.bool.isRequired,
|
||||
isSearchingForMissing: PropTypes.bool.isRequired,
|
||||
useCurrentPage: PropTypes.bool.isRequired,
|
||||
onSearchMissingPress: PropTypes.func.isRequired,
|
||||
onDaysCountChange: PropTypes.func.isRequired,
|
||||
onRssSyncPress: PropTypes.func.isRequired,
|
||||
onFilterSelect: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarPage;
|
||||
224
frontend/src/Calendar/CalendarPage.tsx
Normal file
224
frontend/src/Calendar/CalendarPage.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import NoMovie from 'Movie/NoMovie';
|
||||
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 createMovieCountSelector from 'Store/Selectors/createMovieCountSelector';
|
||||
import { isCommandExecuting } from 'Utilities/Command';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Calendar from './Calendar';
|
||||
import CalendarFilterModal from './CalendarFilterModal';
|
||||
import CalendarLinkModal from './iCal/CalendarLinkModal';
|
||||
import Legend from './Legend/Legend';
|
||||
import CalendarOptionsModal from './Options/CalendarOptionsModal';
|
||||
import styles from './CalendarPage.css';
|
||||
|
||||
const MINIMUM_DAY_WIDTH = 120;
|
||||
|
||||
function createMissingMovieIdsSelector() {
|
||||
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, movies, queueDetails) => {
|
||||
return movies.reduce<number[]>((acc, movie) => {
|
||||
const { inCinemas } = movie;
|
||||
|
||||
if (
|
||||
!movie.movieFileId &&
|
||||
inCinemas &&
|
||||
moment(inCinemas).isAfter(start) &&
|
||||
moment(inCinemas).isBefore(end) &&
|
||||
isBefore(inCinemas) &&
|
||||
!queueDetails.some(
|
||||
(details) => !!details.movie && details.movie.id === movie.id
|
||||
)
|
||||
) {
|
||||
acc.push(movie.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createIsSearchingSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.searchMissingCommandId,
|
||||
createCommandsSelector(),
|
||||
(searchMissingCommandId, commands) => {
|
||||
if (searchMissingCommandId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isCommandExecuting(
|
||||
commands.find((command) => {
|
||||
return command.id === searchMissingCommandId;
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarPage() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { selectedFilterKey, filters } = useSelector(
|
||||
(state: AppState) => state.calendar
|
||||
);
|
||||
const missingMovieIds = useSelector(createMissingMovieIdsSelector());
|
||||
const isSearchingForMissing = useSelector(createIsSearchingSelector());
|
||||
const isRssSyncExecuting = useSelector(
|
||||
createCommandExecutingSelector(commandNames.RSS_SYNC)
|
||||
);
|
||||
const customFilters = useSelector(createCustomFiltersSelector('calendar'));
|
||||
const hasMovies = !!useSelector(createMovieCountSelector());
|
||||
|
||||
const [pageContentRef, { width }] = useMeasure();
|
||||
const [isCalendarLinkModalOpen, setIsCalendarLinkModalOpen] = useState(false);
|
||||
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
|
||||
|
||||
const isMeasured = width > 0;
|
||||
const PageComponent = hasMovies ? Calendar : NoMovie;
|
||||
|
||||
const handleGetCalendarLinkPress = useCallback(() => {
|
||||
setIsCalendarLinkModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleGetCalendarLinkModalClose = useCallback(() => {
|
||||
setIsCalendarLinkModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleOptionsPress = useCallback(() => {
|
||||
setIsOptionsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleOptionsModalClose = useCallback(() => {
|
||||
setIsOptionsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleRssSyncPress = useCallback(() => {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: commandNames.RSS_SYNC,
|
||||
})
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSearchMissingPress = useCallback(() => {
|
||||
dispatch(searchMissing({ movieIds: missingMovieIds }));
|
||||
}, [missingMovieIds, dispatch]);
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(key: string | number) => {
|
||||
dispatch(setCalendarFilter({ selectedFilterKey: key }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dayCount = Math.max(
|
||||
3,
|
||||
Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH))
|
||||
);
|
||||
|
||||
dispatch(setCalendarDaysCount({ dayCount }));
|
||||
}, [width, dispatch]);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('Calendar')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('ICalLink')}
|
||||
iconName={icons.CALENDAR}
|
||||
onPress={handleGetCalendarLinkPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
onPress={handleRssSyncPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchForMissing')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!missingMovieIds.length}
|
||||
isSpinning={isSearchingForMissing}
|
||||
onPress={handleSearchMissingPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.POSTER}
|
||||
onPress={handleOptionsPress}
|
||||
/>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
isDisabled={!hasMovies}
|
||||
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 />}
|
||||
{hasMovies && <Legend />}
|
||||
</PageContentBody>
|
||||
|
||||
<CalendarLinkModal
|
||||
isOpen={isCalendarLinkModalOpen}
|
||||
onModalClose={handleGetCalendarLinkModalClose}
|
||||
/>
|
||||
|
||||
<CalendarOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={handleOptionsModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarPage;
|
||||
@@ -1,120 +0,0 @@
|
||||
import moment from 'moment';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import withCurrentPage from 'Components/withCurrentPage';
|
||||
import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import createMovieCountSelector from 'Store/Selectors/createMovieCountSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import { isCommandExecuting } from 'Utilities/Command';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import CalendarPage from './CalendarPage';
|
||||
|
||||
function createMissingMovieIdsSelector() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.start,
|
||||
(state) => state.calendar.end,
|
||||
(state) => state.calendar.items,
|
||||
(state) => state.queue.details.items,
|
||||
(start, end, movies, queueDetails) => {
|
||||
return movies.reduce((acc, movie) => {
|
||||
const inCinemas = movie.inCinemas;
|
||||
|
||||
if (
|
||||
!movie.hasFile &&
|
||||
moment(inCinemas).isAfter(start) &&
|
||||
moment(inCinemas).isBefore(end) &&
|
||||
isBefore(movie.inCinemas) &&
|
||||
!queueDetails.some((details) => details.movieId === movie.id)
|
||||
) {
|
||||
acc.push(movie.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createIsSearchingSelector() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.searchMissingCommandId,
|
||||
createCommandsSelector(),
|
||||
(searchMissingCommandId, commands) => {
|
||||
if (searchMissingCommandId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isCommandExecuting(commands.find((command) => {
|
||||
return command.id === searchMissingCommandId;
|
||||
}));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.selectedFilterKey,
|
||||
(state) => state.calendar.filters,
|
||||
createCustomFiltersSelector('calendar'),
|
||||
createMovieCountSelector(),
|
||||
createUISettingsSelector(),
|
||||
createMissingMovieIdsSelector(),
|
||||
createCommandExecutingSelector(commandNames.RSS_SYNC),
|
||||
createIsSearchingSelector(),
|
||||
(
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
customFilters,
|
||||
movieCount,
|
||||
uiSettings,
|
||||
missingMovieIds,
|
||||
isRssSyncExecuting,
|
||||
isSearchingForMissing
|
||||
) => {
|
||||
return {
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
customFilters,
|
||||
colorImpairedMode: uiSettings.enableColorImpairedMode,
|
||||
hasMovie: !!movieCount.count,
|
||||
movieError: movieCount.error,
|
||||
movieIsFetching: movieCount.isFetching,
|
||||
movieIsPopulated: movieCount.isPopulated,
|
||||
missingMovieIds,
|
||||
isRssSyncExecuting,
|
||||
isSearchingForMissing
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onRssSyncPress() {
|
||||
dispatch(executeCommand({
|
||||
name: commandNames.RSS_SYNC
|
||||
}));
|
||||
},
|
||||
|
||||
onSearchMissingPress(movieIds) {
|
||||
dispatch(searchMissing({ movieIds }));
|
||||
},
|
||||
|
||||
onDaysCountChange(dayCount) {
|
||||
dispatch(setCalendarDaysCount({ dayCount }));
|
||||
},
|
||||
|
||||
onFilterSelect(selectedFilterKey) {
|
||||
dispatch(setCalendarFilter({ selectedFilterKey }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default withCurrentPage(
|
||||
connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage)
|
||||
);
|
||||
@@ -1,23 +1,61 @@
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
|
||||
import CalendarEvent from 'typings/CalendarEvent';
|
||||
import CalendarEvent from 'Calendar/Events/CalendarEvent';
|
||||
import { CalendarEvent as CalendarEventModel } from 'typings/Calendar';
|
||||
import styles from './CalendarDay.css';
|
||||
|
||||
function sort(items: CalendarEventModel[]) {
|
||||
return items.sort((a, b) => {
|
||||
const aDate = moment(a.inCinemas).unix();
|
||||
const bDate = moment(b.inCinemas).unix();
|
||||
|
||||
return aDate - bDate;
|
||||
});
|
||||
}
|
||||
|
||||
function createCalendarEventsConnector(date: string) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.items,
|
||||
(state: AppState) => state.calendar.options,
|
||||
(items, options) => {
|
||||
const { showCinemaRelease, showDigitalRelease, showPhysicalRelease } =
|
||||
options;
|
||||
const momentDate = moment(date);
|
||||
|
||||
const filtered = items.filter(
|
||||
({ inCinemas, digitalRelease, physicalRelease }) => {
|
||||
return (
|
||||
(showCinemaRelease &&
|
||||
inCinemas &&
|
||||
momentDate.isSame(moment(inCinemas), 'day')) ||
|
||||
(showDigitalRelease &&
|
||||
digitalRelease &&
|
||||
momentDate.isSame(moment(digitalRelease), 'day')) ||
|
||||
(showPhysicalRelease &&
|
||||
physicalRelease &&
|
||||
momentDate.isSame(moment(physicalRelease), 'day'))
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return sort(filtered);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface CalendarDayProps {
|
||||
date: string;
|
||||
time: string;
|
||||
isTodaysDate: boolean;
|
||||
events: CalendarEvent[];
|
||||
view: string;
|
||||
onEventModalOpenToggle(...args: unknown[]): unknown;
|
||||
}
|
||||
|
||||
function CalendarDay(props: CalendarDayProps) {
|
||||
const { date, time, isTodaysDate, events, view, onEventModalOpenToggle } =
|
||||
props;
|
||||
function CalendarDay({ date, isTodaysDate }: CalendarDayProps) {
|
||||
const { time, view } = useSelector((state: AppState) => state.calendar);
|
||||
const events = useSelector(createCalendarEventsConnector(date));
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -50,13 +88,7 @@ function CalendarDay(props: CalendarDayProps) {
|
||||
<div>
|
||||
{events.map((event) => {
|
||||
return (
|
||||
<CalendarEventConnector
|
||||
key={event.id}
|
||||
{...event}
|
||||
movieId={event.id}
|
||||
date={date as string}
|
||||
onEventModalOpenToggle={onEventModalOpenToggle}
|
||||
/>
|
||||
<CalendarEvent key={event.id} {...event} date={date as string} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import CalendarDay from './CalendarDay';
|
||||
|
||||
function sort(items) {
|
||||
return _.sortBy(items, (item) => {
|
||||
if (item.isGroup) {
|
||||
return moment(item.events[0].inCinemas).unix();
|
||||
}
|
||||
|
||||
return moment(item.inCinemas).unix();
|
||||
});
|
||||
}
|
||||
|
||||
function createCalendarEventsConnector() {
|
||||
return createSelector(
|
||||
(state, { date }) => date,
|
||||
(state) => state.calendar.items,
|
||||
(date, items) => {
|
||||
const filtered = _.filter(items, (item) => {
|
||||
return (item.inCinemas && moment(date).isSame(moment(item.inCinemas), 'day')) ||
|
||||
(item.physicalRelease && moment(date).isSame(moment(item.physicalRelease), 'day')) ||
|
||||
(item.digitalRelease && moment(date).isSame(moment(item.digitalRelease), 'day'));
|
||||
});
|
||||
|
||||
return sort(filtered);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
createCalendarEventsConnector(),
|
||||
(calendar, events) => {
|
||||
return {
|
||||
time: calendar.time,
|
||||
view: calendar.view,
|
||||
events
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
class CalendarDayConnector extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CalendarDay
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarDayConnector.propTypes = {
|
||||
date: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps)(CalendarDayConnector);
|
||||
@@ -1,164 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import isToday from 'Utilities/Date/isToday';
|
||||
import CalendarDayConnector from './CalendarDayConnector';
|
||||
import styles from './CalendarDays.css';
|
||||
|
||||
class CalendarDays extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._touchStart = null;
|
||||
|
||||
this.state = {
|
||||
todaysDate: moment().startOf('day').toISOString(),
|
||||
isEventModalOpen: false
|
||||
};
|
||||
|
||||
this.updateTimeoutId = null;
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const view = this.props.view;
|
||||
|
||||
if (view === calendarViews.MONTH) {
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
|
||||
window.addEventListener('touchstart', this.onTouchStart);
|
||||
window.addEventListener('touchend', this.onTouchEnd);
|
||||
window.addEventListener('touchcancel', this.onTouchCancel);
|
||||
window.addEventListener('touchmove', this.onTouchMove);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.clearUpdateTimeout();
|
||||
|
||||
window.removeEventListener('touchstart', this.onTouchStart);
|
||||
window.removeEventListener('touchend', this.onTouchEnd);
|
||||
window.removeEventListener('touchcancel', this.onTouchCancel);
|
||||
window.removeEventListener('touchmove', this.onTouchMove);
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
scheduleUpdate = () => {
|
||||
this.clearUpdateTimeout();
|
||||
const todaysDate = moment().startOf('day');
|
||||
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
|
||||
|
||||
this.setState({ todaysDate: todaysDate.toISOString() });
|
||||
|
||||
this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
|
||||
};
|
||||
|
||||
clearUpdateTimeout = () => {
|
||||
if (this.updateTimeoutId) {
|
||||
clearTimeout(this.updateTimeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onEventModalOpenToggle = (isEventModalOpen) => {
|
||||
this.setState({ isEventModalOpen });
|
||||
};
|
||||
|
||||
onTouchStart = (event) => {
|
||||
const touches = event.touches;
|
||||
const touchStart = touches[0].pageX;
|
||||
|
||||
if (touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
touchStart < 50 ||
|
||||
this.props.isSidebarVisible ||
|
||||
this.state.isEventModalOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._touchStart = touchStart;
|
||||
};
|
||||
|
||||
onTouchEnd = (event) => {
|
||||
const touches = event.changedTouches;
|
||||
const currentTouch = touches[0].pageX;
|
||||
|
||||
if (!this._touchStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) {
|
||||
this.props.onNavigatePrevious();
|
||||
} else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) {
|
||||
this.props.onNavigateNext();
|
||||
}
|
||||
|
||||
this._touchStart = null;
|
||||
};
|
||||
|
||||
onTouchCancel = (event) => {
|
||||
this._touchStart = null;
|
||||
};
|
||||
|
||||
onTouchMove = (event) => {
|
||||
if (!this._touchStart) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dates,
|
||||
view
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
styles.days,
|
||||
styles[view]
|
||||
)}
|
||||
>
|
||||
{
|
||||
dates.map((date) => {
|
||||
return (
|
||||
<CalendarDayConnector
|
||||
key={date}
|
||||
date={date}
|
||||
isTodaysDate={isToday(date)}
|
||||
onEventModalOpenToggle={this.onEventModalOpenToggle}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarDays.propTypes = {
|
||||
dates: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
view: PropTypes.string.isRequired,
|
||||
isSidebarVisible: PropTypes.bool.isRequired,
|
||||
onNavigatePrevious: PropTypes.func.isRequired,
|
||||
onNavigateNext: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarDays;
|
||||
129
frontend/src/Calendar/Day/CalendarDays.tsx
Normal file
129
frontend/src/Calendar/Day/CalendarDays.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import {
|
||||
gotoCalendarNextRange,
|
||||
gotoCalendarPreviousRange,
|
||||
} from 'Store/Actions/calendarActions';
|
||||
import CalendarDay from './CalendarDay';
|
||||
import styles from './CalendarDays.css';
|
||||
|
||||
function CalendarDays() {
|
||||
const dispatch = useDispatch();
|
||||
const { dates, view } = useSelector((state: AppState) => state.calendar);
|
||||
const isSidebarVisible = useSelector(
|
||||
(state: AppState) => state.app.isSidebarVisible
|
||||
);
|
||||
|
||||
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
const touchStart = useRef<number | null>(null);
|
||||
const [todaysDate, setTodaysDate] = useState(
|
||||
moment().startOf('day').toISOString()
|
||||
);
|
||||
|
||||
const scheduleUpdate = useCallback(() => {
|
||||
clearTimeout(updateTimeout.current);
|
||||
|
||||
const todaysDate = moment().startOf('day');
|
||||
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
|
||||
|
||||
setTodaysDate(todaysDate.toISOString());
|
||||
|
||||
updateTimeout.current = setTimeout(scheduleUpdate, diff);
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(event: TouchEvent) => {
|
||||
const touches = event.touches;
|
||||
const currentTouch = touches[0].pageX;
|
||||
|
||||
if (touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTouch < 50 || isSidebarVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
touchStart.current = currentTouch;
|
||||
},
|
||||
[isSidebarVisible]
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(event: TouchEvent) => {
|
||||
const touches = event.changedTouches;
|
||||
const currentTouch = touches[0].pageX;
|
||||
|
||||
if (!touchStart.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
currentTouch > touchStart.current &&
|
||||
currentTouch - touchStart.current > 100
|
||||
) {
|
||||
dispatch(gotoCalendarPreviousRange());
|
||||
} else if (
|
||||
currentTouch < touchStart.current &&
|
||||
touchStart.current - currentTouch > 100
|
||||
) {
|
||||
dispatch(gotoCalendarNextRange());
|
||||
}
|
||||
|
||||
touchStart.current = null;
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTouchCancel = useCallback(() => {
|
||||
touchStart.current = null;
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback(() => {
|
||||
if (!touchStart.current) {
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === calendarViews.MONTH) {
|
||||
scheduleUpdate();
|
||||
}
|
||||
}, [view, scheduleUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('touchstart', handleTouchStart);
|
||||
window.addEventListener('touchend', handleTouchEnd);
|
||||
window.addEventListener('touchcancel', handleTouchCancel);
|
||||
window.addEventListener('touchmove', handleTouchMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', handleTouchStart);
|
||||
window.removeEventListener('touchend', handleTouchEnd);
|
||||
window.removeEventListener('touchcancel', handleTouchCancel);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
};
|
||||
}, [handleTouchStart, handleTouchEnd, handleTouchCancel, handleTouchMove]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.days, styles[view as keyof typeof styles])}
|
||||
>
|
||||
{dates.map((date) => {
|
||||
return (
|
||||
<CalendarDay
|
||||
key={date}
|
||||
date={date}
|
||||
isTodaysDate={date === todaysDate}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarDays;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { gotoCalendarNextRange, gotoCalendarPreviousRange } from 'Store/Actions/calendarActions';
|
||||
import CalendarDays from './CalendarDays';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
(state) => state.app.isSidebarVisible,
|
||||
(calendar, isSidebarVisible) => {
|
||||
return {
|
||||
dates: calendar.dates,
|
||||
view: calendar.view,
|
||||
isSidebarVisible
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
onNavigatePrevious: gotoCalendarPreviousRange,
|
||||
onNavigateNext: gotoCalendarNextRange
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays);
|
||||
@@ -1,56 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import styles from './DayOfWeek.css';
|
||||
|
||||
class DayOfWeek extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
date,
|
||||
view,
|
||||
isTodaysDate,
|
||||
calendarWeekColumnHeader,
|
||||
shortDateFormat,
|
||||
showRelativeDates
|
||||
} = this.props;
|
||||
|
||||
const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
|
||||
const momentDate = moment(date);
|
||||
let formatedDate = momentDate.format('dddd');
|
||||
|
||||
if (view === calendarViews.WEEK) {
|
||||
formatedDate = momentDate.format(calendarWeekColumnHeader);
|
||||
} else if (view === calendarViews.FORECAST) {
|
||||
formatedDate = getRelativeDate({ date, shortDateFormat, showRelativeDates });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
styles.dayOfWeek,
|
||||
view === calendarViews.DAY && styles.isSingleDay,
|
||||
highlightToday && styles.isToday
|
||||
)}
|
||||
>
|
||||
{formatedDate}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DayOfWeek.propTypes = {
|
||||
date: PropTypes.string.isRequired,
|
||||
view: PropTypes.string.isRequired,
|
||||
isTodaysDate: PropTypes.bool.isRequired,
|
||||
calendarWeekColumnHeader: PropTypes.string.isRequired,
|
||||
shortDateFormat: PropTypes.string.isRequired,
|
||||
showRelativeDates: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default DayOfWeek;
|
||||
54
frontend/src/Calendar/Day/DayOfWeek.tsx
Normal file
54
frontend/src/Calendar/Day/DayOfWeek.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React from 'react';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import styles from './DayOfWeek.css';
|
||||
|
||||
interface DayOfWeekProps {
|
||||
date: string;
|
||||
view: string;
|
||||
isTodaysDate: boolean;
|
||||
calendarWeekColumnHeader: string;
|
||||
shortDateFormat: string;
|
||||
showRelativeDates: boolean;
|
||||
}
|
||||
|
||||
function DayOfWeek(props: DayOfWeekProps) {
|
||||
const {
|
||||
date,
|
||||
view,
|
||||
isTodaysDate,
|
||||
calendarWeekColumnHeader,
|
||||
shortDateFormat,
|
||||
showRelativeDates,
|
||||
} = props;
|
||||
|
||||
const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
|
||||
const momentDate = moment(date);
|
||||
let formatedDate = momentDate.format('dddd');
|
||||
|
||||
if (view === calendarViews.WEEK) {
|
||||
formatedDate = momentDate.format(calendarWeekColumnHeader);
|
||||
} else if (view === calendarViews.FORECAST) {
|
||||
formatedDate = getRelativeDate({
|
||||
date,
|
||||
shortDateFormat,
|
||||
showRelativeDates,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.dayOfWeek,
|
||||
view === calendarViews.DAY && styles.isSingleDay,
|
||||
highlightToday && styles.isToday
|
||||
)}
|
||||
>
|
||||
{formatedDate}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DayOfWeek;
|
||||
@@ -1,97 +0,0 @@
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import DayOfWeek from './DayOfWeek';
|
||||
import styles from './DaysOfWeek.css';
|
||||
|
||||
class DaysOfWeek extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
todaysDate: moment().startOf('day').toISOString()
|
||||
};
|
||||
|
||||
this.updateTimeoutId = null;
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const view = this.props.view;
|
||||
|
||||
if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) {
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.clearUpdateTimeout();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
scheduleUpdate = () => {
|
||||
this.clearUpdateTimeout();
|
||||
const todaysDate = moment().startOf('day');
|
||||
const diff = todaysDate.clone().add(1, 'day').diff(moment());
|
||||
|
||||
this.setState({
|
||||
todaysDate: todaysDate.toISOString()
|
||||
});
|
||||
|
||||
this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
|
||||
};
|
||||
|
||||
clearUpdateTimeout = () => {
|
||||
if (this.updateTimeoutId) {
|
||||
clearTimeout(this.updateTimeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dates,
|
||||
view,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
if (view === calendarViews.AGENDA) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.daysOfWeek}>
|
||||
{
|
||||
dates.map((date) => {
|
||||
return (
|
||||
<DayOfWeek
|
||||
key={date}
|
||||
date={date}
|
||||
view={view}
|
||||
isTodaysDate={date === this.state.todaysDate}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DaysOfWeek.propTypes = {
|
||||
dates: PropTypes.arrayOf(PropTypes.string),
|
||||
view: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default DaysOfWeek;
|
||||
60
frontend/src/Calendar/Day/DaysOfWeek.tsx
Normal file
60
frontend/src/Calendar/Day/DaysOfWeek.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import DayOfWeek from './DayOfWeek';
|
||||
import styles from './DaysOfWeek.css';
|
||||
|
||||
function DaysOfWeek() {
|
||||
const { dates, view } = useSelector((state: AppState) => state.calendar);
|
||||
const { calendarWeekColumnHeader, shortDateFormat, showRelativeDates } =
|
||||
useSelector(createUISettingsSelector());
|
||||
|
||||
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
const [todaysDate, setTodaysDate] = useState(
|
||||
moment().startOf('day').toISOString()
|
||||
);
|
||||
|
||||
const scheduleUpdate = useCallback(() => {
|
||||
clearTimeout(updateTimeout.current);
|
||||
|
||||
const todaysDate = moment().startOf('day');
|
||||
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
|
||||
|
||||
setTodaysDate(todaysDate.toISOString());
|
||||
|
||||
updateTimeout.current = setTimeout(scheduleUpdate, diff);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== calendarViews.AGENDA && view !== calendarViews.MONTH) {
|
||||
scheduleUpdate();
|
||||
}
|
||||
}, [view, scheduleUpdate]);
|
||||
|
||||
if (view === calendarViews.AGENDA) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.daysOfWeek}>
|
||||
{dates.map((date) => {
|
||||
return (
|
||||
<DayOfWeek
|
||||
key={date}
|
||||
date={date}
|
||||
view={view}
|
||||
isTodaysDate={date === todaysDate}
|
||||
calendarWeekColumnHeader={calendarWeekColumnHeader}
|
||||
shortDateFormat={shortDateFormat}
|
||||
showRelativeDates={showRelativeDates}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DaysOfWeek;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import DaysOfWeek from './DaysOfWeek';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
createUISettingsSelector(),
|
||||
(calendar, UiSettings) => {
|
||||
return {
|
||||
dates: calendar.dates.slice(0, 7),
|
||||
view: calendar.view,
|
||||
calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader,
|
||||
shortDateFormat: UiSettings.shortDateFormat,
|
||||
showRelativeDates: UiSettings.showRelativeDates
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(DaysOfWeek);
|
||||
@@ -34,7 +34,8 @@ $fullColorGradient: rgba(244, 245, 246, 0.2);
|
||||
}
|
||||
|
||||
.movieTitle,
|
||||
.genres {
|
||||
.genres,
|
||||
.eventType {
|
||||
@add-mixin truncate;
|
||||
flex: 1 0 1px;
|
||||
margin-right: 10px;
|
||||
|
||||
@@ -4,6 +4,7 @@ interface CssExports {
|
||||
'continuing': string;
|
||||
'downloaded': string;
|
||||
'event': string;
|
||||
'eventType': string;
|
||||
'genres': string;
|
||||
'info': string;
|
||||
'missingMonitored': string;
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CalendarEventQueueDetails from './CalendarEventQueueDetails';
|
||||
import styles from './CalendarEvent.css';
|
||||
|
||||
class CalendarEvent extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
movieFile,
|
||||
isAvailable,
|
||||
inCinemas,
|
||||
physicalRelease,
|
||||
digitalRelease,
|
||||
title,
|
||||
titleSlug,
|
||||
genres,
|
||||
date,
|
||||
monitored,
|
||||
certification,
|
||||
hasFile,
|
||||
grabbed,
|
||||
queueItem,
|
||||
showMovieInformation,
|
||||
showCutoffUnmetIcon,
|
||||
fullColorEvents,
|
||||
colorImpairedMode
|
||||
} = this.props;
|
||||
|
||||
const isDownloading = !!(queueItem || grabbed);
|
||||
const isMonitored = monitored;
|
||||
const statusStyle = getStatusStyle(hasFile, isDownloading, isMonitored, isAvailable);
|
||||
const joinedGenres = genres.slice(0, 2).join(', ');
|
||||
const link = `/movie/${titleSlug}`;
|
||||
const eventType = [];
|
||||
|
||||
if (inCinemas && moment(date).isSame(moment(inCinemas), 'day')) {
|
||||
eventType.push('Cinemas');
|
||||
}
|
||||
|
||||
if (physicalRelease && moment(date).isSame(moment(physicalRelease), 'day')) {
|
||||
eventType.push('Physical');
|
||||
}
|
||||
|
||||
if (digitalRelease && moment(date).isSame(moment(digitalRelease), 'day')) {
|
||||
eventType.push('Digital');
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.event,
|
||||
styles[statusStyle],
|
||||
colorImpairedMode && 'colorImpaired',
|
||||
fullColorEvents && 'fullColor'
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
to={link}
|
||||
/>
|
||||
|
||||
<div className={styles.overlay} >
|
||||
<div className={styles.info}>
|
||||
<div className={styles.movieTitle}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
styles.statusContainer,
|
||||
fullColorEvents && 'fullColor'
|
||||
)}
|
||||
>
|
||||
{
|
||||
queueItem ?
|
||||
<span className={styles.statusIcon}>
|
||||
<CalendarEventQueueDetails
|
||||
{...queueItem}
|
||||
fullColorEvents={fullColorEvents}
|
||||
/>
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!queueItem && grabbed ?
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.DOWNLOADING}
|
||||
title={translate('MovieIsDownloading')}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
showCutoffUnmetIcon &&
|
||||
!!movieFile &&
|
||||
movieFile.qualityCutoffNotMet ?
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.MOVIE_FILE}
|
||||
kind={kinds.WARNING}
|
||||
title={translate('QualityCutoffNotMet')}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
showMovieInformation ?
|
||||
<div className={styles.movieInfo}>
|
||||
<div className={styles.genres}>
|
||||
{joinedGenres}
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
showMovieInformation ?
|
||||
<div className={styles.movieInfo}>
|
||||
<div className={styles.genres}>
|
||||
{eventType.join(', ')}
|
||||
</div>
|
||||
<div>
|
||||
{certification}
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarEvent.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
genres: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
movieFile: PropTypes.object,
|
||||
title: PropTypes.string.isRequired,
|
||||
titleSlug: PropTypes.string.isRequired,
|
||||
isAvailable: PropTypes.bool.isRequired,
|
||||
inCinemas: PropTypes.string,
|
||||
physicalRelease: PropTypes.string,
|
||||
digitalRelease: PropTypes.string,
|
||||
date: PropTypes.string.isRequired,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
certification: PropTypes.string,
|
||||
hasFile: PropTypes.bool.isRequired,
|
||||
grabbed: PropTypes.bool,
|
||||
queueItem: PropTypes.object,
|
||||
// These props come from the connector, not marked as required to appease TS for now.
|
||||
showMovieInformation: PropTypes.bool,
|
||||
showCutoffUnmetIcon: PropTypes.bool,
|
||||
fullColorEvents: PropTypes.bool,
|
||||
timeFormat: PropTypes.string,
|
||||
colorImpairedMode: PropTypes.bool
|
||||
};
|
||||
|
||||
CalendarEvent.defaultProps = {
|
||||
genres: []
|
||||
};
|
||||
|
||||
export default CalendarEvent;
|
||||
180
frontend/src/Calendar/Events/CalendarEvent.tsx
Normal file
180
frontend/src/Calendar/Events/CalendarEvent.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useMovieFile from 'MovieFile/useMovieFile';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CalendarEventQueueDetails from './CalendarEventQueueDetails';
|
||||
import styles from './CalendarEvent.css';
|
||||
|
||||
interface CalendarEventProps {
|
||||
id: number;
|
||||
movieFileId?: number;
|
||||
title: string;
|
||||
titleSlug: string;
|
||||
genres: string[];
|
||||
certification?: string;
|
||||
date: string;
|
||||
inCinemas?: string;
|
||||
digitalRelease?: string;
|
||||
physicalRelease?: string;
|
||||
isAvailable: boolean;
|
||||
monitored: boolean;
|
||||
hasFile: boolean;
|
||||
grabbed?: boolean;
|
||||
}
|
||||
|
||||
function CalendarEvent({
|
||||
id,
|
||||
movieFileId,
|
||||
title,
|
||||
titleSlug,
|
||||
genres = [],
|
||||
certification,
|
||||
date,
|
||||
inCinemas,
|
||||
digitalRelease,
|
||||
physicalRelease,
|
||||
isAvailable,
|
||||
monitored: isMonitored,
|
||||
hasFile,
|
||||
grabbed,
|
||||
}: CalendarEventProps) {
|
||||
const movieFile = useMovieFile(movieFileId);
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(id));
|
||||
|
||||
const { enableColorImpairedMode } = useSelector(createUISettingsSelector());
|
||||
|
||||
const {
|
||||
showMovieInformation,
|
||||
showCinemaRelease,
|
||||
showDigitalRelease,
|
||||
showPhysicalRelease,
|
||||
showCutoffUnmetIcon,
|
||||
fullColorEvents,
|
||||
} = useSelector((state: AppState) => state.calendar.options);
|
||||
|
||||
const isDownloading = !!(queueItem || grabbed);
|
||||
const statusStyle = getStatusStyle(
|
||||
hasFile,
|
||||
isDownloading,
|
||||
isMonitored,
|
||||
isAvailable
|
||||
);
|
||||
const joinedGenres = genres.slice(0, 2).join(', ');
|
||||
const link = `/movie/${titleSlug}`;
|
||||
|
||||
const eventTypes = useMemo(() => {
|
||||
const momentDate = moment(date);
|
||||
|
||||
const types = [];
|
||||
|
||||
if (
|
||||
showCinemaRelease &&
|
||||
inCinemas &&
|
||||
momentDate.isSame(moment(inCinemas), 'day')
|
||||
) {
|
||||
types.push('Cinemas');
|
||||
}
|
||||
|
||||
if (
|
||||
showDigitalRelease &&
|
||||
digitalRelease &&
|
||||
momentDate.isSame(moment(digitalRelease), 'day')
|
||||
) {
|
||||
types.push('Digital');
|
||||
}
|
||||
|
||||
if (
|
||||
showPhysicalRelease &&
|
||||
physicalRelease &&
|
||||
momentDate.isSame(moment(physicalRelease), 'day')
|
||||
) {
|
||||
types.push('Physical');
|
||||
}
|
||||
|
||||
return types;
|
||||
}, [
|
||||
date,
|
||||
showCinemaRelease,
|
||||
showDigitalRelease,
|
||||
showPhysicalRelease,
|
||||
inCinemas,
|
||||
digitalRelease,
|
||||
physicalRelease,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.event,
|
||||
styles[statusStyle],
|
||||
enableColorImpairedMode && 'colorImpaired',
|
||||
fullColorEvents && 'fullColor'
|
||||
)}
|
||||
>
|
||||
<Link className={styles.underlay} to={link} />
|
||||
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.movieTitle}>{title}</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
styles.statusContainer,
|
||||
fullColorEvents && 'fullColor'
|
||||
)}
|
||||
>
|
||||
{queueItem ? (
|
||||
<span className={styles.statusIcon}>
|
||||
<CalendarEventQueueDetails {...queueItem} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{!queueItem && grabbed ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.DOWNLOADING}
|
||||
title={translate('MovieIsDownloading')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showCutoffUnmetIcon &&
|
||||
!!movieFile &&
|
||||
movieFile.qualityCutoffNotMet ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.MOVIE_FILE}
|
||||
kind={kinds.WARNING}
|
||||
title={translate('QualityCutoffNotMet')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showMovieInformation ? (
|
||||
<>
|
||||
<div className={styles.movieInfo}>
|
||||
<div className={styles.genres}>{joinedGenres}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.movieInfo}>
|
||||
<div className={styles.eventType}>{eventTypes.join(', ')}</div>
|
||||
|
||||
<div>{certification}</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarEvent;
|
||||
@@ -1,26 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createMovieSelector from 'Store/Selectors/createMovieSelector';
|
||||
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import CalendarEvent from './CalendarEvent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.options,
|
||||
createMovieSelector(),
|
||||
createQueueItemSelector(),
|
||||
createUISettingsSelector(),
|
||||
(calendarOptions, movie, queueItem, uiSettings) => {
|
||||
return {
|
||||
movie,
|
||||
queueItem,
|
||||
...calendarOptions,
|
||||
timeFormat: uiSettings.timeFormat,
|
||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(CalendarEvent);
|
||||
@@ -1,56 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import QueueDetails from 'Activity/Queue/QueueDetails';
|
||||
import CircularProgressBar from 'Components/CircularProgressBar';
|
||||
|
||||
function CalendarEventQueueDetails(props) {
|
||||
const {
|
||||
title,
|
||||
size,
|
||||
sizeleft,
|
||||
estimatedCompletionTime,
|
||||
status,
|
||||
trackedDownloadState,
|
||||
trackedDownloadStatus,
|
||||
statusMessages,
|
||||
errorMessage
|
||||
} = props;
|
||||
|
||||
const progress = size ? (100 - sizeleft / size * 100) : 0;
|
||||
|
||||
return (
|
||||
<QueueDetails
|
||||
title={title}
|
||||
size={size}
|
||||
sizeleft={sizeleft}
|
||||
estimatedCompletionTime={estimatedCompletionTime}
|
||||
status={status}
|
||||
trackedDownloadState={trackedDownloadState}
|
||||
trackedDownloadStatus={trackedDownloadStatus}
|
||||
statusMessages={statusMessages}
|
||||
errorMessage={errorMessage}
|
||||
progressBar={
|
||||
<CircularProgressBar
|
||||
progress={progress}
|
||||
size={20}
|
||||
strokeWidth={2}
|
||||
strokeColor={'#7a43b6'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
CalendarEventQueueDetails.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
sizeleft: PropTypes.number.isRequired,
|
||||
estimatedCompletionTime: PropTypes.string,
|
||||
status: PropTypes.string.isRequired,
|
||||
trackedDownloadState: PropTypes.string.isRequired,
|
||||
trackedDownloadStatus: PropTypes.string.isRequired,
|
||||
statusMessages: PropTypes.arrayOf(PropTypes.object),
|
||||
errorMessage: PropTypes.string
|
||||
};
|
||||
|
||||
export default CalendarEventQueueDetails;
|
||||
58
frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx
Normal file
58
frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import QueueDetails from 'Activity/Queue/QueueDetails';
|
||||
import CircularProgressBar from 'Components/CircularProgressBar';
|
||||
import {
|
||||
QueueTrackedDownloadState,
|
||||
QueueTrackedDownloadStatus,
|
||||
StatusMessage,
|
||||
} from 'typings/Queue';
|
||||
|
||||
interface CalendarEventQueueDetailsProps {
|
||||
title: string;
|
||||
size: number;
|
||||
sizeleft: number;
|
||||
estimatedCompletionTime?: string;
|
||||
status: string;
|
||||
trackedDownloadState: QueueTrackedDownloadState;
|
||||
trackedDownloadStatus: QueueTrackedDownloadStatus;
|
||||
statusMessages?: StatusMessage[];
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
function CalendarEventQueueDetails({
|
||||
title,
|
||||
size,
|
||||
sizeleft,
|
||||
estimatedCompletionTime,
|
||||
status,
|
||||
trackedDownloadState,
|
||||
trackedDownloadStatus,
|
||||
statusMessages,
|
||||
errorMessage,
|
||||
}: CalendarEventQueueDetailsProps) {
|
||||
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
|
||||
|
||||
return (
|
||||
<QueueDetails
|
||||
title={title}
|
||||
size={size}
|
||||
sizeleft={sizeleft}
|
||||
estimatedCompletionTime={estimatedCompletionTime}
|
||||
status={status}
|
||||
trackedDownloadState={trackedDownloadState}
|
||||
trackedDownloadStatus={trackedDownloadStatus}
|
||||
statusMessages={statusMessages}
|
||||
errorMessage={errorMessage}
|
||||
progressBar={
|
||||
<CircularProgressBar
|
||||
progress={progress}
|
||||
size={20}
|
||||
strokeWidth={2}
|
||||
strokeColor="#7a43b6"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarEventQueueDetails;
|
||||
@@ -1,268 +0,0 @@
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Menu from 'Components/Menu/Menu';
|
||||
import MenuButton from 'Components/Menu/MenuButton';
|
||||
import MenuContent from 'Components/Menu/MenuContent';
|
||||
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
|
||||
import styles from './CalendarHeader.css';
|
||||
|
||||
function getTitle(time, start, end, view, longDateFormat) {
|
||||
const timeMoment = moment(time);
|
||||
const startMoment = moment(start);
|
||||
const endMoment = moment(end);
|
||||
|
||||
if (view === 'day') {
|
||||
return timeMoment.format(longDateFormat);
|
||||
} else if (view === 'month') {
|
||||
return timeMoment.format('MMMM YYYY');
|
||||
} else if (view === 'agenda') {
|
||||
return `Agenda: ${startMoment.format('MMM D')} - ${endMoment.format('MMM D')}`;
|
||||
}
|
||||
|
||||
let startFormat = 'MMM D YYYY';
|
||||
let endFormat = 'MMM D YYYY';
|
||||
|
||||
if (startMoment.isSame(endMoment, 'month')) {
|
||||
startFormat = 'MMM D';
|
||||
endFormat = 'D YYYY';
|
||||
} else if (startMoment.isSame(endMoment, 'year')) {
|
||||
startFormat = 'MMM D';
|
||||
endFormat = 'MMM D YYYY';
|
||||
}
|
||||
|
||||
return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`;
|
||||
}
|
||||
|
||||
// TODO Convert to a stateful Component so we can track view internally when changed
|
||||
|
||||
class CalendarHeader extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
view: props.view
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const view = this.props.view;
|
||||
|
||||
if (prevProps.view !== view) {
|
||||
this.setState({ view });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onViewChange = (view) => {
|
||||
this.setState({ view }, () => {
|
||||
this.props.onViewChange(view);
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
time,
|
||||
start,
|
||||
end,
|
||||
longDateFormat,
|
||||
isSmallScreen,
|
||||
collapseViewButtons,
|
||||
onTodayPress,
|
||||
onPreviousPress,
|
||||
onNextPress
|
||||
} = this.props;
|
||||
|
||||
const view = this.state.view;
|
||||
|
||||
const title = getTitle(time, start, end, view, longDateFormat);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
isSmallScreen &&
|
||||
<div className={styles.titleMobile}>
|
||||
{title}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className={styles.header}>
|
||||
<div className={styles.navigationButtons}>
|
||||
<Button
|
||||
buttonGroupPosition={align.LEFT}
|
||||
isDisabled={view === calendarViews.AGENDA}
|
||||
onPress={onPreviousPress}
|
||||
>
|
||||
<Icon name={icons.PAGE_PREVIOUS} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
buttonGroupPosition={align.RIGHT}
|
||||
isDisabled={view === calendarViews.AGENDA}
|
||||
onPress={onNextPress}
|
||||
>
|
||||
<Icon name={icons.PAGE_NEXT} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className={styles.todayButton}
|
||||
isDisabled={view === calendarViews.AGENDA}
|
||||
onPress={onTodayPress}
|
||||
>
|
||||
{translate('Today')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{
|
||||
!isSmallScreen &&
|
||||
<div className={styles.titleDesktop}>
|
||||
{title}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className={styles.viewButtonsContainer}>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
collapseViewButtons ?
|
||||
<Menu
|
||||
className={styles.viewMenu}
|
||||
alignMenu={align.RIGHT}
|
||||
>
|
||||
<MenuButton>
|
||||
<Icon
|
||||
name={icons.VIEW}
|
||||
size={22}
|
||||
/>
|
||||
</MenuButton>
|
||||
|
||||
<MenuContent>
|
||||
{
|
||||
isSmallScreen ?
|
||||
null :
|
||||
<ViewMenuItem
|
||||
name={calendarViews.MONTH}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
{translate('Month')}
|
||||
</ViewMenuItem>
|
||||
}
|
||||
|
||||
<ViewMenuItem
|
||||
name={calendarViews.WEEK}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
{translate('Week')}
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name={calendarViews.FORECAST}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
{translate('Forecast')}
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name={calendarViews.DAY}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
{translate('Day')}
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name={calendarViews.AGENDA}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
{translate('Agenda')}
|
||||
</ViewMenuItem>
|
||||
</MenuContent>
|
||||
</Menu> :
|
||||
|
||||
<div className={styles.viewButtons}>
|
||||
<CalendarHeaderViewButton
|
||||
view={calendarViews.MONTH}
|
||||
selectedView={view}
|
||||
buttonGroupPosition={align.LEFT}
|
||||
onPress={this.onViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view={calendarViews.WEEK}
|
||||
selectedView={view}
|
||||
buttonGroupPosition={align.CENTER}
|
||||
onPress={this.onViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view={calendarViews.FORECAST}
|
||||
selectedView={view}
|
||||
buttonGroupPosition={align.CENTER}
|
||||
onPress={this.onViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view={calendarViews.DAY}
|
||||
selectedView={view}
|
||||
buttonGroupPosition={align.CENTER}
|
||||
onPress={this.onViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view={calendarViews.AGENDA}
|
||||
selectedView={view}
|
||||
buttonGroupPosition={align.RIGHT}
|
||||
onPress={this.onViewChange}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarHeader.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
time: PropTypes.string.isRequired,
|
||||
start: PropTypes.string.isRequired,
|
||||
end: PropTypes.string.isRequired,
|
||||
view: PropTypes.oneOf(calendarViews.all).isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
collapseViewButtons: PropTypes.bool.isRequired,
|
||||
longDateFormat: PropTypes.string.isRequired,
|
||||
onViewChange: PropTypes.func.isRequired,
|
||||
onTodayPress: PropTypes.func.isRequired,
|
||||
onPreviousPress: PropTypes.func.isRequired,
|
||||
onNextPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarHeader;
|
||||
218
frontend/src/Calendar/Header/CalendarHeader.tsx
Normal file
218
frontend/src/Calendar/Header/CalendarHeader.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Menu from 'Components/Menu/Menu';
|
||||
import MenuButton from 'Components/Menu/MenuButton';
|
||||
import MenuContent from 'Components/Menu/MenuContent';
|
||||
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import {
|
||||
gotoCalendarNextRange,
|
||||
gotoCalendarPreviousRange,
|
||||
gotoCalendarToday,
|
||||
setCalendarView,
|
||||
} from 'Store/Actions/calendarActions';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
|
||||
import styles from './CalendarHeader.css';
|
||||
|
||||
function CalendarHeader() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { isFetching, view, time, start, end } = useSelector(
|
||||
(state: AppState) => state.calendar
|
||||
);
|
||||
|
||||
const { isSmallScreen, isLargeScreen } = useSelector(
|
||||
createDimensionsSelector()
|
||||
);
|
||||
|
||||
const { longDateFormat } = useSelector(createUISettingsSelector());
|
||||
|
||||
const handleViewChange = useCallback(
|
||||
(newView: string) => {
|
||||
dispatch(setCalendarView({ view: newView }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTodayPress = useCallback(() => {
|
||||
dispatch(gotoCalendarToday());
|
||||
}, [dispatch]);
|
||||
|
||||
const handlePreviousPress = useCallback(() => {
|
||||
dispatch(gotoCalendarPreviousRange());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleNextPress = useCallback(() => {
|
||||
dispatch(gotoCalendarNextRange());
|
||||
}, [dispatch]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
const timeMoment = moment(time);
|
||||
const startMoment = moment(start);
|
||||
const endMoment = moment(end);
|
||||
|
||||
if (view === 'day') {
|
||||
return timeMoment.format(longDateFormat);
|
||||
} else if (view === 'month') {
|
||||
return timeMoment.format('MMMM YYYY');
|
||||
}
|
||||
|
||||
let startFormat = 'MMM D YYYY';
|
||||
let endFormat = 'MMM D YYYY';
|
||||
|
||||
if (startMoment.isSame(endMoment, 'month')) {
|
||||
startFormat = 'MMM D';
|
||||
endFormat = 'D YYYY';
|
||||
} else if (startMoment.isSame(endMoment, 'year')) {
|
||||
startFormat = 'MMM D';
|
||||
endFormat = 'MMM D YYYY';
|
||||
}
|
||||
|
||||
return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(
|
||||
endFormat
|
||||
)}`;
|
||||
}, [time, start, end, view, longDateFormat]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isSmallScreen ? <div className={styles.titleMobile}>{title}</div> : null}
|
||||
|
||||
<div className={styles.header}>
|
||||
<div className={styles.navigationButtons}>
|
||||
<Button
|
||||
buttonGroupPosition="left"
|
||||
isDisabled={view === 'agenda'}
|
||||
onPress={handlePreviousPress}
|
||||
>
|
||||
<Icon name={icons.PAGE_PREVIOUS} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
buttonGroupPosition="right"
|
||||
isDisabled={view === 'agenda'}
|
||||
onPress={handleNextPress}
|
||||
>
|
||||
<Icon name={icons.PAGE_NEXT} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className={styles.todayButton}
|
||||
isDisabled={view === 'agenda'}
|
||||
onPress={handleTodayPress}
|
||||
>
|
||||
{translate('Today')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isSmallScreen ? null : (
|
||||
<div className={styles.titleDesktop}>{title}</div>
|
||||
)}
|
||||
|
||||
<div className={styles.viewButtonsContainer}>
|
||||
{isFetching ? (
|
||||
<LoadingIndicator className={styles.loading} size={20} />
|
||||
) : null}
|
||||
|
||||
{isLargeScreen ? (
|
||||
<Menu className={styles.viewMenu} alignMenu={align.RIGHT}>
|
||||
<MenuButton>
|
||||
<Icon name={icons.VIEW} size={22} />
|
||||
</MenuButton>
|
||||
|
||||
<MenuContent>
|
||||
{isSmallScreen ? null : (
|
||||
<ViewMenuItem
|
||||
name="month"
|
||||
selectedView={view}
|
||||
onPress={handleViewChange}
|
||||
>
|
||||
{translate('Month')}
|
||||
</ViewMenuItem>
|
||||
)}
|
||||
|
||||
<ViewMenuItem
|
||||
name="week"
|
||||
selectedView={view}
|
||||
onPress={handleViewChange}
|
||||
>
|
||||
{translate('Week')}
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name="forecast"
|
||||
selectedView={view}
|
||||
onPress={handleViewChange}
|
||||
>
|
||||
{translate('Forecast')}
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name="day"
|
||||
selectedView={view}
|
||||
onPress={handleViewChange}
|
||||
>
|
||||
{translate('Day')}
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name="agenda"
|
||||
selectedView={view}
|
||||
onPress={handleViewChange}
|
||||
>
|
||||
{translate('Agenda')}
|
||||
</ViewMenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
) : (
|
||||
<>
|
||||
<CalendarHeaderViewButton
|
||||
view="month"
|
||||
selectedView={view}
|
||||
buttonGroupPosition="left"
|
||||
onPress={handleViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view="week"
|
||||
selectedView={view}
|
||||
buttonGroupPosition="center"
|
||||
onPress={handleViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view="forecast"
|
||||
selectedView={view}
|
||||
buttonGroupPosition="center"
|
||||
onPress={handleViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view="day"
|
||||
selectedView={view}
|
||||
buttonGroupPosition="center"
|
||||
onPress={handleViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view="agenda"
|
||||
selectedView={view}
|
||||
buttonGroupPosition="right"
|
||||
onPress={handleViewChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarHeader;
|
||||
@@ -1,81 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { gotoCalendarNextRange, gotoCalendarPreviousRange, gotoCalendarToday, setCalendarView } from 'Store/Actions/calendarActions';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import CalendarHeader from './CalendarHeader';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
createDimensionsSelector(),
|
||||
createUISettingsSelector(),
|
||||
(calendar, dimensions, uiSettings) => {
|
||||
return {
|
||||
isFetching: calendar.isFetching,
|
||||
view: calendar.view,
|
||||
time: calendar.time,
|
||||
start: calendar.start,
|
||||
end: calendar.end,
|
||||
isSmallScreen: dimensions.isSmallScreen,
|
||||
collapseViewButtons: dimensions.isLargeScreen,
|
||||
longDateFormat: uiSettings.longDateFormat
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setCalendarView,
|
||||
gotoCalendarToday,
|
||||
gotoCalendarPreviousRange,
|
||||
gotoCalendarNextRange
|
||||
};
|
||||
|
||||
class CalendarHeaderConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onViewChange = (view) => {
|
||||
this.props.setCalendarView({ view });
|
||||
};
|
||||
|
||||
onTodayPress = () => {
|
||||
this.props.gotoCalendarToday();
|
||||
};
|
||||
|
||||
onPreviousPress = () => {
|
||||
this.props.gotoCalendarPreviousRange();
|
||||
};
|
||||
|
||||
onNextPress = () => {
|
||||
this.props.gotoCalendarNextRange();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CalendarHeader
|
||||
{...this.props}
|
||||
onViewChange={this.onViewChange}
|
||||
onTodayPress={this.onTodayPress}
|
||||
onPreviousPress={this.onPreviousPress}
|
||||
onNextPress={this.onNextPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarHeaderConnector.propTypes = {
|
||||
setCalendarView: PropTypes.func.isRequired,
|
||||
gotoCalendarToday: PropTypes.func.isRequired,
|
||||
gotoCalendarPreviousRange: PropTypes.func.isRequired,
|
||||
gotoCalendarNextRange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector);
|
||||
@@ -1,45 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import Button from 'Components/Link/Button';
|
||||
import titleCase from 'Utilities/String/titleCase';
|
||||
// import styles from './CalendarHeaderViewButton.css';
|
||||
|
||||
class CalendarHeaderViewButton extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
this.props.onPress(this.props.view);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
view,
|
||||
selectedView,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
isDisabled={selectedView === view}
|
||||
{...otherProps}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
{titleCase(view)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarHeaderViewButton.propTypes = {
|
||||
view: PropTypes.oneOf(calendarViews.all).isRequired,
|
||||
selectedView: PropTypes.oneOf(calendarViews.all).isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarHeaderViewButton;
|
||||
34
frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx
Normal file
34
frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { CalendarView } from 'Calendar/calendarViews';
|
||||
import Button, { ButtonProps } from 'Components/Link/Button';
|
||||
import titleCase from 'Utilities/String/titleCase';
|
||||
|
||||
interface CalendarHeaderViewButtonProps
|
||||
extends Omit<ButtonProps, 'children' | 'onPress'> {
|
||||
view: CalendarView;
|
||||
selectedView: CalendarView;
|
||||
onPress: (view: CalendarView) => void;
|
||||
}
|
||||
|
||||
function CalendarHeaderViewButton({
|
||||
view,
|
||||
selectedView,
|
||||
onPress,
|
||||
...otherProps
|
||||
}: CalendarHeaderViewButtonProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(view);
|
||||
}, [view, onPress]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
isDisabled={selectedView === view}
|
||||
{...otherProps}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{titleCase(view)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarHeaderViewButton;
|
||||
@@ -1,18 +1,19 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import LegendIconItem from './LegendIconItem';
|
||||
import LegendItem from './LegendItem';
|
||||
import styles from './Legend.css';
|
||||
|
||||
function Legend(props) {
|
||||
const {
|
||||
view,
|
||||
showCutoffUnmetIcon,
|
||||
fullColorEvents,
|
||||
colorImpairedMode
|
||||
} = props;
|
||||
function Legend() {
|
||||
const view = useSelector((state: AppState) => state.calendar.view);
|
||||
const { showCutoffUnmetIcon, fullColorEvents } = useSelector(
|
||||
(state: AppState) => state.calendar.options
|
||||
);
|
||||
const { enableColorImpairedMode } = useSelector(createUISettingsSelector());
|
||||
|
||||
const iconsToShow = [];
|
||||
const isAgendaView = view === 'agenda';
|
||||
@@ -37,7 +38,7 @@ function Legend(props) {
|
||||
name={translate('DownloadedAndMonitored')}
|
||||
isAgendaView={isAgendaView}
|
||||
fullColorEvents={fullColorEvents}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
colorImpairedMode={enableColorImpairedMode}
|
||||
/>
|
||||
|
||||
<LegendItem
|
||||
@@ -45,7 +46,7 @@ function Legend(props) {
|
||||
name={translate('DownloadedButNotMonitored')}
|
||||
isAgendaView={isAgendaView}
|
||||
fullColorEvents={fullColorEvents}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
colorImpairedMode={enableColorImpairedMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +56,7 @@ function Legend(props) {
|
||||
name={translate('MissingMonitoredAndConsideredAvailable')}
|
||||
isAgendaView={isAgendaView}
|
||||
fullColorEvents={fullColorEvents}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
colorImpairedMode={enableColorImpairedMode}
|
||||
/>
|
||||
|
||||
<LegendItem
|
||||
@@ -63,7 +64,7 @@ function Legend(props) {
|
||||
name={translate('MissingNotMonitored')}
|
||||
isAgendaView={isAgendaView}
|
||||
fullColorEvents={fullColorEvents}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
colorImpairedMode={enableColorImpairedMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -73,7 +74,7 @@ function Legend(props) {
|
||||
name={translate('Queued')}
|
||||
isAgendaView={isAgendaView}
|
||||
fullColorEvents={fullColorEvents}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
colorImpairedMode={enableColorImpairedMode}
|
||||
/>
|
||||
|
||||
<LegendItem
|
||||
@@ -81,25 +82,13 @@ function Legend(props) {
|
||||
name={translate('Unreleased')}
|
||||
isAgendaView={isAgendaView}
|
||||
fullColorEvents={fullColorEvents}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
colorImpairedMode={enableColorImpairedMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
iconsToShow.length > 0 &&
|
||||
<div>
|
||||
{iconsToShow[0]}
|
||||
</div>
|
||||
}
|
||||
{iconsToShow.length > 0 ? <div>{iconsToShow[0]}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Legend.propTypes = {
|
||||
view: PropTypes.string.isRequired,
|
||||
showCutoffUnmetIcon: PropTypes.bool.isRequired,
|
||||
fullColorEvents: PropTypes.bool.isRequired,
|
||||
colorImpairedMode: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default Legend;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import Legend from './Legend';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.options,
|
||||
(state) => state.calendar.view,
|
||||
createUISettingsSelector(),
|
||||
(calendarOptions, view, uiSettings) => {
|
||||
return {
|
||||
...calendarOptions,
|
||||
view,
|
||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(Legend);
|
||||
@@ -1,43 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import styles from './LegendIconItem.css';
|
||||
|
||||
function LegendIconItem(props) {
|
||||
const {
|
||||
name,
|
||||
fullColorEvents,
|
||||
icon,
|
||||
kind,
|
||||
tooltip
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.legendIconItem}
|
||||
title={tooltip}
|
||||
>
|
||||
<Icon
|
||||
className={classNames(
|
||||
styles.icon,
|
||||
fullColorEvents && 'fullColorEvents'
|
||||
)}
|
||||
name={icon}
|
||||
kind={kind}
|
||||
/>
|
||||
|
||||
{name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LegendIconItem.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
fullColorEvents: PropTypes.bool.isRequired,
|
||||
icon: PropTypes.object.isRequired,
|
||||
kind: PropTypes.string.isRequired,
|
||||
tooltip: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default LegendIconItem;
|
||||
33
frontend/src/Calendar/Legend/LegendIconItem.tsx
Normal file
33
frontend/src/Calendar/Legend/LegendIconItem.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { FontAwesomeIconProps } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import styles from './LegendIconItem.css';
|
||||
|
||||
interface LegendIconItemProps extends Pick<IconProps, 'kind'> {
|
||||
name: string;
|
||||
fullColorEvents: boolean;
|
||||
icon: FontAwesomeIconProps['icon'];
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
function LegendIconItem(props: LegendIconItemProps) {
|
||||
const { name, fullColorEvents, icon, kind, tooltip } = props;
|
||||
|
||||
return (
|
||||
<div className={styles.legendIconItem} title={tooltip}>
|
||||
<Icon
|
||||
className={classNames(
|
||||
styles.icon,
|
||||
fullColorEvents && 'fullColorEvents'
|
||||
)}
|
||||
name={icon}
|
||||
kind={kind}
|
||||
/>
|
||||
|
||||
{name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LegendIconItem;
|
||||
@@ -1,37 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './LegendItem.css';
|
||||
|
||||
function LegendItem(props) {
|
||||
const {
|
||||
name,
|
||||
status,
|
||||
isAgendaView,
|
||||
fullColorEvents,
|
||||
colorImpairedMode
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.legendItem,
|
||||
styles[status],
|
||||
colorImpairedMode && 'colorImpaired',
|
||||
fullColorEvents && !isAgendaView && 'fullColor'
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LegendItem.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
isAgendaView: PropTypes.bool.isRequired,
|
||||
fullColorEvents: PropTypes.bool.isRequired,
|
||||
colorImpairedMode: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default LegendItem;
|
||||
35
frontend/src/Calendar/Legend/LegendItem.tsx
Normal file
35
frontend/src/Calendar/Legend/LegendItem.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { CalendarStatus } from 'typings/Calendar';
|
||||
import styles from './LegendItem.css';
|
||||
|
||||
interface LegendItemProps {
|
||||
name: string;
|
||||
status: CalendarStatus;
|
||||
isAgendaView: boolean;
|
||||
fullColorEvents: boolean;
|
||||
colorImpairedMode: boolean;
|
||||
}
|
||||
|
||||
function LegendItem({
|
||||
name,
|
||||
status,
|
||||
isAgendaView,
|
||||
fullColorEvents,
|
||||
colorImpairedMode,
|
||||
}: LegendItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.legendItem,
|
||||
styles[status],
|
||||
colorImpairedMode && 'colorImpaired',
|
||||
fullColorEvents && !isAgendaView && 'fullColor'
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LegendItem;
|
||||
@@ -1,29 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector';
|
||||
|
||||
function CalendarOptionsModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<CalendarOptionsModalContentConnector
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
CalendarOptionsModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarOptionsModal;
|
||||
21
frontend/src/Calendar/Options/CalendarOptionsModal.tsx
Normal file
21
frontend/src/Calendar/Options/CalendarOptionsModal.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import CalendarOptionsModalContent from './CalendarOptionsModalContent';
|
||||
|
||||
interface CalendarOptionsModalProps {
|
||||
isOpen: boolean;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function CalendarOptionsModal({
|
||||
isOpen,
|
||||
onModalClose,
|
||||
}: CalendarOptionsModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<CalendarOptionsModalContent onModalClose={onModalClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarOptionsModal;
|
||||
@@ -1,234 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { firstDayOfWeekOptions, timeFormatOptions, weekColumnOptions } from 'Settings/UI/UISettings';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
class CalendarOptionsModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const {
|
||||
firstDayOfWeek,
|
||||
calendarWeekColumnHeader,
|
||||
timeFormat,
|
||||
enableColorImpairedMode,
|
||||
fullColorEvents
|
||||
} = props;
|
||||
|
||||
this.state = {
|
||||
firstDayOfWeek,
|
||||
calendarWeekColumnHeader,
|
||||
timeFormat,
|
||||
enableColorImpairedMode,
|
||||
fullColorEvents
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
firstDayOfWeek,
|
||||
calendarWeekColumnHeader,
|
||||
timeFormat,
|
||||
enableColorImpairedMode
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
prevProps.firstDayOfWeek !== firstDayOfWeek ||
|
||||
prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader ||
|
||||
prevProps.timeFormat !== timeFormat ||
|
||||
prevProps.enableColorImpairedMode !== enableColorImpairedMode
|
||||
) {
|
||||
this.setState({
|
||||
firstDayOfWeek,
|
||||
calendarWeekColumnHeader,
|
||||
timeFormat,
|
||||
enableColorImpairedMode
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onOptionInputChange = ({ name, value }) => {
|
||||
const {
|
||||
dispatchSetCalendarOption
|
||||
} = this.props;
|
||||
|
||||
dispatchSetCalendarOption({ [name]: value });
|
||||
};
|
||||
|
||||
onGlobalInputChange = ({ name, value }) => {
|
||||
const {
|
||||
dispatchSaveUISettings
|
||||
} = this.props;
|
||||
|
||||
const setting = { [name]: value };
|
||||
|
||||
this.setState(setting, () => {
|
||||
dispatchSaveUISettings(setting);
|
||||
});
|
||||
};
|
||||
|
||||
onLinkFocus = (event) => {
|
||||
event.target.select();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
showMovieInformation,
|
||||
showCutoffUnmetIcon,
|
||||
fullColorEvents,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
firstDayOfWeek,
|
||||
calendarWeekColumnHeader,
|
||||
timeFormat,
|
||||
enableColorImpairedMode
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('CalendarOptions')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<FieldSet legend={translate('Local')}>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowMovieInformation')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showMovieInformation"
|
||||
value={showMovieInformation}
|
||||
helpText={translate('ShowMovieInformationHelpText')}
|
||||
onChange={this.onOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IconForCutoffUnmet')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showCutoffUnmetIcon"
|
||||
value={showCutoffUnmetIcon}
|
||||
helpText={translate('IconForCutoffUnmetHelpText')}
|
||||
onChange={this.onOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('FullColorEvents')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="fullColorEvents"
|
||||
value={fullColorEvents}
|
||||
helpText={translate('FullColorEventsHelpText')}
|
||||
onChange={this.onOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Global')}>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('FirstDayOfWeek')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="firstDayOfWeek"
|
||||
values={firstDayOfWeekOptions}
|
||||
value={firstDayOfWeek}
|
||||
onChange={this.onGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('WeekColumnHeader')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="calendarWeekColumnHeader"
|
||||
values={weekColumnOptions}
|
||||
value={calendarWeekColumnHeader}
|
||||
onChange={this.onGlobalInputChange}
|
||||
helpText={translate('WeekColumnHeaderHelpText')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('TimeFormat')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="timeFormat"
|
||||
values={timeFormatOptions}
|
||||
value={timeFormat}
|
||||
onChange={this.onGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('EnableColorImpairedMode')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enableColorImpairedMode"
|
||||
value={enableColorImpairedMode}
|
||||
helpText={translate('EnableColorImpairedModeHelpText')}
|
||||
onChange={this.onGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</FieldSet>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>
|
||||
{translate('Close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarOptionsModalContent.propTypes = {
|
||||
showMovieInformation: PropTypes.bool.isRequired,
|
||||
showCutoffUnmetIcon: PropTypes.bool.isRequired,
|
||||
firstDayOfWeek: PropTypes.number.isRequired,
|
||||
calendarWeekColumnHeader: PropTypes.string.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired,
|
||||
enableColorImpairedMode: PropTypes.bool.isRequired,
|
||||
fullColorEvents: PropTypes.bool.isRequired,
|
||||
dispatchSetCalendarOption: PropTypes.func.isRequired,
|
||||
dispatchSaveUISettings: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarOptionsModalContent;
|
||||
243
frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx
Normal file
243
frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import {
|
||||
firstDayOfWeekOptions,
|
||||
timeFormatOptions,
|
||||
weekColumnOptions,
|
||||
} from 'Settings/UI/UISettings';
|
||||
import { setCalendarOption } from 'Store/Actions/calendarActions';
|
||||
import { saveUISettings } from 'Store/Actions/settingsActions';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import UiSettings from 'typings/Settings/UiSettings';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface CalendarOptionsModalContentProps {
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function CalendarOptionsModalContent({
|
||||
onModalClose,
|
||||
}: CalendarOptionsModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
showMovieInformation,
|
||||
showCinemaRelease,
|
||||
showDigitalRelease,
|
||||
showPhysicalRelease,
|
||||
showCutoffUnmetIcon,
|
||||
fullColorEvents,
|
||||
} = useSelector((state: AppState) => state.calendar.options);
|
||||
|
||||
const uiSettings = useSelector(createUISettingsSelector());
|
||||
|
||||
const [state, setState] = useState<Partial<UiSettings>>({
|
||||
firstDayOfWeek: uiSettings.firstDayOfWeek,
|
||||
calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
|
||||
timeFormat: uiSettings.timeFormat,
|
||||
enableColorImpairedMode: uiSettings.enableColorImpairedMode,
|
||||
});
|
||||
|
||||
const {
|
||||
firstDayOfWeek,
|
||||
calendarWeekColumnHeader,
|
||||
timeFormat,
|
||||
enableColorImpairedMode,
|
||||
} = state;
|
||||
|
||||
const handleOptionInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
dispatch(setCalendarOption({ [name]: value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleGlobalInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
setState((prevState) => ({ ...prevState, [name]: value }));
|
||||
|
||||
dispatch(saveUISettings({ [name]: value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setState({
|
||||
firstDayOfWeek: uiSettings.firstDayOfWeek,
|
||||
calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
|
||||
timeFormat: uiSettings.timeFormat,
|
||||
enableColorImpairedMode: uiSettings.enableColorImpairedMode,
|
||||
});
|
||||
}, [uiSettings]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('CalendarOptions')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<FieldSet legend={translate('Local')}>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowMovieInformation')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showMovieInformation"
|
||||
value={showMovieInformation}
|
||||
helpText={translate('ShowMovieInformationHelpText')}
|
||||
onChange={handleOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowCinemaRelease')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showCinemaRelease"
|
||||
value={showCinemaRelease}
|
||||
helpText={translate('ShowCinemaReleaseCalendarHelpText')}
|
||||
isDisabled={
|
||||
showCinemaRelease &&
|
||||
!showDigitalRelease &&
|
||||
!showPhysicalRelease
|
||||
}
|
||||
onChange={handleOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowDigitalRelease')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showDigitalRelease"
|
||||
value={showDigitalRelease}
|
||||
helpText={translate('ShowDigitalReleaseCalendarHelpText')}
|
||||
isDisabled={
|
||||
!showCinemaRelease &&
|
||||
showDigitalRelease &&
|
||||
!showPhysicalRelease
|
||||
}
|
||||
onChange={handleOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowPhysicalRelease')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showPhysicalRelease"
|
||||
value={showPhysicalRelease}
|
||||
helpText={translate('ShowPhysicalReleaseCalendarHelpText')}
|
||||
isDisabled={
|
||||
!showCinemaRelease &&
|
||||
!showDigitalRelease &&
|
||||
showPhysicalRelease
|
||||
}
|
||||
onChange={handleOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IconForCutoffUnmet')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showCutoffUnmetIcon"
|
||||
value={showCutoffUnmetIcon}
|
||||
helpText={translate('IconForCutoffUnmetHelpText')}
|
||||
onChange={handleOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('FullColorEvents')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="fullColorEvents"
|
||||
value={fullColorEvents}
|
||||
helpText={translate('FullColorEventsHelpText')}
|
||||
onChange={handleOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Global')}>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('FirstDayOfWeek')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="firstDayOfWeek"
|
||||
values={firstDayOfWeekOptions}
|
||||
value={firstDayOfWeek}
|
||||
onChange={handleGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('WeekColumnHeader')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="calendarWeekColumnHeader"
|
||||
values={weekColumnOptions}
|
||||
value={calendarWeekColumnHeader}
|
||||
helpText={translate('WeekColumnHeaderHelpText')}
|
||||
onChange={handleGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('TimeFormat')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="timeFormat"
|
||||
values={timeFormatOptions}
|
||||
value={timeFormat}
|
||||
onChange={handleGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('EnableColorImpairedMode')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enableColorImpairedMode"
|
||||
value={enableColorImpairedMode}
|
||||
helpText={translate('EnableColorImpairedModeHelpText')}
|
||||
onChange={handleGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</FieldSet>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarOptionsModalContent;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { setCalendarOption } from 'Store/Actions/calendarActions';
|
||||
import { saveUISettings } from 'Store/Actions/settingsActions';
|
||||
import CalendarOptionsModalContent from './CalendarOptionsModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.options,
|
||||
(state) => state.settings.ui.item,
|
||||
(options, uiSettings) => {
|
||||
return {
|
||||
...options,
|
||||
...uiSettings
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchSetCalendarOption: setCalendarOption,
|
||||
dispatchSaveUISettings: saveUISettings
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent);
|
||||
@@ -5,3 +5,5 @@ export const FORECAST = 'forecast';
|
||||
export const AGENDA = 'agenda';
|
||||
|
||||
export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA];
|
||||
|
||||
export type CalendarView = 'agenda' | 'day' | 'forecast' | 'month' | 'week';
|
||||
@@ -1,4 +1,9 @@
|
||||
function getStatusStyle(hasFile, downloading, isMonitored, isAvailable) {
|
||||
function getStatusStyle(
|
||||
hasFile: boolean,
|
||||
downloading: boolean,
|
||||
isMonitored: boolean,
|
||||
isAvailable: boolean
|
||||
) {
|
||||
if (downloading) {
|
||||
return 'queue';
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector';
|
||||
|
||||
function CalendarLinkModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<CalendarLinkModalContentConnector
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
CalendarLinkModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarLinkModal;
|
||||
20
frontend/src/Calendar/iCal/CalendarLinkModal.tsx
Normal file
20
frontend/src/Calendar/iCal/CalendarLinkModal.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import CalendarLinkModalContent from './CalendarLinkModalContent';
|
||||
|
||||
interface CalendarLinkModalProps {
|
||||
isOpen: boolean;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function CalendarLinkModal(props: CalendarLinkModalProps) {
|
||||
const { isOpen, onModalClose } = props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<CalendarLinkModalContent onModalClose={onModalClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarLinkModal;
|
||||
@@ -1,203 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ClipboardButton from 'Components/Link/ClipboardButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
function getUrls(state) {
|
||||
const {
|
||||
unmonitored,
|
||||
asAllDay,
|
||||
tags
|
||||
} = state;
|
||||
|
||||
let icalUrl = `${window.location.host}${window.Radarr.urlBase}/feed/v3/calendar/Radarr.ics?`;
|
||||
|
||||
if (unmonitored) {
|
||||
icalUrl += 'unmonitored=true&';
|
||||
}
|
||||
|
||||
if (asAllDay) {
|
||||
icalUrl += 'asAllDay=true&';
|
||||
}
|
||||
|
||||
if (tags.length) {
|
||||
icalUrl += `tags=${tags.toString()}&`;
|
||||
}
|
||||
|
||||
icalUrl += `apikey=${encodeURIComponent(window.Radarr.apiKey)}`;
|
||||
|
||||
const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`;
|
||||
const iCalWebCalUrl = `webcal://${icalUrl}`;
|
||||
|
||||
return {
|
||||
iCalHttpUrl,
|
||||
iCalWebCalUrl
|
||||
};
|
||||
}
|
||||
|
||||
class CalendarLinkModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const defaultState = {
|
||||
unmonitored: false,
|
||||
asAllDay: false,
|
||||
tags: []
|
||||
};
|
||||
|
||||
const urls = getUrls(defaultState);
|
||||
|
||||
this.state = {
|
||||
...defaultState,
|
||||
...urls
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
const state = {
|
||||
...this.state,
|
||||
[name]: value
|
||||
};
|
||||
|
||||
const urls = getUrls(state);
|
||||
|
||||
this.setState({
|
||||
[name]: value,
|
||||
...urls
|
||||
});
|
||||
};
|
||||
|
||||
onLinkFocus = (event) => {
|
||||
event.target.select();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
unmonitored,
|
||||
asAllDay,
|
||||
tags,
|
||||
iCalHttpUrl,
|
||||
iCalWebCalUrl
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('CalendarFeed')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludeUnmonitored')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="unmonitored"
|
||||
value={unmonitored}
|
||||
helpText={translate('ICalIncludeUnmonitoredMoviesHelpText')}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ICalShowAsAllDayEvents')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="asAllDay"
|
||||
value={asAllDay}
|
||||
helpText={translate('ICalShowAsAllDayEventsHelpText')}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
value={tags}
|
||||
helpText={translate('ICalTagsMoviesHelpText')}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<FormLabel>{translate('ICalFeed')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="iCalHttpUrl"
|
||||
value={iCalHttpUrl}
|
||||
readOnly={true}
|
||||
helpText={translate('ICalFeedHelpText')}
|
||||
buttons={[
|
||||
<ClipboardButton
|
||||
key="copy"
|
||||
value={iCalHttpUrl}
|
||||
kind={kinds.DEFAULT}
|
||||
/>,
|
||||
|
||||
<FormInputButton
|
||||
key="webcal"
|
||||
kind={kinds.DEFAULT}
|
||||
to={iCalWebCalUrl}
|
||||
target="_blank"
|
||||
noRouter={true}
|
||||
>
|
||||
<Icon name={icons.CALENDAR_O} />
|
||||
</FormInputButton>
|
||||
]}
|
||||
onChange={this.onInputChange}
|
||||
onFocus={this.onLinkFocus}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>
|
||||
{translate('Close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarLinkModalContent.propTypes = {
|
||||
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarLinkModalContent;
|
||||
196
frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx
Normal file
196
frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { FocusEvent, useCallback, useMemo, useState } from 'react';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ClipboardButton from 'Components/Link/ClipboardButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
const releaseTypeOptions: EnhancedSelectInputValue<string>[] = [
|
||||
{
|
||||
key: 'cinemaRelease',
|
||||
get value() {
|
||||
return translate('CinemaRelease');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'digitalRelease',
|
||||
get value() {
|
||||
return translate('DigitalRelease');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'physicalRelease',
|
||||
get value() {
|
||||
return translate('PhysicalRelease');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
interface CalendarLinkModalContentProps {
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function CalendarLinkModalContent({
|
||||
onModalClose,
|
||||
}: CalendarLinkModalContentProps) {
|
||||
const [state, setState] = useState<{
|
||||
unmonitored: boolean;
|
||||
asAllDay: boolean;
|
||||
releaseTypes: string[];
|
||||
tags: number[];
|
||||
}>({
|
||||
unmonitored: false,
|
||||
asAllDay: false,
|
||||
releaseTypes: [],
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const { unmonitored, asAllDay, releaseTypes, tags } = state;
|
||||
|
||||
const handleInputChange = useCallback(({ name, value }: InputChanged) => {
|
||||
setState((prevState) => ({ ...prevState, [name]: value }));
|
||||
}, []);
|
||||
|
||||
const handleLinkFocus = useCallback(
|
||||
(event: FocusEvent<HTMLInputElement, Element>) => {
|
||||
event.target.select();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const { iCalHttpUrl, iCalWebCalUrl } = useMemo(() => {
|
||||
let icalUrl = `${window.location.host}${window.Radarr.urlBase}/feed/v3/calendar/Radarr.ics?`;
|
||||
|
||||
if (unmonitored) {
|
||||
icalUrl += 'unmonitored=true&';
|
||||
}
|
||||
|
||||
if (asAllDay) {
|
||||
icalUrl += 'asAllDay=true&';
|
||||
}
|
||||
|
||||
if (releaseTypes.length) {
|
||||
releaseTypes.forEach((releaseType) => {
|
||||
icalUrl += `releaseTypes=${releaseType}&`;
|
||||
});
|
||||
}
|
||||
|
||||
if (tags.length) {
|
||||
icalUrl += `tags=${tags.toString()}&`;
|
||||
}
|
||||
|
||||
icalUrl += `apikey=${encodeURIComponent(window.Radarr.apiKey)}`;
|
||||
|
||||
return {
|
||||
iCalHttpUrl: `${window.location.protocol}//${icalUrl}`,
|
||||
iCalWebCalUrl: `webcal://${icalUrl}`,
|
||||
};
|
||||
}, [unmonitored, asAllDay, releaseTypes, tags]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('CalendarFeed')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludeUnmonitored')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="unmonitored"
|
||||
value={unmonitored}
|
||||
helpText={translate('ICalIncludeUnmonitoredMoviesHelpText')}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ICalShowAsAllDayEvents')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="asAllDay"
|
||||
value={asAllDay}
|
||||
helpText={translate('ICalShowAsAllDayEventsHelpText')}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ICalReleaseTypes')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="releaseTypes"
|
||||
value={releaseTypes}
|
||||
values={releaseTypeOptions}
|
||||
helpText={translate('ICalReleaseTypesMoviesHelpText')}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MOVIE_TAG}
|
||||
name="tags"
|
||||
value={tags}
|
||||
helpText={translate('ICalTagsMoviesHelpText')}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup size={sizes.LARGE}>
|
||||
<FormLabel>{translate('ICalFeed')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="iCalHttpUrl"
|
||||
value={iCalHttpUrl}
|
||||
readOnly={true}
|
||||
helpText={translate('ICalFeedHelpText')}
|
||||
buttons={[
|
||||
<ClipboardButton
|
||||
key="copy"
|
||||
value={iCalHttpUrl}
|
||||
kind={kinds.DEFAULT}
|
||||
/>,
|
||||
|
||||
<FormInputButton
|
||||
key="webcal"
|
||||
kind={kinds.DEFAULT}
|
||||
to={iCalWebCalUrl}
|
||||
target="_blank"
|
||||
noRouter={true}
|
||||
>
|
||||
<Icon name={icons.CALENDAR_O} />
|
||||
</FormInputButton>,
|
||||
]}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleLinkFocus}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarLinkModalContent;
|
||||
@@ -1,17 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import CalendarLinkModalContent from './CalendarLinkModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createTagsSelector(),
|
||||
(tagList) => {
|
||||
return {
|
||||
tagList
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(CalendarLinkModalContent);
|
||||
@@ -1,31 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import AddNewCollectionMovieModalContentConnector from './AddNewCollectionMovieModalContentConnector';
|
||||
|
||||
function AddNewCollectionMovieModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<AddNewCollectionMovieModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddNewCollectionMovieModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddNewCollectionMovieModal;
|
||||
@@ -1,204 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes, kinds } from 'Helpers/Props';
|
||||
import MoviePoster from 'Movie/MoviePoster';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AddNewCollectionMovieModalContent.css';
|
||||
|
||||
class AddNewCollectionMovieModalContent extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onQualityProfileIdChange = ({ value }) => {
|
||||
this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
|
||||
};
|
||||
|
||||
onAddMoviePress = () => {
|
||||
this.props.onAddMoviePress();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
title,
|
||||
year,
|
||||
overview,
|
||||
images,
|
||||
isAdding,
|
||||
folder,
|
||||
tags,
|
||||
isSmallScreen,
|
||||
isWindows,
|
||||
onModalClose,
|
||||
onInputChange,
|
||||
rootFolderPath,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
minimumAvailability,
|
||||
searchForMovie
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{title}
|
||||
|
||||
{
|
||||
!title.contains(year) && !!year &&
|
||||
<span className={styles.year}>({year})</span>
|
||||
}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className={styles.container}>
|
||||
{
|
||||
!isSmallScreen &&
|
||||
<div className={styles.poster}>
|
||||
<MoviePoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={250}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className={styles.info}>
|
||||
<div className={styles.overview}>
|
||||
{overview}
|
||||
</div>
|
||||
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('RootFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.ROOT_FOLDER_SELECT}
|
||||
name="rootFolderPath"
|
||||
valueOptions={{
|
||||
movieFolder: folder,
|
||||
isWindows
|
||||
}}
|
||||
selectedValueOptions={{
|
||||
movieFolder: folder,
|
||||
isWindows
|
||||
}}
|
||||
helpText={translate('SubfolderWillBeCreatedAutomaticallyInterp', [folder])}
|
||||
onChange={onInputChange}
|
||||
{...rootFolderPath}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('Monitor')}
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MOVIE_MONITORED_SELECT}
|
||||
name="monitor"
|
||||
onChange={onInputChange}
|
||||
{...monitor}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('MinimumAvailability')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.AVAILABILITY_SELECT}
|
||||
name="minimumAvailability"
|
||||
onChange={onInputChange}
|
||||
{...minimumAvailability}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('QualityProfile')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
onChange={this.onQualityProfileIdChange}
|
||||
{...qualityProfileId}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
onChange={onInputChange}
|
||||
{...tags}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={styles.modalFooter}>
|
||||
<label className={styles.searchForMissingMovieLabelContainer}>
|
||||
<span className={styles.searchForMissingMovieLabel}>
|
||||
{translate('StartSearchForMissingMovie')}
|
||||
</span>
|
||||
|
||||
<CheckInput
|
||||
containerClassName={styles.searchForMissingMovieContainer}
|
||||
className={styles.searchForMissingMovieInput}
|
||||
name="searchForMovie"
|
||||
onChange={onInputChange}
|
||||
{...searchForMovie}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<SpinnerButton
|
||||
className={styles.addButton}
|
||||
kind={kinds.SUCCESS}
|
||||
isSpinning={isAdding}
|
||||
onPress={this.onAddMoviePress}
|
||||
>
|
||||
{translate('AddMovie')}
|
||||
</SpinnerButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddNewCollectionMovieModalContent.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
year: PropTypes.number.isRequired,
|
||||
overview: PropTypes.string,
|
||||
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isAdding: PropTypes.bool.isRequired,
|
||||
addError: PropTypes.object,
|
||||
rootFolderPath: PropTypes.object,
|
||||
monitor: PropTypes.object.isRequired,
|
||||
qualityProfileId: PropTypes.object,
|
||||
minimumAvailability: PropTypes.object.isRequired,
|
||||
searchForMovie: PropTypes.object.isRequired,
|
||||
folder: PropTypes.string.isRequired,
|
||||
tags: PropTypes.object.isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
isWindows: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onAddMoviePress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddNewCollectionMovieModalContent;
|
||||
@@ -1,121 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { addMovie, setMovieCollectionValue } from 'Store/Actions/movieCollectionActions';
|
||||
import createCollectionSelector from 'Store/Selectors/createCollectionSelector';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import AddNewMovieModalContent from './AddNewCollectionMovieModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.movieCollections,
|
||||
createCollectionSelector(),
|
||||
createDimensionsSelector(),
|
||||
createSystemStatusSelector(),
|
||||
(discoverMovieState, collection, dimensions, systemStatus) => {
|
||||
const {
|
||||
isAdding,
|
||||
addError,
|
||||
pendingChanges
|
||||
} = discoverMovieState;
|
||||
|
||||
const collectionDefaults = {
|
||||
rootFolderPath: collection.rootFolderPath,
|
||||
monitor: 'movieOnly',
|
||||
qualityProfileId: collection.qualityProfileId,
|
||||
minimumAvailability: collection.minimumAvailability,
|
||||
searchForMovie: collection.searchOnAdd,
|
||||
tags: collection.tags || []
|
||||
};
|
||||
|
||||
const {
|
||||
settings,
|
||||
validationErrors,
|
||||
validationWarnings
|
||||
} = selectSettings(collectionDefaults, pendingChanges, addError);
|
||||
|
||||
return {
|
||||
isAdding,
|
||||
addError,
|
||||
isSmallScreen: dimensions.isSmallScreen,
|
||||
validationErrors,
|
||||
validationWarnings,
|
||||
isWindows: systemStatus.isWindows,
|
||||
...settings
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
addMovie,
|
||||
setMovieCollectionValue
|
||||
};
|
||||
|
||||
class AddNewCollectionMovieModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.setMovieCollectionValue({ name, value });
|
||||
};
|
||||
|
||||
onAddMoviePress = () => {
|
||||
const {
|
||||
tmdbId,
|
||||
title,
|
||||
rootFolderPath,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
minimumAvailability,
|
||||
searchForMovie,
|
||||
tags
|
||||
} = this.props;
|
||||
|
||||
this.props.addMovie({
|
||||
tmdbId,
|
||||
title,
|
||||
rootFolderPath: rootFolderPath.value,
|
||||
monitor: monitor.value,
|
||||
qualityProfileId: qualityProfileId.value,
|
||||
minimumAvailability: minimumAvailability.value,
|
||||
searchForMovie: searchForMovie.value,
|
||||
tags: tags.value
|
||||
});
|
||||
|
||||
this.props.onModalClose(true);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<AddNewMovieModalContent
|
||||
{...this.props}
|
||||
onInputChange={this.onInputChange}
|
||||
onAddMoviePress={this.onAddMoviePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AddNewCollectionMovieModalContentConnector.propTypes = {
|
||||
tmdbId: PropTypes.number.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
rootFolderPath: PropTypes.object,
|
||||
monitor: PropTypes.object.isRequired,
|
||||
qualityProfileId: PropTypes.object,
|
||||
minimumAvailability: PropTypes.object.isRequired,
|
||||
searchForMovie: PropTypes.object.isRequired,
|
||||
tags: PropTypes.object.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
addMovie: PropTypes.func.isRequired,
|
||||
setMovieCollectionValue: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewCollectionMovieModalContentConnector);
|
||||
45
frontend/src/Collection/AddNewMovieCollectionMovieModal.tsx
Normal file
45
frontend/src/Collection/AddNewMovieCollectionMovieModal.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import AddNewMovieCollectionMovieModalContent, {
|
||||
AddNewMovieCollectionMovieModalContentProps,
|
||||
} from './AddNewMovieCollectionMovieModalContent';
|
||||
|
||||
interface AddNewCollectionMovieModalProps
|
||||
extends AddNewMovieCollectionMovieModalContentProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function AddNewMovieCollectionMovieModal({
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
}: AddNewCollectionMovieModalProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
dispatch(clearPendingChanges({ section: 'movieCollections' }));
|
||||
onModalClose();
|
||||
}, [dispatch, onModalClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasOpen && !isOpen) {
|
||||
dispatch(clearPendingChanges({ section: 'movieCollections' }));
|
||||
}
|
||||
}, [wasOpen, isOpen, dispatch]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={handleModalClose}>
|
||||
<AddNewMovieCollectionMovieModalContent
|
||||
{...otherProps}
|
||||
onModalClose={handleModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddNewMovieCollectionMovieModal;
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import MovieMinimumAvailabilityPopoverContent from 'AddMovie/MovieMinimumAvailabilityPopoverContent';
|
||||
import AppState from 'App/State/AppState';
|
||||
import useMovieCollection from 'Collection/useMovieCollection';
|
||||
import CheckInput from 'Components/Form/CheckInput';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
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 Popover from 'Components/Tooltip/Popover';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { Image } from 'Movie/Movie';
|
||||
import MoviePoster from 'Movie/MoviePoster';
|
||||
import {
|
||||
addMovie,
|
||||
setMovieCollectionValue,
|
||||
} from 'Store/Actions/movieCollectionActions';
|
||||
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 styles from './AddNewMovieCollectionMovieModalContent.css';
|
||||
|
||||
export interface AddNewMovieCollectionMovieModalContentProps {
|
||||
tmdbId: number;
|
||||
title: string;
|
||||
year: number;
|
||||
overview?: string;
|
||||
images: Image[];
|
||||
collectionId: number;
|
||||
folder: string;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function AddNewMovieCollectionMovieModalContent({
|
||||
tmdbId,
|
||||
title,
|
||||
year,
|
||||
overview,
|
||||
images,
|
||||
collectionId,
|
||||
folder,
|
||||
onModalClose,
|
||||
}: AddNewMovieCollectionMovieModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const collection = useMovieCollection(collectionId)!;
|
||||
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
const isWindows = useIsWindows();
|
||||
|
||||
const { isAdding, addError, pendingChanges } = useSelector(
|
||||
(state: AppState) => state.movieCollections
|
||||
);
|
||||
|
||||
const wasAdding = usePrevious(isAdding);
|
||||
|
||||
const { settings, validationErrors, validationWarnings } = useMemo(() => {
|
||||
const options = {
|
||||
rootFolderPath: collection.rootFolderPath,
|
||||
monitor: collection.monitored ? 'movieOnly' : 'none',
|
||||
qualityProfileId: collection.qualityProfileId,
|
||||
minimumAvailability: collection.minimumAvailability,
|
||||
searchForMovie: collection.searchOnAdd,
|
||||
tags: collection.tags || [],
|
||||
};
|
||||
|
||||
return selectSettings(options, pendingChanges, addError);
|
||||
}, [collection, pendingChanges, addError]);
|
||||
|
||||
const {
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
minimumAvailability,
|
||||
rootFolderPath,
|
||||
searchForMovie,
|
||||
tags,
|
||||
} = settings;
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
// @ts-expect-error actions aren't typed
|
||||
dispatch(setMovieCollectionValue({ name, value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleAddMoviePress = useCallback(() => {
|
||||
dispatch(
|
||||
addMovie({
|
||||
tmdbId,
|
||||
title,
|
||||
rootFolderPath: rootFolderPath.value,
|
||||
monitor: monitor.value,
|
||||
qualityProfileId: qualityProfileId.value,
|
||||
minimumAvailability: minimumAvailability.value,
|
||||
searchForMovie: searchForMovie.value,
|
||||
tags: tags.value,
|
||||
})
|
||||
);
|
||||
}, [
|
||||
tmdbId,
|
||||
title,
|
||||
rootFolderPath,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
minimumAvailability,
|
||||
searchForMovie,
|
||||
tags,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdding && wasAdding && !addError) {
|
||||
onModalClose();
|
||||
}
|
||||
}, [isAdding, wasAdding, addError, onModalClose]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{title}
|
||||
|
||||
{!title.includes(String(year)) && year ? (
|
||||
<span className={styles.year}>({year})</span>
|
||||
) : null}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div className={styles.container}>
|
||||
{isSmallScreen ? null : (
|
||||
<div className={styles.poster}>
|
||||
<MoviePoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={250}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.info}>
|
||||
{overview ? (
|
||||
<div className={styles.overview}>{overview}</div>
|
||||
) : null}
|
||||
|
||||
<Form
|
||||
validationErrors={validationErrors}
|
||||
validationWarnings={validationWarnings}
|
||||
>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('RootFolder')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.ROOT_FOLDER_SELECT}
|
||||
name="rootFolderPath"
|
||||
valueOptions={{
|
||||
movieFolder: folder,
|
||||
isWindows,
|
||||
}}
|
||||
selectedValueOptions={{
|
||||
movieFolder: folder,
|
||||
isWindows,
|
||||
}}
|
||||
helpText={translate('AddNewMovieRootFolderHelpText', {
|
||||
folder,
|
||||
})}
|
||||
{...rootFolderPath}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Monitor')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.MONITOR_MOVIES_SELECT}
|
||||
name="monitor"
|
||||
{...monitor}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('MinimumAvailability')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon className={styles.labelIcon} name={icons.INFO} />
|
||||
}
|
||||
title={translate('MinimumAvailability')}
|
||||
body={<MovieMinimumAvailabilityPopoverContent />}
|
||||
position={tooltipPositions.RIGHT}
|
||||
/>
|
||||
</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.AVAILABILITY_SELECT}
|
||||
name="minimumAvailability"
|
||||
{...minimumAvailability}
|
||||
helpLink="https://wiki.servarr.com/radarr/faq#what-is-minimum-availability"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('QualityProfile')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
{...qualityProfileId}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
{...tags}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={styles.modalFooter}>
|
||||
<label className={styles.searchForMissingMovieLabelContainer}>
|
||||
<span className={styles.searchForMissingMovieLabel}>
|
||||
{translate('StartSearchForMissingMovie')}
|
||||
</span>
|
||||
|
||||
<CheckInput
|
||||
containerClassName={styles.searchForMissingMovieContainer}
|
||||
className={styles.searchForMissingMovieInput}
|
||||
name="searchForMovie"
|
||||
{...searchForMovie}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<SpinnerButton
|
||||
className={styles.addButton}
|
||||
kind={kinds.SUCCESS}
|
||||
isSpinning={isAdding}
|
||||
onPress={handleAddMoviePress}
|
||||
>
|
||||
{translate('AddMovie')}
|
||||
</SpinnerButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddNewMovieCollectionMovieModalContent;
|
||||
@@ -18,9 +18,9 @@ import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import selectAll from 'Utilities/Table/selectAll';
|
||||
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||
import CollectionFooter from './CollectionFooter';
|
||||
import CollectionFilterMenu from './Menus/CollectionFilterMenu';
|
||||
import CollectionSortMenu from './Menus/CollectionSortMenu';
|
||||
import NoCollection from './NoCollection';
|
||||
import MovieCollectionFilterMenu from './Menus/MovieCollectionFilterMenu';
|
||||
import MovieCollectionSortMenu from './Menus/MovieCollectionSortMenu';
|
||||
import NoMovieCollections from './NoMovieCollections';
|
||||
import CollectionOverviewsConnector from './Overview/CollectionOverviewsConnector';
|
||||
import CollectionOverviewOptionsModal from './Overview/Options/CollectionOverviewOptionsModal';
|
||||
|
||||
@@ -224,6 +224,7 @@ class Collection extends Component {
|
||||
view,
|
||||
onSortSelect,
|
||||
onFilterSelect,
|
||||
initialScrollTop,
|
||||
onScroll,
|
||||
isRefreshingCollections,
|
||||
isSaving,
|
||||
@@ -247,7 +248,7 @@ class Collection extends Component {
|
||||
const hasNoCollection = !totalItems;
|
||||
|
||||
return (
|
||||
<PageContent>
|
||||
<PageContent title={translate('Collections')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
@@ -284,14 +285,14 @@ class Collection extends Component {
|
||||
<PageToolbarSeparator />
|
||||
}
|
||||
|
||||
<CollectionSortMenu
|
||||
<MovieCollectionSortMenu
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
isDisabled={hasNoCollection}
|
||||
onSortSelect={onSortSelect}
|
||||
/>
|
||||
|
||||
<CollectionFilterMenu
|
||||
<MovieCollectionFilterMenu
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
@@ -306,6 +307,7 @@ class Collection extends Component {
|
||||
ref={this.scrollerRef}
|
||||
className={styles.contentBody}
|
||||
innerClassName={styles[`${view}InnerContentBody`]}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{
|
||||
isFetching && !isPopulated &&
|
||||
@@ -334,6 +336,7 @@ class Collection extends Component {
|
||||
onSelectedChange={this.onSelectedChange}
|
||||
onSelectAllChange={this.onSelectAllChange}
|
||||
selectedState={selectedState}
|
||||
scrollTop={initialScrollTop}
|
||||
{...otherProps}
|
||||
/>
|
||||
</div>
|
||||
@@ -341,7 +344,7 @@ class Collection extends Component {
|
||||
|
||||
{
|
||||
!error && isPopulated && !items.length &&
|
||||
<NoCollection totalItems={totalItems} />
|
||||
<NoMovieCollections totalItems={totalItems} />
|
||||
}
|
||||
</PageContentBody>
|
||||
|
||||
@@ -374,6 +377,7 @@ class Collection extends Component {
|
||||
}
|
||||
|
||||
Collection.propTypes = {
|
||||
initialScrollTop: PropTypes.number,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
|
||||
@@ -5,14 +5,17 @@ import { createSelector } from 'reselect';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import withScrollPosition from 'Components/withScrollPosition';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { saveMovieCollections, setMovieCollectionsFilter, setMovieCollectionsSort } from 'Store/Actions/movieCollectionActions';
|
||||
import {
|
||||
fetchMovieCollections,
|
||||
saveMovieCollections,
|
||||
setMovieCollectionsFilter,
|
||||
setMovieCollectionsSort
|
||||
} from 'Store/Actions/movieCollectionActions';
|
||||
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import scrollPositions from 'Store/scrollPositions';
|
||||
import createCollectionClientSideCollectionItemsSelector from 'Store/Selectors/createCollectionClientSideCollectionItemsSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
|
||||
import Collection from './Collection';
|
||||
|
||||
function createMapStateToProps() {
|
||||
@@ -36,8 +39,8 @@ function createMapStateToProps() {
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
dispatchFetchRootFolders() {
|
||||
dispatch(fetchRootFolders());
|
||||
dispatchFetchMovieCollections() {
|
||||
dispatch(fetchMovieCollections());
|
||||
},
|
||||
dispatchFetchQueueDetails() {
|
||||
dispatch(fetchQueueDetails());
|
||||
@@ -68,13 +71,11 @@ class CollectionConnector extends Component {
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
registerPagePopulator(this.repopulate);
|
||||
this.props.dispatchFetchRootFolders();
|
||||
this.props.dispatchFetchMovieCollections();
|
||||
this.props.dispatchFetchQueueDetails();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
unregisterPagePopulator(this.repopulate);
|
||||
this.props.dispatchClearQueueDetails();
|
||||
}
|
||||
|
||||
@@ -93,9 +94,16 @@ class CollectionConnector extends Component {
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dispatchFetchMovieCollections,
|
||||
dispatchFetchQueueDetails,
|
||||
dispatchClearQueueDetails,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Collection
|
||||
{...this.props}
|
||||
{...otherProps}
|
||||
onViewSelect={this.onViewSelect}
|
||||
onScroll={this.onScroll}
|
||||
onUpdateSelectedPress={this.onUpdateSelectedPress}
|
||||
@@ -108,7 +116,7 @@ CollectionConnector.propTypes = {
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
view: PropTypes.string.isRequired,
|
||||
onUpdateSelectedPress: PropTypes.func.isRequired,
|
||||
dispatchFetchRootFolders: PropTypes.func.isRequired,
|
||||
dispatchFetchMovieCollections: PropTypes.func.isRequired,
|
||||
dispatchFetchQueueDetails: PropTypes.func.isRequired,
|
||||
dispatchClearQueueDetails: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import FilterModal from 'Components/Filter/FilterModal';
|
||||
import { setMovieCollectionsFilter } from 'Store/Actions/movieCollectionActions';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.movieCollections.items,
|
||||
(state) => state.movieCollections.filterBuilderProps,
|
||||
(sectionItems, filterBuilderProps) => {
|
||||
return {
|
||||
sectionItems,
|
||||
filterBuilderProps,
|
||||
customFilterType: 'movieCollections'
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchSetFilter: setMovieCollectionsFilter
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal);
|
||||
@@ -1,296 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import AvailabilitySelectInput from 'Components/Form/AvailabilitySelectInput';
|
||||
import QualityProfileSelectInputConnector from 'Components/Form/QualityProfileSelectInputConnector';
|
||||
import RootFolderSelectInputConnector from 'Components/Form/RootFolderSelectInputConnector';
|
||||
import SelectInput from 'Components/Form/SelectInput';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import PageContentFooter from 'Components/Page/PageContentFooter';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CollectionFooterLabel from './CollectionFooterLabel';
|
||||
import styles from './CollectionFooter.css';
|
||||
|
||||
const NO_CHANGE = 'noChange';
|
||||
|
||||
const monitoredOptions = [
|
||||
{
|
||||
key: NO_CHANGE,
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
isDisabled: true
|
||||
},
|
||||
{
|
||||
key: 'monitored',
|
||||
get value() {
|
||||
return translate('Monitored');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'unmonitored',
|
||||
get value() {
|
||||
return translate('Unmonitored');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const searchOnAddOptions = [
|
||||
{
|
||||
key: NO_CHANGE,
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
isDisabled: true
|
||||
},
|
||||
{
|
||||
key: 'yes',
|
||||
get value() {
|
||||
return translate('Yes');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'no',
|
||||
get value() {
|
||||
return translate('No');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
class CollectionFooter extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
monitored: NO_CHANGE,
|
||||
monitor: NO_CHANGE,
|
||||
qualityProfileId: NO_CHANGE,
|
||||
minimumAvailability: NO_CHANGE,
|
||||
rootFolderPath: NO_CHANGE,
|
||||
searchOnAdd: NO_CHANGE
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
isSaving,
|
||||
saveError
|
||||
} = this.props;
|
||||
|
||||
const newState = {};
|
||||
if (prevProps.isSaving && !isSaving && !saveError) {
|
||||
this.setState({
|
||||
monitored: NO_CHANGE,
|
||||
monitor: NO_CHANGE,
|
||||
qualityProfileId: NO_CHANGE,
|
||||
minimumAvailability: NO_CHANGE,
|
||||
rootFolderPath: NO_CHANGE,
|
||||
searchOnAdd: NO_CHANGE
|
||||
});
|
||||
}
|
||||
|
||||
if (!_.isEmpty(newState)) {
|
||||
this.setState(newState);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.setState({ [name]: value });
|
||||
};
|
||||
|
||||
onUpdateSelectedPress = () => {
|
||||
const {
|
||||
monitored,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
minimumAvailability,
|
||||
rootFolderPath,
|
||||
searchOnAdd
|
||||
} = this.state;
|
||||
|
||||
const changes = {};
|
||||
|
||||
if (monitored !== NO_CHANGE) {
|
||||
changes.monitored = monitored === 'monitored';
|
||||
}
|
||||
|
||||
if (monitor !== NO_CHANGE) {
|
||||
changes.monitor = monitor;
|
||||
}
|
||||
|
||||
if (qualityProfileId !== NO_CHANGE) {
|
||||
changes.qualityProfileId = qualityProfileId;
|
||||
}
|
||||
|
||||
if (minimumAvailability !== NO_CHANGE) {
|
||||
changes.minimumAvailability = minimumAvailability;
|
||||
}
|
||||
|
||||
if (rootFolderPath !== NO_CHANGE) {
|
||||
changes.rootFolderPath = rootFolderPath;
|
||||
}
|
||||
|
||||
if (searchOnAdd !== NO_CHANGE) {
|
||||
changes.searchOnAdd = searchOnAdd === 'yes';
|
||||
}
|
||||
|
||||
this.props.onUpdateSelectedPress(changes);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
selectedIds,
|
||||
isSaving
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
monitored,
|
||||
monitor,
|
||||
qualityProfileId,
|
||||
minimumAvailability,
|
||||
rootFolderPath,
|
||||
searchOnAdd
|
||||
} = this.state;
|
||||
|
||||
const selectedCount = selectedIds.length;
|
||||
|
||||
return (
|
||||
<PageContentFooter>
|
||||
<div className={styles.inputContainer}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('MonitorCollection')}
|
||||
isSaving={isSaving && monitored !== NO_CHANGE}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
name="monitored"
|
||||
value={monitored}
|
||||
values={monitoredOptions}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('MonitorMovies')}
|
||||
isSaving={isSaving && monitor !== NO_CHANGE}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
name="monitor"
|
||||
value={monitor}
|
||||
values={monitoredOptions}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('QualityProfile')}
|
||||
isSaving={isSaving && qualityProfileId !== NO_CHANGE}
|
||||
/>
|
||||
|
||||
<QualityProfileSelectInputConnector
|
||||
name="qualityProfileId"
|
||||
value={qualityProfileId}
|
||||
includeNoChange={true}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('MinimumAvailability')}
|
||||
isSaving={isSaving && minimumAvailability !== NO_CHANGE}
|
||||
/>
|
||||
|
||||
<AvailabilitySelectInput
|
||||
name="minimumAvailability"
|
||||
value={minimumAvailability}
|
||||
includeNoChange={true}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('RootFolder')}
|
||||
isSaving={isSaving && rootFolderPath !== NO_CHANGE}
|
||||
/>
|
||||
|
||||
<RootFolderSelectInputConnector
|
||||
name="rootFolderPath"
|
||||
value={rootFolderPath}
|
||||
includeNoChange={true}
|
||||
isDisabled={!selectedCount}
|
||||
selectedValueOptions={{ includeFreeSpace: false }}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('SearchMoviesOnAdd')}
|
||||
isSaving={isSaving && searchOnAdd !== NO_CHANGE}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
name="searchOnAdd"
|
||||
value={searchOnAdd}
|
||||
values={searchOnAddOptions}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<div className={styles.buttonContainerContent}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('CountCollectionsSelected', { count: selectedCount })}
|
||||
isSaving={false}
|
||||
/>
|
||||
|
||||
<div className={styles.buttons}>
|
||||
<div>
|
||||
<SpinnerButton
|
||||
className={styles.addSelectedButton}
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={isSaving}
|
||||
isDisabled={!selectedCount || isSaving}
|
||||
onPress={this.onUpdateSelectedPress}
|
||||
>
|
||||
{translate('UpdateSelected')}
|
||||
</SpinnerButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentFooter>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CollectionFooter.propTypes = {
|
||||
selectedIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
isAdding: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
onUpdateSelectedPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CollectionFooter;
|
||||
317
frontend/src/Collection/CollectionFooter.tsx
Normal file
317
frontend/src/Collection/CollectionFooter.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import { EnhancedSelectInputValue } from 'Components/Form/Select/EnhancedSelectInput';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import PageContentFooter from 'Components/Page/PageContentFooter';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { inputTypes, kinds } from 'Helpers/Props';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CollectionFooterLabel from './CollectionFooterLabel';
|
||||
import styles from './CollectionFooter.css';
|
||||
|
||||
interface SavePayload {
|
||||
monitored?: boolean;
|
||||
monitor?: string;
|
||||
qualityProfileId?: number;
|
||||
minimumAvailability?: string;
|
||||
rootFolderPath?: string;
|
||||
searchOnAdd?: boolean;
|
||||
}
|
||||
|
||||
interface CollectionFooterProps {
|
||||
selectedIds: number[];
|
||||
isAdding: boolean;
|
||||
isSaving: boolean;
|
||||
saveError: Error;
|
||||
onUpdateSelectedPress(payload: object): void;
|
||||
}
|
||||
|
||||
const NO_CHANGE = 'noChange';
|
||||
|
||||
const monitoredOptions: EnhancedSelectInputValue<string>[] = [
|
||||
{
|
||||
key: NO_CHANGE,
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'monitored',
|
||||
get value() {
|
||||
return translate('Monitored');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'unmonitored',
|
||||
get value() {
|
||||
return translate('Unmonitored');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const searchOnAddOptions: EnhancedSelectInputValue<string>[] = [
|
||||
{
|
||||
key: NO_CHANGE,
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'yes',
|
||||
get value() {
|
||||
return translate('Yes');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'no',
|
||||
get value() {
|
||||
return translate('No');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function CollectionFooter({
|
||||
selectedIds,
|
||||
isSaving,
|
||||
saveError,
|
||||
onUpdateSelectedPress,
|
||||
}: CollectionFooterProps) {
|
||||
const [monitored, setMonitored] = useState(NO_CHANGE);
|
||||
const [monitor, setMonitor] = useState(NO_CHANGE);
|
||||
const [qualityProfileId, setQualityProfileId] = useState<string | number>(
|
||||
NO_CHANGE
|
||||
);
|
||||
const [minimumAvailability, setMinimumAvailability] = useState(NO_CHANGE);
|
||||
const [rootFolderPath, setRootFolderPath] = useState(NO_CHANGE);
|
||||
const [searchOnAdd, setSearchOnAdd] = useState(NO_CHANGE);
|
||||
|
||||
const wasSaving = usePrevious(isSaving);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
let hasChanges = false;
|
||||
const payload: SavePayload = {};
|
||||
|
||||
if (monitored !== NO_CHANGE) {
|
||||
hasChanges = true;
|
||||
payload.monitored = monitored === 'monitored';
|
||||
}
|
||||
|
||||
if (monitor !== NO_CHANGE) {
|
||||
hasChanges = true;
|
||||
payload.monitor = monitor;
|
||||
}
|
||||
|
||||
if (qualityProfileId !== NO_CHANGE) {
|
||||
hasChanges = true;
|
||||
payload.qualityProfileId = qualityProfileId as number;
|
||||
}
|
||||
|
||||
if (minimumAvailability !== NO_CHANGE) {
|
||||
hasChanges = true;
|
||||
payload.minimumAvailability = minimumAvailability as string;
|
||||
}
|
||||
|
||||
if (rootFolderPath !== NO_CHANGE) {
|
||||
hasChanges = true;
|
||||
payload.rootFolderPath = rootFolderPath;
|
||||
}
|
||||
|
||||
if (searchOnAdd !== NO_CHANGE) {
|
||||
hasChanges = true;
|
||||
payload.searchOnAdd = searchOnAdd === 'yes';
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
onUpdateSelectedPress(payload);
|
||||
}
|
||||
}, [
|
||||
monitor,
|
||||
monitored,
|
||||
qualityProfileId,
|
||||
minimumAvailability,
|
||||
rootFolderPath,
|
||||
searchOnAdd,
|
||||
onUpdateSelectedPress,
|
||||
]);
|
||||
|
||||
const handleInputChange = useCallback(({ name, value }: InputChanged) => {
|
||||
switch (name) {
|
||||
case 'monitored':
|
||||
setMonitored(value as string);
|
||||
break;
|
||||
case 'monitor':
|
||||
setMonitor(value as string);
|
||||
break;
|
||||
case 'qualityProfileId':
|
||||
setQualityProfileId(value as string);
|
||||
break;
|
||||
case 'minimumAvailability':
|
||||
setMinimumAvailability(value as string);
|
||||
break;
|
||||
case 'rootFolderPath':
|
||||
setRootFolderPath(value as string);
|
||||
break;
|
||||
case 'searchOnAdd':
|
||||
setSearchOnAdd(value as string);
|
||||
break;
|
||||
default:
|
||||
console.warn(`CollectionFooter Unknown Input: '${name}'`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSaving && wasSaving && !saveError) {
|
||||
setMonitored(NO_CHANGE);
|
||||
setMonitor(NO_CHANGE);
|
||||
setQualityProfileId(NO_CHANGE);
|
||||
setMinimumAvailability(NO_CHANGE);
|
||||
setRootFolderPath(NO_CHANGE);
|
||||
setSearchOnAdd(NO_CHANGE);
|
||||
}
|
||||
}, [
|
||||
isSaving,
|
||||
wasSaving,
|
||||
saveError,
|
||||
setMonitored,
|
||||
setMonitor,
|
||||
setQualityProfileId,
|
||||
setMinimumAvailability,
|
||||
setRootFolderPath,
|
||||
setSearchOnAdd,
|
||||
]);
|
||||
|
||||
const selectedCount = selectedIds.length;
|
||||
|
||||
return (
|
||||
<PageContentFooter>
|
||||
<div className={styles.inputContainer}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('MonitorCollection')}
|
||||
isSaving={isSaving && monitored !== NO_CHANGE}
|
||||
/>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="monitored"
|
||||
value={monitored}
|
||||
values={monitoredOptions}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('MonitorMovies')}
|
||||
isSaving={isSaving && monitor !== NO_CHANGE}
|
||||
/>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="monitor"
|
||||
value={monitor}
|
||||
values={monitoredOptions}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('QualityProfile')}
|
||||
isSaving={isSaving && qualityProfileId !== NO_CHANGE}
|
||||
/>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.QUALITY_PROFILE_SELECT}
|
||||
name="qualityProfileId"
|
||||
value={qualityProfileId}
|
||||
includeNoChange={true}
|
||||
includeNoChangeDisabled={false}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('MinimumAvailability')}
|
||||
isSaving={isSaving && minimumAvailability !== NO_CHANGE}
|
||||
/>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.AVAILABILITY_SELECT}
|
||||
name="minimumAvailability"
|
||||
value={minimumAvailability}
|
||||
includeNoChange={true}
|
||||
includeNoChangeDisabled={false}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('RootFolder')}
|
||||
isSaving={isSaving && rootFolderPath !== NO_CHANGE}
|
||||
/>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.ROOT_FOLDER_SELECT}
|
||||
name="rootFolderPath"
|
||||
value={rootFolderPath}
|
||||
includeNoChange={true}
|
||||
includeNoChangeDisabled={false}
|
||||
selectedValueOptions={{ includeFreeSpace: false }}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputContainer}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('SearchMoviesOnAdd')}
|
||||
isSaving={isSaving && searchOnAdd !== NO_CHANGE}
|
||||
/>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="searchOnAdd"
|
||||
value={searchOnAdd}
|
||||
values={searchOnAddOptions}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<div className={styles.buttonContainerContent}>
|
||||
<CollectionFooterLabel
|
||||
label={translate('CountCollectionsSelected', {
|
||||
count: selectedCount,
|
||||
})}
|
||||
isSaving={false}
|
||||
/>
|
||||
|
||||
<div className={styles.buttons}>
|
||||
<div>
|
||||
<SpinnerButton
|
||||
className={styles.addSelectedButton}
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={isSaving}
|
||||
isDisabled={!selectedCount || isSaving}
|
||||
onPress={handleSavePress}
|
||||
>
|
||||
{translate('UpdateSelected')}
|
||||
</SpinnerButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentFooter>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollectionFooter;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user