1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-03-05 13:21:25 -05:00

Compare commits

...

75 Commits

Author SHA1 Message Date
bakerboy448
f36845c251 Fixed: Parse UHDBDRip as BluRay quality 2025-08-16 10:35:25 -05:00
bakerboy448
110a338fb6 Fixed: TMDb List Paging (#11201) 2025-08-16 09:20:34 -05:00
Mark McDowall
3fcbaf9259 New: Move auth success logging to debug
Closes #7978

(cherry picked from commit 9ebe043bd94d036fe2ab45f3bb3f882cda48e211)
2025-08-12 12:20:36 -05:00
Luigi
576eff1890 New: Select with poster click in movie selection (#11187) 2025-08-12 11:49:58 -05:00
jkhsjdhjs
b0284bda07 Fixed: Parse HDDVDRip as BluRay 2025-08-11 18:53:36 -05:00
Bwaffles
c78666009d New: Add Year sorting to Discover page 2025-08-11 18:52:56 -05:00
Mark McDowall
b51d1beaaa Don't log debug messages for API key validation
(cherry picked from commit 78ca30d1f81361a2dabaddd0036b764859b858af)
2025-08-11 18:32:42 -05:00
Weblate
4d22bf1ceb Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: ArLab1 <arnaud.laberge@hotmail.com>
Co-authored-by: Ethan Francois <ethanfrancois0@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: adri1m64 <adrien.melle@laposte.net>
Co-authored-by: deamok <deamok@gmail.com>
Co-authored-by: dtorner <dtorner@gmail.com>
Co-authored-by: scdani <csonkadancsi@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2025-08-11 18:31:56 -05:00
bakerboy448
f9562b9b76 Bump version to 5.27.4 2025-08-03 01:10:22 -05:00
bakerboy448
6851c26328 Bump SixLabors.ImageSharp to 3.1.11 2025-07-31 21:52:59 -05:00
bakerboy448
e29be26fc9 Fixed: Prevent using Original names with other movie file tokens (#11175)
Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>
2025-07-25 08:16:18 -05:00
bakerboy448
f6bd2f52d5 Bump version to 5.27.3 2025-07-23 08:51:28 -05:00
Denis Gheorghescu
8bef9b4da7 New:(Pushcut) Improved Notification Details (#10897) 2025-07-19 10:43:18 -04:00
Mark McDowall
787c387036 Return error if Manual Import called without items
(cherry picked from commit 4bdb0408f1bafa38b777a41babb1a775f99a94c1)
2025-07-09 16:19:57 -05:00
bakerboy448
0525256115 Bump version to 5.27.2 2025-07-08 18:37:58 -05:00
bakerboy448
5767e181b7 New: Improve Reject for Unknown Movie Messaging (#11063) 2025-07-08 18:25:51 -05:00
Mark McDowall
1cf3ef5dff New: Improve stored UI settings for multiple instances under the same host
Closes #10671
Fixes #11146

(cherry picked from commit 6677fd11168de6dbf78d03bfedf67b89dfe1df53)
2025-07-08 18:07:52 -05:00
Weblate
b6bad2398c Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: ACHN SYPS <achn.syps@gmail.com>
Co-authored-by: EdiTurn <yyxstter@gmail.com>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: josef <josef.holzapfel@proton.me>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/th/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2025-07-08 18:06:46 -05:00
nuxen
16308e4b1c Fixed: xvid not always detected correctly (#11138) 2025-07-07 20:00:13 -05:00
bakerboy448
bd7465fae4 Fixed: Allow Discover Exclusions of Movies without Year (Year 0)
Fixes #11135
2025-06-28 09:07:26 -05:00
Weblate
c0d70485c3 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: HanaO00 <lwin24452@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translation: Servarr/Radarr
2025-06-19 18:20:22 -05:00
Bogdan
c743383912 Fixed: Deleting tags from UI
Fixes #11131
2025-06-16 20:34:00 +03:00
Servarr
d93c1d7808 Automated API Docs update 2025-06-16 20:22:31 +03:00
nuxen
0e2e7e4259 New: Support for multiple movieIds in Rename API endpoint 2025-06-16 19:09:19 +03:00
Bogdan
e6b27512c9 Bump version to 5.27.1 2025-06-15 09:20:29 +03:00
Bogdan
dae5e86b2c Fixed: Skip title searches for Newznab/Torznab indexers when movie year is missing
Prevents useless text searches of `Movie Title 0` when year is missing.

Fixes #10569
2025-06-14 13:20:11 +03:00
Bogdan
71f032d175 Bump Polly to 8.6.0 2025-06-11 23:13:54 +03:00
Bogdan
5a6db29dbd Bump version to 5.27.0 2025-06-11 23:11:20 +03:00
Weblate
2dac2dd35b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Qqqqqquexx <946921515@qq.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_Hans/
Translation: Servarr/Radarr
2025-06-11 15:01:35 +03:00
Stevie Robinson
b829638a77 Fixed: Include network drive types in Disk Space
(cherry picked from commit 9ffcd141a515e99604881a4ef383dadafef31eeb)
2025-06-10 15:31:34 +03:00
Mark McDowall
b6b7f13839 Prevent should refresh movie metadata from failing
Fixed: Prevent error checking if movie metadata should be refreshed from failing refresh movies task
(cherry picked from commit 3eed84c67938fed308e562e69cf7bcd727063803)
2025-06-10 15:31:34 +03:00
Mark McDowall
a9ad197b75 New: Update wording when removing a root folder
(cherry picked from commit 51c17fd3122f7b96a4155593d465ba32870d0c91)
2025-06-10 15:31:34 +03:00
Mark McDowall
1b28116a7e Fixed: Escape backticks in discord notifications
(cherry picked from commit 70c74fc1769f2094a14faaa103c49d744534be9f)
2025-06-10 15:31:34 +03:00
Bogdan
5870c88e1c Fix fullscreen automation screenshots 2025-06-09 22:05:09 +03:00
Servarr
0629832bd0 Automated API Docs update 2025-06-09 15:22:52 +03:00
Bogdan
430897c710 Fixed: Hide separators when page toolbar shows all buttons on small screens
Fixes #11124
2025-06-09 15:11:45 +03:00
Bogdan
9c42246eef Bump SixLabors.ImageSharp to 3.1.9 2025-06-08 11:32:36 +03:00
Weblate
489a86b253 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_TW/
Translation: Servarr/Radarr
2025-06-08 10:42:02 +03:00
Robert Dailey
9c8d3b679d Add 'qualitydefinition/limits' endpoint to get size limitations
(cherry picked from commit 24f03fc1e96eba215f96312c791cf167f10499c7)
2025-06-08 10:41:37 +03:00
Bogdan
b2e51d1613 Bump version to 5.26.2 2025-06-08 10:30:06 +03:00
Michael Peleshenko
a95b1f2992 Fixed: Handling movies with empty IMDB IDs in lists clean library 2025-06-07 11:35:44 +03:00
Mark McDowall
ac33b15048 Convert Tags to TypeScript
(cherry picked from commit 60529f0bacf2398838ef8d7843490a35046a1093)
2025-06-04 22:16:24 +03:00
Bogdan
d28f03af28 Fixed: Allow more prefixes and suffixes for Release Year naming token 2025-06-04 19:50:08 +03:00
Bogdan
73b99d0be2 Add translation for missing movies count from collection 2025-06-04 18:54:09 +03:00
Stevie Robinson
15c34a61de New: Ability to clone Import Lists
(cherry picked from commit 2314d0b506e30d3a965497a052bc5e54fa0beb81)

Closes #10948
2025-06-04 18:34:13 +03:00
Mark McDowall
b99c536306 Convert ImportLists to TypeScript
(cherry picked from commit 10e3a237ef972540abcf4348bb56973d7ee19bd7)
2025-06-04 18:28:50 +03:00
Mark McDowall
2ebf391f85 Convert Media Management settings to TypeScript
(cherry picked from commit 27f81117ed188712600d8daf3ccb5121f9808458)
2025-06-04 17:50:00 +03:00
Mark McDowall
3945a2eeb8 Convert Indexer settings to TypeScript
(cherry picked from commit 6e008a8e855e67bb14b0e04bdb9042eebcacb59f)
2025-06-04 15:57:46 +03:00
Mark McDowall
e6980df590 Convert SettingsToolbar to TypeScript
(cherry picked from commit fd09ca6e719a96f760006ed0f08756faa20b6f75)
2025-06-04 14:43:30 +03:00
nuxen
187dd79b9c Fixed: Allow opening curly bracket as prefix in naming format 2025-06-03 16:27:31 +03:00
Bogdan
22ef334de6 Fix translation token for root folders load error 2025-06-03 15:22:38 +03:00
Weblate
c9eb9b8b98 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Tur3Q <andrejturan@protonmail.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: cyrille <oscarboehringer@gmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translation: Servarr/Radarr
2025-06-03 14:31:18 +03:00
Bogdan
9c74c40fc6 Fixed: Quality sliders on some browsers
Fixes #11109
2025-06-01 18:07:25 +03:00
Mark McDowall
8911cbe872 Sync react-slider props for Quality sliders with upstream
(cherry picked from commit 9dab2ba6e4316879e4db8db47363476a5c4f13b2)
2025-06-01 17:54:31 +03:00
Ghworg
7e541d4653 Fixed: Display media info bitrates in bits (#11087) 2025-06-01 14:50:53 +03:00
Bogdan
1cc2237ac0 Bump version to 5.26.1 2025-06-01 10:39:43 +03:00
Bogdan
470963921d Fixed: Improve edition naming for ordinals and certain keywords
Co-authored-by: Brandon Shelley <brandon@pacificaviator.co>
2025-05-31 14:46:43 +03:00
Servarr
36f9ec4ea7 Automated API Docs update 2025-05-30 16:20:50 +03:00
Bogdan
9df2368601 New: Release type options for Calendar page 2025-05-30 15:39:00 +03:00
Bogdan
e7d76350ec New: Release type options for Calendar Feed 2025-05-30 15:39:00 +03:00
Bogdan
fd3828ff5d Update translation tokens for release dates on poster options 2025-05-30 01:04:29 +03:00
Bogdan
368e1fead8 Fixed: ISO language code for Bengali 2025-05-29 18:50:21 +03:00
Mark McDowall
5b357faf16 useMeasure instead of Measure in TypeScript components
(cherry picked from commit ee1a0a1f7175839c63595bef6d0221d3787189f4)
2025-05-29 17:14:47 +03:00
Bogdan
3f35b7c782 Convert Calendar to TypeScript
(cherry picked from commit 811eb36c7b1a5124270ff93d18d16944e654de81)

Co-authored-by: Mark McDowall <mark@mcdowall.ca>

Closes #10764
Closes #10776
Closes #10781
2025-05-29 17:14:07 +03:00
Bogdan
7d29deb93c Fixed: Parsing quality for release titles with Hybrid WEB 2025-05-28 12:54:00 +03:00
Mark McDowall
d0bfdce9c5 Fixed: Sorting of some titles with acronyms or common words at the start
(cherry picked from commit 79436149eb6869033d2263cd9558dbe75b1d3a68)
2025-05-27 18:23:08 +03:00
Bogdan
5d0cd78667 Bump version to 5.26.0 2025-05-27 00:58:58 +03:00
Bogdan
afbe0ebcd4 Fixed: Validation for remote path mapping
Fixes #11092
2025-05-26 21:26:07 +03:00
Bogdan
bfbb7532a2 Bump version to 5.25.0 2025-05-26 13:25:58 +03:00
Bogdan
c92d8c08f1 Follow redirects for usenet grabs on non-prod builds 2025-05-25 20:05:18 +03:00
bakerboy448
358ce92f85 Fixed: Production builds erroneously marked as not Production
build number exceeded; bump to 20k
fixes #11089
2025-05-25 11:48:29 -05:00
Servarr
3ec5a4b78a Automated API Docs update 2025-05-25 16:43:04 +03:00
Bogdan
cb59ce891a New: Keywords custom filter and autotagging for movies 2025-05-25 14:55:45 +03:00
Weblate
4d3d46d796 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Alan <1790727569@qq.com>
Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: warkurre86 <tom.novo.86@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_Hans/
Translation: Servarr/Radarr
2025-05-25 14:12:23 +03:00
Bogdan
0941e51d27 Bump version to 5.24.1 2025-05-25 14:11:22 +03:00
332 changed files with 8689 additions and 9612 deletions

9
.gitignore vendored
View File

@@ -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/

View File

@@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '5.24.0'
majorVersion: '5.27.4'
minorVersion: $[counter('minorVersion', 2000)]
radarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(radarrVersion)'

View File

@@ -108,7 +108,7 @@ class ImportMovie extends Component {
{
!rootFoldersFetching && !!rootFoldersError ?
<Alert kind={kinds.DANGER}>
{translate('UnableToLoadRootFolders')}
{translate('RootFoldersLoadError')}
</Alert> :
null
}

View File

@@ -93,7 +93,7 @@ class ImportMovieSelectFolder extends Component {
{
!isFetching && error ?
<Alert kind={kinds.DANGER}>
{translate('UnableToLoadRootFolders')}
{translate('RootFoldersLoadError')}
</Alert> :
null
}

View File

@@ -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';
@@ -73,7 +73,7 @@ function AppRoutes() {
Calendar
*/}
<Route path="/calendar" component={CalendarPageConnector} />
<Route path="/calendar" component={CalendarPage} />
{/*
Activity
@@ -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} />

View File

@@ -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> {

View File

@@ -68,6 +68,7 @@ export interface AppSectionState {
prevVersion?: string;
dimensions: {
isSmallScreen: boolean;
isLargeScreen: boolean;
width: number;
height: number;
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -17,7 +17,7 @@ export interface TagDetail extends ModelBase {
indexerIds: number[];
movieIds: number[];
notificationIds: number[];
restrictionIds: number[];
releaseProfileIds: number[];
}
export interface TagDetailAppState

View File

@@ -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;

View 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;

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;

View 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;

View File

@@ -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);

View File

@@ -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;

View 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;

View File

@@ -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);

View File

@@ -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;

View 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;

View File

@@ -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)
);

View File

@@ -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>

View File

@@ -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);

View File

@@ -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;

View 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;

View File

@@ -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);

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -4,6 +4,7 @@ interface CssExports {
'continuing': string;
'downloaded': string;
'event': string;
'eventType': string;
'genres': string;
'info': string;
'missingMonitored': string;

View File

@@ -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;

View 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;

View File

@@ -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);

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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);

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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);

View File

@@ -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';

View File

@@ -1,4 +1,9 @@
function getStatusStyle(hasFile, downloading, isMonitored, isAvailable) {
function getStatusStyle(
hasFile: boolean,
downloading: boolean,
isMonitored: boolean,
isAvailable: boolean
) {
if (downloading) {
return 'queue';
}

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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);

View File

@@ -196,7 +196,7 @@ class CollectionOverview extends Component {
size={13}
/>
<span className={styles.status}>
{`${missingMovies} missing movie(s)`}
{translate('CountMissingMoviesFromLibrary', { count: missingMovies })}
</span>
</Label>

View File

@@ -5,18 +5,20 @@ import { ValidationError, ValidationWarning } from 'typings/pending';
import styles from './Form.css';
export interface FormProps {
id?: string;
children: ReactNode;
validationErrors?: ValidationError[];
validationWarnings?: ValidationWarning[];
}
function Form({
id,
children,
validationErrors = [],
validationWarnings = [],
}: FormProps) {
return (
<div>
<div id={id}>
{validationErrors.length || validationWarnings.length ? (
<div className={styles.validationFailures}>
{validationErrors.map((error, index) => {

View File

@@ -18,7 +18,7 @@ function createQualityProfilesSelector(
includeMixed: boolean
) {
return createSelector(
createSortedSectionSelector(
createSortedSectionSelector<QualityProfile, QualityProfilesAppState>(
'settings.qualityProfiles',
sortByProp<QualityProfile, 'name'>('name')
),

View File

@@ -1,16 +1,14 @@
import classNames from 'classnames';
import React from 'react';
import { align, kinds, sizes } from 'Helpers/Props';
import { kinds, sizes } from 'Helpers/Props';
import { Align } from 'Helpers/Props/align';
import { Kind } from 'Helpers/Props/kinds';
import { Size } from 'Helpers/Props/sizes';
import Link, { LinkProps } from './Link';
import styles from './Button.css';
export interface ButtonProps extends Omit<LinkProps, 'children' | 'size'> {
buttonGroupPosition?: Extract<
(typeof align.all)[number],
keyof typeof styles
>;
buttonGroupPosition?: Extract<Align, keyof typeof styles>;
kind?: Extract<Kind, keyof typeof styles>;
size?: Extract<Size, keyof typeof styles>;
children: Required<LinkProps['children']>;

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { Error } from 'App/State/AppSectionState';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { kinds } from 'Helpers/Props';
@@ -6,7 +7,7 @@ import { kinds } from 'Helpers/Props';
interface PageSectionContentProps {
isFetching: boolean;
isPopulated: boolean;
error?: object;
error?: Error;
errorMessage: string;
children: React.ReactNode;
}
@@ -18,7 +19,7 @@ function PageSectionContent({
errorMessage,
children,
}: PageSectionContentProps) {
if (isFetching) {
if (isFetching && !isPopulated) {
return <LoadingIndicator />;
}

View File

@@ -16,10 +16,10 @@ const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth);
const SEPARATOR_MARGIN = parseInt(dimensions.toolbarSeparatorMargin);
const SEPARATOR_WIDTH = 2 * SEPARATOR_MARGIN + 1;
interface PageToolbarSectionProps {
export interface PageToolbarSectionProps {
children?:
| (ReactElement<PageToolbarButtonProps> | ReactElement<never>)
| (ReactElement<PageToolbarButtonProps> | ReactElement<never>)[];
| (ReactElement<PageToolbarButtonProps> | ReactElement<never> | null)
| (ReactElement<PageToolbarButtonProps> | ReactElement<never> | null)[];
alignContent?: Extract<Align, keyof typeof styles>;
collapseButtons?: boolean;
}
@@ -80,8 +80,12 @@ function PageToolbarSection({
if (buttonCount - 1 === maxButtons) {
const overflowItems: PageToolbarButtonProps[] = [];
const buttonsWithoutSeparators = validChildren.filter(
(child) => Object.keys(child.props).length > 0
);
return {
buttons: validChildren,
buttons: buttonsWithoutSeparators,
buttonCount,
overflowItems,
};

View File

@@ -47,6 +47,15 @@ function DiscoverMovieSortMenu(props) {
{translate('Studio')}
</SortMenuItem>
<SortMenuItem
name="year"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('Year')}
</SortMenuItem>
<SortMenuItem
name="inCinemas"
sortKey={sortKey}

View File

@@ -36,7 +36,8 @@
.imdbRating,
.rottenTomatoesRating,
.traktRating,
.runtime {
.runtime,
.year {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 90px;

View File

@@ -22,6 +22,7 @@ interface CssExports {
'studio': string;
'tmdbRating': string;
'traktRating': string;
'year': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -61,7 +61,8 @@
.imdbRating,
.rottenTomatoesRating,
.traktRating,
.runtime {
.runtime,
.year {
composes: cell;
flex: 0 0 90px;

View File

@@ -28,6 +28,7 @@ interface CssExports {
'studio': string;
'tmdbRating': string;
'traktRating': string;
'year': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -167,6 +167,14 @@ class DiscoverMovieRow extends Component {
);
}
if (name === 'year') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{year}
</VirtualTableRowCell>
);
}
if (name === 'collection') {
return (
<VirtualTableRowCell

View File

@@ -0,0 +1,8 @@
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
function useIsWindows() {
return useSelector((state: AppState) => state.system.status.item.isWindows);
}
export default useIsWindows;

View File

@@ -0,0 +1,8 @@
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
function useShowAdvancedSettings() {
return useSelector((state: AppState) => state.settings.advancedSettings);
}
export default useShowAdvancedSettings;

View File

@@ -1,149 +0,0 @@
// https://github.com/react-bootstrap/react-element-children
import React from 'react';
/**
* Iterates through children that are typically specified as `props.children`,
* but only maps over children that are "valid components".
*
* The mapFunction provided index will be normalised to the components mapped,
* so an invalid component would not increase the index.
*
* @param {?*} children Children tree container.
* @param {function(*, int)} func.
* @param {*} context Context for func.
* @return {object} Object containing the ordered map of results.
*/
export function map(children, func, context) {
let index = 0;
return React.Children.map(children, (child) => {
if (!React.isValidElement(child)) {
return child;
}
return func.call(context, child, index++);
});
}
/**
* Iterates through children that are "valid components".
*
* The provided forEachFunc(child, index) will be called for each
* leaf child with the index reflecting the position relative to "valid components".
*
* @param {?*} children Children tree container.
* @param {function(*, int)} func.
* @param {*} context Context for context.
*/
export function forEach(children, func, context) {
let index = 0;
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) {
return;
}
func.call(context, child, index++);
});
}
/**
* Count the number of "valid components" in the Children container.
*
* @param {?*} children Children tree container.
* @returns {number}
*/
export function count(children) {
let result = 0;
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) {
return;
}
++result;
});
return result;
}
/**
* Finds children that are typically specified as `props.children`,
* but only iterates over children that are "valid components".
*
* The provided forEachFunc(child, index) will be called for each
* leaf child with the index reflecting the position relative to "valid components".
*
* @param {?*} children Children tree container.
* @param {function(*, int)} func.
* @param {*} context Context for func.
* @returns {array} of children that meet the func return statement
*/
export function filter(children, func, context) {
const result = [];
forEach(children, (child, index) => {
if (func.call(context, child, index)) {
result.push(child);
}
});
return result;
}
export function find(children, func, context) {
let result = null;
forEach(children, (child, index) => {
if (result) {
return;
}
if (func.call(context, child, index)) {
result = child;
}
});
return result;
}
export function every(children, func, context) {
let result = true;
forEach(children, (child, index) => {
if (!result) {
return;
}
if (!func.call(context, child, index)) {
result = false;
}
});
return result;
}
export function some(children, func, context) {
let result = false;
forEach(children, (child, index) => {
if (result) {
return;
}
if (func.call(context, child, index)) {
result = true;
}
});
return result;
}
export function toArray(children) {
const result = [];
forEach(children, (child) => {
result.push(child);
});
return result;
}

View File

@@ -1,3 +0,0 @@
export default function getDisplayName(Component) {
return Component.displayName || Component.name || 'Component';
}

View File

@@ -10,7 +10,7 @@ import Popover from 'Components/Tooltip/Popover';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons, kinds, sizes } from 'Helpers/Props';
import MovieHeadshot from 'Movie/MovieHeadshot';
import EditImportListModalConnector from 'Settings/ImportLists/ImportLists/EditImportListModalConnector';
import EditImportListModal from 'Settings/ImportLists/ImportLists/EditImportListModal';
import { deleteImportList } from 'Store/Actions/Settings/importLists';
import ImportList from 'typings/ImportList';
import MovieCredit from 'typings/MovieCredit';
@@ -154,7 +154,7 @@ function MovieCastPoster(props: MovieCastPosterProps) {
{character}
</div>
<EditImportListModalConnector
<EditImportListModal
id={importListId}
isOpen={isEditImportListModalOpen}
onModalClose={setEditImportListModalClosed}

View File

@@ -10,7 +10,7 @@ import Popover from 'Components/Tooltip/Popover';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons, kinds, sizes } from 'Helpers/Props';
import MovieHeadshot from 'Movie/MovieHeadshot';
import EditImportListModalConnector from 'Settings/ImportLists/ImportLists/EditImportListModalConnector';
import EditImportListModal from 'Settings/ImportLists/ImportLists/EditImportListModal';
import { deleteImportList } from 'Store/Actions/Settings/importLists';
import ImportList from 'typings/ImportList';
import MovieCredit from 'typings/MovieCredit';
@@ -152,7 +152,7 @@ function MovieCrewPoster(props: MovieCrewPosterProps) {
</div>
<div className={classNames(styles.title, 'swiper-no-swiping')}>{job}</div>
<EditImportListModalConnector
<EditImportListModal
id={importListId}
isOpen={isEditImportListModalOpen}
onModalClose={setEditImportListModalClosed}

View File

@@ -1,5 +1,6 @@
import React, { useCallback, useState } from 'react';
import React, { SyntheticEvent, useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelect } from 'App/SelectContext';
import { MOVIE_SEARCH, REFRESH_MOVIE } from 'Commands/commandNames';
import Icon from 'Components/Icon';
import ImdbRating from 'Components/ImdbRating';
@@ -141,8 +142,31 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
setIsDeleteMovieModalOpen(false);
}, [setIsDeleteMovieModalOpen]);
const [selectState, selectDispatch] = useSelect();
const onSelectPress = useCallback(
(event: SyntheticEvent<HTMLElement, MouseEvent>) => {
if (event.nativeEvent.ctrlKey || event.nativeEvent.metaKey) {
window.open(`/movie/${tmdbId}`, '_blank');
return;
}
const shiftKey = event.nativeEvent.shiftKey;
selectDispatch({
type: 'toggleSelected',
id: movieId,
isSelected: !selectState.selectedState[movieId],
shiftKey,
});
},
[movieId, selectState.selectedState, selectDispatch, tmdbId]
);
const link = `/movie/${tmdbId}`;
const linkProps = isSelectMode ? { onPress: onSelectPress } : { to: link };
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`,
@@ -196,7 +220,7 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
<div className={styles.deleted} title={translate('Deleted')} />
) : null}
<Link className={styles.link} style={elementStyle} to={link}>
<Link className={styles.link} style={elementStyle} {...linkProps}>
<MoviePoster
style={elementStyle}
images={images}

View File

@@ -140,37 +140,37 @@ function MovieIndexPosterOptionsModalContent(
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('ShowCinemaRelease')}</FormLabel>
<FormLabel>{translate('ShowCinemaReleaseDate')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showCinemaRelease"
value={showCinemaRelease}
helpText={translate('ShowCinemaReleaseHelpText')}
helpText={translate('ShowCinemaReleaseDatePosterHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('ShowDigitalRelease')}</FormLabel>
<FormLabel>{translate('ShowDigitalReleaseDate')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showDigitalRelease"
value={showDigitalRelease}
helpText={translate('ShowDigitalReleaseHelpText')}
helpText={translate('ShowDigitalReleaseDatePosterHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('ShowPhysicalRelease')}</FormLabel>
<FormLabel>{translate('ShowPhysicalReleaseDate')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showPhysicalRelease"
value={showPhysicalRelease}
helpText={translate('ShowPhysicalReleaseHelpText')}
helpText={translate('ShowPhysicalReleaseDatePosterHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
@@ -182,7 +182,7 @@ function MovieIndexPosterOptionsModalContent(
type={inputTypes.CHECK}
name="showReleaseDate"
value={showReleaseDate}
helpText={translate('ShowReleaseDateHelpText')}
helpText={translate('ShowReleaseDatePosterHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>

View File

@@ -43,7 +43,8 @@
.physicalRelease,
.digitalRelease,
.releaseDate,
.genres {
.genres,
.keywords {
composes: cell;
flex: 0 0 180px;

View File

@@ -12,6 +12,7 @@ interface CssExports {
'genres': string;
'imdbRating': string;
'inCinemas': string;
'keywords': string;
'minimumAvailability': string;
'movieStatus': string;
'originalLanguage': string;

View File

@@ -72,6 +72,7 @@ function MovieIndexRow(props: MovieIndexRowProps) {
minimumAvailability,
path,
genres = [],
keywords = [],
ratings,
popularity,
certification,
@@ -339,6 +340,20 @@ function MovieIndexRow(props: MovieIndexRowProps) {
);
}
if (name === 'keywords') {
const joinedKeywords = keywords.join(', ');
const truncatedKeywords =
keywords.length > 3
? `${keywords.slice(0, 3).join(', ')}...`
: joinedKeywords;
return (
<VirtualTableRowCell key={name} className={styles[name]}>
<span title={joinedKeywords}>{truncatedKeywords}</span>
</VirtualTableRowCell>
);
}
if (name === 'movieStatus') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>

View File

@@ -36,7 +36,8 @@
.physicalRelease,
.digitalRelease,
.releaseDate,
.genres {
.genres,
.keywords {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 180px;

View File

@@ -9,6 +9,7 @@ interface CssExports {
'genres': string;
'imdbRating': string;
'inCinemas': string;
'keywords': string;
'minimumAvailability': string;
'movieStatus': string;
'originalLanguage': string;

View File

@@ -82,12 +82,14 @@ interface Movie extends ModelBase {
minimumAvailability: MovieAvailability;
path: string;
genres: string[];
keywords: string[];
ratings: Ratings;
popularity: number;
certification: string;
statistics?: Statistics;
tags: number[];
images: Image[];
movieFileId: number;
movieFile?: MovieFile;
hasFile: boolean;
grabbed?: boolean;

View File

@@ -82,9 +82,9 @@ function RootFolderRow(props: RootFolderRowProps) {
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteRootFolder')}
message={translate('DeleteRootFolderMessageText', { path })}
confirmLabel={translate('Delete')}
title={translate('RemoveRootFolder')}
message={translate('RemoveRootFolderMoviesMessageText', { path })}
confirmLabel={translate('Remove')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>

View File

@@ -1,70 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './AdvancedSettingsButton.css';
function AdvancedSettingsButton(props) {
const {
advancedSettings,
onAdvancedSettingsPress,
showLabel
} = props;
return (
<Link
className={styles.button}
title={advancedSettings ? translate('ShownClickToHide') : translate('HiddenClickToShow')}
onPress={onAdvancedSettingsPress}
>
<Icon
name={icons.ADVANCED_SETTINGS}
size={21}
/>
<span
className={classNames(
styles.indicatorContainer,
'fa-layers fa-fw'
)}
>
<Icon
className={styles.indicatorBackground}
name={icons.CIRCLE}
size={16}
/>
<Icon
className={advancedSettings ? styles.enabled : styles.disabled}
name={advancedSettings ? icons.CHECK : icons.CLOSE}
size={10}
/>
</span>
{
showLabel ?
<div className={styles.labelContainer}>
<div className={styles.label}>
{advancedSettings ? translate('HideAdvanced') : translate('ShowAdvanced')}
</div>
</div> :
null
}
</Link>
);
}
AdvancedSettingsButton.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
onAdvancedSettingsPress: PropTypes.func.isRequired,
showLabel: PropTypes.bool.isRequired
};
AdvancedSettingsButton.defaultProps = {
showLabel: true
};
export default AdvancedSettingsButton;

View File

@@ -0,0 +1,67 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import { toggleAdvancedSettings } from 'Store/Actions/settingsActions';
import translate from 'Utilities/String/translate';
import styles from './AdvancedSettingsButton.css';
interface AdvancedSettingsButtonProps {
showLabel: boolean;
}
function AdvancedSettingsButton({ showLabel }: AdvancedSettingsButtonProps) {
const showAdvancedSettings = useSelector(
(state: AppState) => state.settings.advancedSettings
);
const dispatch = useDispatch();
const handlePress = useCallback(() => {
dispatch(toggleAdvancedSettings());
}, [dispatch]);
return (
<Link
className={styles.button}
title={
showAdvancedSettings
? translate('ShownClickToHide')
: translate('HiddenClickToShow')
}
onPress={handlePress}
>
<Icon name={icons.ADVANCED_SETTINGS} size={21} />
<span
className={classNames(styles.indicatorContainer, 'fa-layers fa-fw')}
>
<Icon
className={styles.indicatorBackground}
name={icons.CIRCLE}
size={16}
/>
<Icon
className={showAdvancedSettings ? styles.enabled : styles.disabled}
name={showAdvancedSettings ? icons.CHECK : icons.CLOSE}
size={10}
/>
</span>
{showLabel ? (
<div className={styles.labelContainer}>
<div className={styles.label}>
{showAdvancedSettings
? translate('HideAdvanced')
: translate('ShowAdvanced')}
</div>
</div>
) : null}
</Link>
);
}
export default AdvancedSettingsButton;

View File

@@ -5,7 +5,7 @@ import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import ParseToolbarButton from 'Parse/ParseToolbarButton';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import SettingsToolbar from 'Settings/SettingsToolbar';
import translate from 'Utilities/String/translate';
import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector';
import ManageCustomFormatsToolbarButton from './CustomFormats/Manage/ManageCustomFormatsToolbarButton';
@@ -13,9 +13,7 @@ import ManageCustomFormatsToolbarButton from './CustomFormats/Manage/ManageCusto
function CustomFormatSettingsPage() {
return (
<PageContent title={translate('CustomFormatsSettings')}>
<SettingsToolbarConnector
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<SettingsToolbar
showSave={false}
additionalButtons={
<>

View File

@@ -5,7 +5,7 @@ import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import SettingsToolbar from 'Settings/SettingsToolbar';
import translate from 'Utilities/String/translate';
import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector';
import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal';
@@ -71,7 +71,7 @@ class DownloadClientSettings extends Component {
return (
<PageContent title={translate('DownloadClientSettings')}>
<SettingsToolbarConnector
<SettingsToolbar
isSaving={isSaving}
hasPendingChanges={hasPendingChanges}
additionalButtons={

View File

@@ -38,7 +38,6 @@ class EditDownloadClientModalContent extends Component {
onModalClose,
onSavePress,
onTestPress,
onAdvancedSettingsPress,
onDeleteDownloadClientPress,
...otherProps
} = this.props;
@@ -198,8 +197,6 @@ class EditDownloadClientModalContent extends Component {
}
<AdvancedSettingsButton
advancedSettings={advancedSettings}
onAdvancedSettingsPress={onAdvancedSettingsPress}
showLabel={false}
/>
@@ -243,7 +240,6 @@ EditDownloadClientModalContent.propTypes = {
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onTestPress: PropTypes.func.isRequired,
onAdvancedSettingsPress: PropTypes.func.isRequired,
onDeleteDownloadClientPress: PropTypes.func
};

View File

@@ -6,8 +6,7 @@ import {
saveDownloadClient,
setDownloadClientFieldValue,
setDownloadClientValue,
testDownloadClient,
toggleAdvancedSettings
testDownloadClient
} from 'Store/Actions/settingsActions';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import EditDownloadClientModalContent from './EditDownloadClientModalContent';
@@ -29,8 +28,7 @@ const mapDispatchToProps = {
setDownloadClientValue,
setDownloadClientFieldValue,
saveDownloadClient,
testDownloadClient,
toggleAdvancedSettings
testDownloadClient
};
class EditDownloadClientModalContentConnector extends Component {
@@ -63,10 +61,6 @@ class EditDownloadClientModalContentConnector extends Component {
this.props.testDownloadClient({ id: this.props.id });
};
onAdvancedSettingsPress = () => {
this.props.toggleAdvancedSettings();
};
//
// Render
@@ -76,7 +70,6 @@ class EditDownloadClientModalContentConnector extends Component {
{...this.props}
onSavePress={this.onSavePress}
onTestPress={this.onTestPress}
onAdvancedSettingsPress={this.onAdvancedSettingsPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
@@ -94,7 +87,6 @@ EditDownloadClientModalContentConnector.propTypes = {
setDownloadClientFieldValue: PropTypes.func.isRequired,
saveDownloadClient: PropTypes.func.isRequired,
testDownloadClient: PropTypes.func.isRequired,
toggleAdvancedSettings: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};

View File

@@ -8,7 +8,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { kinds } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import SettingsToolbar from 'Settings/SettingsToolbar';
import translate from 'Utilities/String/translate';
import AnalyticSettings from './AnalyticSettings';
import BackupSettings from './BackupSettings';
@@ -113,7 +113,7 @@ class GeneralSettings extends Component {
return (
<PageContent title={translate('GeneralSettings')}>
<SettingsToolbarConnector
<SettingsToolbar
{...otherProps}
/>

View File

@@ -46,7 +46,6 @@ function createImportListExclusionSelector(id?: number) {
const settings = selectSettings(mapping, pendingChanges, saveError);
return {
id,
isFetching,
error,
isSaving,
@@ -171,7 +170,7 @@ function EditImportListExclusionModalContent({
</ModalBody>
<ModalFooter>
{id && (
{id ? (
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
@@ -179,7 +178,7 @@ function EditImportListExclusionModalContent({
>
{translate('Delete')}
</Button>
)}
) : null}
<Button onPress={onModalClose}>{translate('Cancel')}</Button>

View File

@@ -1,123 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component, Fragment } from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import ImportListExclusions from './ImportListExclusions/ImportListExclusions';
import ImportListsConnector from './ImportLists/ImportListsConnector';
import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal';
import ImportListOptions from './Options/ImportListOptions';
class ImportListSettings extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._saveCallback = null;
this.state = {
isSaving: false,
hasPendingChanges: false,
isManageImportListsOpen: false
};
}
//
// Listeners
setChildSave = (saveCallback) => {
this._saveCallback = saveCallback;
};
onChildStateChange = (payload) => {
this.setState(payload);
};
onManageImportListsPress = () => {
this.setState({ isManageImportListsOpen: true });
};
onManageImportListsModalClose = () => {
this.setState({ isManageImportListsOpen: false });
};
onSavePress = () => {
if (this._saveCallback) {
this._saveCallback();
}
};
//
// Render
render() {
const {
isTestingAll,
dispatchTestAllImportLists
} = this.props;
const {
isSaving,
hasPendingChanges,
isManageImportListsOpen
} = this.state;
return (
<PageContent title={translate('ImportListSettings')}>
<SettingsToolbarConnector
isSaving={isSaving}
hasPendingChanges={hasPendingChanges}
additionalButtons={
<Fragment>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('TestAllLists')}
iconName={icons.TEST}
isSpinning={isTestingAll}
onPress={dispatchTestAllImportLists}
/>
<PageToolbarButton
label={translate('ManageLists')}
iconName={icons.MANAGE}
onPress={this.onManageImportListsPress}
/>
</Fragment>
}
onSavePress={this.onSavePress}
/>
<PageContentBody>
<ImportListsConnector />
<ImportListOptions
setChildSave={this.setChildSave}
onChildStateChange={this.onChildStateChange}
/>
<ImportListExclusions />
<ManageImportListsModal
isOpen={isManageImportListsOpen}
onModalClose={this.onManageImportListsModalClose}
/>
</PageContentBody>
</PageContent>
);
}
}
ImportListSettings.propTypes = {
isTestingAll: PropTypes.bool.isRequired,
dispatchTestAllImportLists: PropTypes.func.isRequired
};
export default ImportListSettings;

View File

@@ -0,0 +1,107 @@
import React, { useCallback, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props';
import SettingsToolbar from 'Settings/SettingsToolbar';
import { testAllImportLists } from 'Store/Actions/settingsActions';
import {
SaveCallback,
SettingsStateChange,
} from 'typings/Settings/SettingsState';
import translate from 'Utilities/String/translate';
import ImportListExclusions from './ImportListExclusions/ImportListExclusions';
import ImportLists from './ImportLists/ImportLists';
import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal';
import ImportListOptions from './Options/ImportListOptions';
function ImportListSettings() {
const dispatch = useDispatch();
const isTestingAll = useSelector(
(state: AppState) => state.settings.importLists.isTestingAll
);
const saveOptions = useRef<() => void>();
const [isSaving, setIsSaving] = useState(false);
const [hasPendingChanges, setHasPendingChanges] = useState(false);
const [isManageImportListsModalOpen, setIsManageImportListsModalOpen] =
useState(false);
const handleSetChildSave = useCallback((saveCallback: SaveCallback) => {
saveOptions.current = saveCallback;
}, []);
const handleChildStateChange = useCallback(
({ isSaving, hasPendingChanges }: SettingsStateChange) => {
setIsSaving(isSaving);
setHasPendingChanges(hasPendingChanges);
},
[]
);
const handleManageImportListsPress = useCallback(() => {
setIsManageImportListsModalOpen(true);
}, []);
const handleManageImportListsModalClose = useCallback(() => {
setIsManageImportListsModalOpen(false);
}, []);
const handleSavePress = useCallback(() => {
saveOptions.current?.();
}, []);
const handleTestAllIndexersPress = useCallback(() => {
dispatch(testAllImportLists());
}, [dispatch]);
return (
<PageContent title={translate('ImportListSettings')}>
<SettingsToolbar
isSaving={isSaving}
hasPendingChanges={hasPendingChanges}
additionalButtons={
<>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('TestAllLists')}
iconName={icons.TEST}
isSpinning={isTestingAll}
onPress={handleTestAllIndexersPress}
/>
<PageToolbarButton
label={translate('ManageLists')}
iconName={icons.MANAGE}
onPress={handleManageImportListsPress}
/>
</>
}
onSavePress={handleSavePress}
/>
<PageContentBody>
<ImportLists />
<ImportListOptions
setChildSave={handleSetChildSave}
onChildStateChange={handleChildStateChange}
/>
<ImportListExclusions />
<ManageImportListsModal
isOpen={isManageImportListsModalOpen}
onModalClose={handleManageImportListsModalClose}
/>
</PageContentBody>
</PageContent>
);
}
export default ImportListSettings;

Some files were not shown because too many files have changed in this diff Show More