Compare commits

...

138 commits

Author SHA1 Message Date
Mark McDowall
52972e7efc
Add private IPv6 networks 2025-11-03 07:35:36 -08:00
Mark McDowall
8c50919499
Bump version to 4.0.16 2025-10-28 15:53:41 -07:00
Polgonite
fdc07a47b1
Fixed: qBittorrent /login API success check 2025-10-26 08:31:50 -07:00
Collin Heist
36225c3709
Fixed: Prevent modals from overflowing screen width
Closes #8085
2025-10-26 08:31:50 -07:00
康小广
bc037ae356
Follow redirects when fetching Custom Lists 2025-10-26 08:31:50 -07:00
Mark McDowall
77a335de30
Fixed: Default runtime to 45 minutes if unavailable when importing episode files
Closes #7780
2025-10-26 08:31:50 -07:00
Mark McDowall
88d56361c4
Add XML declaration and clean up Kodi metadata generation
Closes #7753
2025-10-26 08:31:50 -07:00
Mark McDowall
d10107739b
Set known networks to RFC 1918 ranges during startup 2025-10-25 19:18:43 -07:00
Mark McDowall
7db7567c8e
Bump version to 4.0.15 2025-06-09 17:19:54 -07:00
Michael Peleshenko
2b2b973b30
Fixed: Prevent series without IMDB ID from being removed erroneously 2025-06-09 17:19:10 -07:00
Mark McDowall
bb954a7424
Fixed: Trakt Import List authentication after 24 hours
Closes #7874
2025-06-09 17:18:54 -07:00
Mark McDowall
640e3e5d44
Bump version to 4.0.14 2025-03-15 09:43:34 -07:00
Mark McDowall
1260d3c800
Upgrade ImageSharp 2025-03-15 09:29:03 -07:00
v3DJG6GL
feeed9a7cf
New: .arj and .lzh extensions are potentially dangerous 2025-03-15 09:25:40 -07:00
Mark McDowall
c8cb74a976
Fixed: Downloads failed for file contents will be removed from client 2025-03-08 19:59:13 -08:00
Stevie Robinson
7193acb5ee
Fixed: Improve rejected download handling 2025-03-08 19:59:07 -08:00
Stevie Robinson
6f1fc1686f
Fixed: Don't return warning in title field for rejected downloads
Closes #7663
2025-02-22 12:42:35 -08:00
Stevie Robinson
b7407837b7
Fixed: Rejected Imports with no associated release or indexer 2025-02-22 12:40:49 -08:00
Mark McDowall
4e65669c48
Bump version to 4.0.13 2025-02-11 19:25:11 -08:00
Weblate
fa38498db0 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Magnus5405 <magnus5405@outlook.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/da/
Translation: Servarr/Sonarr
2025-02-11 19:24:56 -08:00
Mark McDowall
3b024443c5 Fixed: Drop downs flickering in some cases
Closes #7608
2025-01-30 20:58:11 -08:00
Weblate
4ba9b21bb7 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translation: Servarr/Sonarr
2025-01-30 20:57:59 -08:00
Stevie Robinson
e37684e045
Fixed: Failing dangerous and executable single file downloads 2025-01-25 18:29:07 -08:00
Stevie Robinson
103ccd74f3
New: Treat .scr as dangerous file
Closes #7588
2025-01-25 18:27:32 -08:00
Stevie Robinson
ba22992265
Fixed: Don't search for unmonitored specials when searching season
Closes #7589
2025-01-25 18:26:48 -08:00
Bogdan
963395b969 Prevent page crash on console.error being used with non-string values 2025-01-25 18:26:10 -08:00
Weblate
970df1a1d8 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dani Talens <databio@gmail.com>
Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Georgi Panov <darkfella91@gmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@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/sonarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translation: Servarr/Sonarr
2025-01-25 18:26:02 -08:00
kephasdev
2ac139ab4d Fixed: Augmenting languages for releases with MULTI and other languages
(cherry picked from commit d58135bf1754b6185eef19a2f4069b27a918d01e)
2025-01-17 19:58:22 -08:00
Bogdan
c69db1ff92
New: Parsing titles with AKA separating multiple titles
Closes #7576
2025-01-17 19:57:52 -08:00
Bogdan
6dae2f0d84
Fixed: Images after series are updated via Series Editor 2025-01-17 19:57:13 -08:00
Bogdan
87934c7761 Fix typo in logging for custom format score 2025-01-17 19:55:57 -08:00
Bogdan
fe8478f42a Fix translation key for RSS in History Details 2025-01-17 19:55:57 -08:00
jcassette
a840bb5423
New: reflink support for ZFS 2025-01-17 19:55:37 -08:00
Weblate
8f5d628c55 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Florian Savouré <florian.savoure@gmail.com>
Co-authored-by: Georgi Panov <darkfella91@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: keysuck <joshkkim@gmail.com>
Co-authored-by: warkurre86 <tom.novo.86@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-01-17 19:55:19 -08:00
Mark McDowall
acebe87dba New: Parse releases with year and season number in brackets
Closes #7559
2025-01-10 17:06:40 -08:00
Mark McDowall
7d77500667 Fixed: Series being unmonitored when still in Import List
Closes #7555
2025-01-10 17:06:33 -08:00
Stevie Robinson
ec73a13396
Update translation widget 2025-01-10 17:06:19 -08:00
Stevie Robinson
fa0f77659c
Additional logging for delay profile decisions
Closes #7558
2025-01-10 17:06:05 -08:00
Stevie Robinson
1609f0c964
New: Show release source in history grab popup 2025-01-10 17:05:46 -08:00
Bogdan
1fea0b3d10 Remote image links for Discord's manual interaction needed 2025-01-10 17:05:32 -08:00
Stevie Robinson
3c8268c428
Additional logging for custom format score 2025-01-10 17:05:23 -08:00
Mark McDowall
c589c4f85e Fixed: Tooltips for detailed error messages 2025-01-10 17:05:05 -08:00
Weblate
f843107c25 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: CaveMan474 <Caveman_TheLastOne@proton.me>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Lars <lars.erik.heloe@gmail.com>
Co-authored-by: Mickaël O <mickael.ouillon@ac-bordeaux.fr>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: RabSsS01 <royermatthieu78@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: mryx007 <mryx@mail.de>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-01-10 17:04:56 -08:00
Stevie Robinson
035c474f10
Fixed: Listening on all IPv4 Addresses
Closes #7526
2025-01-04 17:58:24 -08:00
Stevie Robinson
4dcc015fb1
Fixed: qBittorrent Ratio Limit Check
Closes #7527
2025-01-04 17:56:49 -08:00
Mark McDowall
1969e0107f Bump version to 4.0.12 2025-01-04 17:17:20 -08:00
Weblate
ac7c05c050 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ano10 <Ano10@users.noreply.translate.servarr.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Matti Meikäläinen <diefor-93@hotmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2025-01-04 17:17:10 -08:00
Bogdan
8aad79fd3e Check if backup folder is writable on backup 2024-12-30 21:16:30 -08:00
Bogdan
f05e552e8e Suggest adding IP to RPC whitelist for on failed Transmission auth 2024-12-30 21:16:16 -08:00
Mark McDowall
8cd5cd603a Fixed: Improve synchronization logic for import list items
Closes #7511
2024-12-30 21:16:02 -08:00
Weblate
ef358e6f24 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Alexander Balya <alexander.balya@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-12-30 21:15:52 -08:00
Mark McDowall
fae24e98fb Don't send session information to Sentry
Closes #7518
2024-12-28 02:12:33 +01:00
Harry Pollard
c885fb81f9
Fixed: Searching by title not using all titles 2024-12-26 11:40:35 -08:00
Bogdan
514c04935f Fixed: Advanced settings for Metadata consumers 2024-12-26 11:39:59 -08:00
Weblate
4b14368736 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: 1 <1228553526@qq.com>
Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Tommy Au <smarttommyau@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: marapavelka <mara.pavelka@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_TW/
Translation: Servarr/Sonarr
2024-12-26 11:39:50 -08:00
Mark McDowall
1c30ecd66d Fixed: Series updated during Import List Sync not reflected in the UI
Closes #7511
2024-12-22 21:59:12 -08:00
Bogdan
f7b54f9d6b Fixed: Prevent exception for seed configuration provider with invalid indexer ID 2024-12-22 21:59:05 -08:00
Weblate
ce7d8a175e Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Tommy Au <smarttommyau@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_TW/
Translation: Servarr/Sonarr
2024-12-22 21:58:44 -08:00
Bogdan
ab49268bac Bump NLog, IPAddressRange, Polly, ImageSharp, Npgsql, System.Memory, Ical.Net and Lib.Harmony 2024-12-20 16:15:58 -08:00
Bogdan
608f67a074 Bump MailKit to 4.8.0 and Microsoft.Data.SqlClient to 2.1.7 2024-12-20 16:15:58 -08:00
Mark McDowall
9a69222c9a Fixed: Prevent exception when grabbing unparsable release
Closes #7494
2024-12-20 16:15:48 -08:00
Weblate
82c526e15c Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-12-20 16:13:47 -08:00
Stevie Robinson
983b079c82
Fix: Adding a new root folder from edit series modal
Closes #7497
2024-12-20 16:13:30 -08:00
Mark McDowall
edfc12e27a Fixed: Loading calendar on older browsers 2024-12-16 20:57:56 -08:00
Mark McDowall
ed10b63fa0 Upgrade @typescript-eslint packages to 8.181.1 2024-12-16 20:57:48 -08:00
Mark McDowall
016b571838 Upgrade Font Awesome to 6.7.1 2024-12-16 20:57:48 -08:00
Mark McDowall
bfcd017012 Upgrade babel to 7.26.0 2024-12-16 20:57:48 -08:00
Bogdan
2e83d59f61 Set minor version for core-js in babel/preset-env 2024-12-16 20:57:34 -08:00
Bogdan
c39fb4fe6f Fix typo about download clients comment 2024-12-16 20:57:28 -08:00
Bogdan
220b4bc257 Fixed: Opening episode info modal on calendar event click 2024-12-16 20:57:28 -08:00
Weblate
99e25cec0f Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-12-16 20:57:20 -08:00
Bogdan
5d1d44e09e New: Series genres for search results 2024-12-14 18:51:58 -08:00
Bogdan
3b00112447 Fixed: Refresh backup list on deletion 2024-12-14 18:51:49 -08:00
Mark McDowall
cb7489ce8f Fixed: Augmenting languages from indexer for release with stale indexer ID
Closes #7476
2024-12-14 18:51:40 -08:00
Mark McDowall
b552d4e9f7 Fixed: Error getting processes in some cases
Closes #7470
2024-12-14 18:51:31 -08:00
Mark McDowall
c0e264cfc5 Fixed: Series without tags bypassing tags on Download Client
Closes #7474
2024-12-14 18:51:19 -08:00
Mark McDowall
811eb36c7b Convert Calendar to TypeScript 2024-12-14 18:51:10 -08:00
Mark McDowall
1484809099 Upgrade TypeScript and core-js 2024-12-14 18:51:10 -08:00
Bogdan
024462c52d Fixed: Fetching ICS calendar with missing series 2024-12-14 18:51:04 -08:00
Weblate
e70aef9690 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: Tomer Horowitz <tomerh2001@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: hhjuhl <hans@kopula.dk>
Co-authored-by: kaisernet <afimark7@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-12-14 18:50:57 -08:00
Stevie Robinson
36633b5d08
New: Optionally as Instance Name to Telegram notifications
Closes #7391
2024-12-08 19:37:51 -08:00
Mark McDowall
1374240321 Fixed: Converting TimeSpan from database
Closes #7461
Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>
2024-12-08 19:36:58 -08:00
Mark McDowall
f1d54d2a9a Convert EpisodeHistory to TypeScript 2024-12-08 19:36:51 -08:00
Mark McDowall
03b8c4c28e Convert EpisodeSearch to TypeScript 2024-12-08 19:36:51 -08:00
Mark McDowall
4e4bf3507f Convert MediaInfo to TypeScript 2024-12-08 19:36:51 -08:00
Stevie Robinson
34ae65c087
Refine localization string for IndexerSettingsFailDownloadsHelpText 2024-12-08 19:36:42 -08:00
Mark McDowall
ebe23104d4 Fixed: Custom Format score bypassing upgrades not being allowed 2024-12-08 19:36:23 -08:00
Stevie Robinson
e8c3aa20bd
New: Reactive search button on Wanted pages
Closes #7449
2024-12-08 19:36:10 -08:00
Bogdan
6c231cbe6a Increase input sizes in edit series modal 2024-12-08 19:35:41 -08:00
Bogdan
8ce688186e Cleanup unused metadatas connector 2024-12-08 19:35:41 -08:00
Weblate
04ebf03fb5 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Michaa85 <michael.seipel@gmx.de>
Co-authored-by: Rodion <rodyon009@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: farebyting <farelbyting@gmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: keysuck <joshkkim@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/uk/
Translation: Servarr/Sonarr
2024-12-08 19:35:31 -08:00
Weblate
c38debab1b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ardenet <1213193613@qq.com>
Co-authored-by: mryx007 <mryx@mail.de>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-12-01 16:24:11 -08:00
Sonarr
32f66922e7 Automated API Docs update
ignore-downstream
2024-12-01 16:23:18 -08:00
Gylesie
ed536a85ad
Remove unnecessary heap allocations in local IP check 2024-12-01 16:22:04 -08:00
Mark McDowall
c62fc9d05b
New: Kometa metadata file creation disabled
Closes #7400
2024-12-01 16:20:55 -08:00
Mark McDowall
fb9a5efe05 Add return type for series/lookup endpoint
Closes #7438
2024-12-01 16:20:19 -08:00
Mark McDowall
8cb58a63d8 Fixed: Don't fail import if symlink target can't be resolved
Closes #7431
2024-12-01 16:20:19 -08:00
soup
4c41a4f368
New: Add config file setting for CGNAT authentication bypass 2024-12-01 16:20:08 -08:00
Stevie Robinson
e039dc45e2
New: Add Languages to Webhook Notifications
Closes #7421
2024-12-01 16:16:36 -08:00
Mark McDowall
776143cc81 New: Option to treat downloads with non-media extensions as failed
Closes #7369
2024-12-01 16:15:52 -08:00
Mark McDowall
8c67a3bdee Add reason enum to decision engine rejections 2024-12-01 16:15:52 -08:00
hhjuhl
160151c6e0
Use 'text-wrap: balance' for text wrapping on overview and details
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2024-12-01 16:15:33 -08:00
Robin Dadswell
efd48710e4 Deleted translation using Weblate (zh_HANS (generated) (zh_HANS)) 2024-12-01 16:14:37 -08:00
Weblate
00c16cd06b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Albrt9527 <2563009889@qq.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Languages add-on <noreply-addon-languages@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: mryx007 <mryx@mail.de>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-12-01 16:14:37 -08:00
Sonarr
65d07fa99e Automated API Docs update
ignore-downstream
2024-11-27 17:32:51 -08:00
Bogdan
bd656ae7f6 Fixed: Avoid default category on existing Transmission configurations
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2024-11-27 17:30:03 -08:00
Mark McDowall
62bcf397dd Fixed: Adding/Editing not replacing Implementation Name 2024-11-27 17:29:24 -08:00
Mark McDowall
f9606518ee Fixed: Error loading queue
Closes #7422
2024-11-27 17:29:24 -08:00
Mark McDowall
40f4ef27b2 Support Postgres with non-standard version string 2024-11-26 17:37:37 -08:00
Mark McDowall
93c3f6d1d6 Fixed: Truncating long text in the middle when it shouldn't be truncated
Closes #7413
2024-11-26 17:37:30 -08:00
Mark McDowall
417af2b915 New: Ability to change root folder when editing series
Closes #5544
2024-11-26 17:37:21 -08:00
Mark McDowall
4491df3ae7 Update React and add React Query 2024-11-26 17:37:21 -08:00
Mark McDowall
a90866a73e Webpack web target 2024-11-26 17:37:21 -08:00
Mark McDowall
2f62494adc Convert EditSeriesModal to TypeScript 2024-11-26 17:37:21 -08:00
Mark McDowall
e361f18837 New: Support for new SABnzbd history retention values
Closes #7373
2024-11-26 17:37:06 -08:00
Mark McDowall
183b8b574a
Deluge communication improvements
Closes #7318
2024-11-26 17:36:53 -08:00
Mark McDowall
12c1eb86f2 Fixed: New episodes in season follow season's monitored status
Closes #7401
2024-11-26 17:36:26 -08:00
Mark McDowall
5034d83062 Fixed: Kometa and Kodi metadata failing with duplicate episode files
Closes #7381
2024-11-26 17:36:10 -08:00
Mark McDowall
dba3a82439 Fixed: Prevent lack of internet from stopping all health checks from running 2024-11-26 17:36:00 -08:00
Mark McDowall
b51a490979 Rename SizeLeft and TimeLeft queue item properties
Closes #7392
2024-11-26 17:36:00 -08:00
Mark McDowall
8b38ccfb63 Bump version to 4.0.11 2024-11-26 17:11:56 -08:00
Mark McDowall
91c5e6f122 Fixed: Custom Format upgrading not respecting 'Upgrades Allowed' 2024-11-26 17:09:12 -08:00
Weblate
dcbef6b7b7 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: 4kwins <hanszimmerme@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Mizuyoru_TW <mizuyoru.tw@gmail.com>
Co-authored-by: Stanislav <stasstrochewskij@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_TW/
Translation: Servarr/Sonarr
2024-11-26 17:09:02 -08:00
Elias Benbourenane
ca0bb14027
Allow GetFileSize to follow symlinks 2024-11-14 19:27:56 -08:00
Mark McDowall
3e99917e9d Fixed: Closing on click outside select input and styling on Library Import 2024-11-14 19:27:31 -08:00
Mark McDowall
936cf699ff Improve LanguageSelectInput 2024-11-14 19:27:31 -08:00
Mark McDowall
202190d032 New: Replace 'Ben the Man' release group parsing with 'Ben the Men'
Closes #7365
2024-11-14 19:02:09 -08:00
Mark McDowall
f739fd0900
Fixed: Allow files to be moved from Torrent Blackhole even when remove is disabled 2024-11-14 19:01:38 -08:00
Mark McDowall
88f4016fe0 New: Parse original from release name when specified
Closes #5805
2024-11-14 19:01:17 -08:00
Gauthier
78fb20282d
New: Add headers setting in webhook connection 2024-11-14 19:01:05 -08:00
Mark McDowall
6677fd1116 New: Improve stored UI settings for multiple instances under the same host
Closes #7368
2024-11-14 19:00:21 -08:00
Mark McDowall
e28b7c3df6 Fixed: .plexmatch episodes on separate lines
Closes #7362
2024-11-14 19:00:10 -08:00
Bogdan
67a1ecb0fe Console warnings for missing translations on development builds 2024-11-14 18:59:53 -08:00
Mark McDowall
5bc943583c Don't try to process items that didn't import in manual import 2024-11-14 18:59:43 -08:00
Mark McDowall
ceeec091f8 Fixed: Normalize unicode characters when comparing paths for equality
Closes #6657
2024-11-14 18:59:43 -08:00
Bogdan
675e3cd38a
New: Labels support for Transmission 4.0
Closes #7300
2024-11-14 18:59:25 -08:00
Weblate
45a62a2e59 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ardenet <1213193613@qq.com>
Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-11-14 18:58:37 -08:00
Sonarr
ae7c07e02f Automated API Docs update
ignore-downstream
2024-11-07 22:42:05 -08:00
436 changed files with 22011 additions and 8436 deletions

View file

@ -22,7 +22,7 @@ env:
FRAMEWORK: net6.0
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
SONARR_MAJOR_VERSION: 4
VERSION: 4.0.10
VERSION: 4.0.16
jobs:
backend:

View file

@ -1,6 +1,6 @@
# <img width="24px" src="./Logo/256.png" alt="Sonarr"></img> Sonarr
[![Translated](https://translate.servarr.com/widgets/servarr/-/sonarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/)
[![Translated](https://translate.servarr.com/widget/servarr/sonarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/)
[![Backers on Open Collective](https://opencollective.com/Sonarr/backers/badge.svg)](#backers)
[![Sponsors on Open Collective](https://opencollective.com/Sonarr/sponsors/badge.svg)](#sponsors)
[![Mega Sponsors on Open Collective](https://opencollective.com/Sonarr/megasponsors/badge.svg)](#mega-sponsors)

View file

@ -363,7 +363,11 @@ module.exports = {
{
args: 'after-used',
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true
}
],
'@typescript-eslint/explicit-function-return-type': 'off',

View file

@ -26,6 +26,7 @@ module.exports = (env) => {
const config = {
mode: isProduction ? 'production' : 'development',
devtool: isProduction ? 'source-map' : 'eval-source-map',
target: 'web',
stats: {
children: false
@ -51,8 +52,7 @@ module.exports = (env) => {
'node_modules'
],
alias: {
jquery: 'jquery/dist/jquery.min',
'react-middle-truncate': 'react-middle-truncate/lib/react-middle-truncate'
jquery: 'jquery/dist/jquery.min'
},
fallback: {
buffer: false,
@ -187,7 +187,7 @@ module.exports = (env) => {
loose: true,
debug: false,
useBuiltIns: 'entry',
corejs: 3
corejs: '3.39'
}
]
]

View file

@ -41,6 +41,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
indexer,
releaseGroup,
seriesMatchType,
releaseSource,
customFormatScore,
nzbInfoUrl,
downloadClient,
@ -53,6 +54,31 @@ function HistoryDetails(props: HistoryDetailsProps) {
const downloadClientNameInfo = downloadClientName ?? downloadClient;
let releaseSourceMessage = '';
switch (releaseSource) {
case 'Unknown':
releaseSourceMessage = translate('Unknown');
break;
case 'Rss':
releaseSourceMessage = translate('Rss');
break;
case 'Search':
releaseSourceMessage = translate('Search');
break;
case 'UserInvokedSearch':
releaseSourceMessage = translate('UserInvokedSearch');
break;
case 'InteractiveSearch':
releaseSourceMessage = translate('InteractiveSearch');
break;
case 'ReleasePush':
releaseSourceMessage = translate('ReleasePush');
break;
default:
releaseSourceMessage = '';
}
return (
<DescriptionList>
<DescriptionListItem
@ -88,6 +114,14 @@ function HistoryDetails(props: HistoryDetailsProps) {
/>
) : null}
{releaseSource ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ReleaseSource')}
data={releaseSourceMessage}
/>
) : null}
{nzbInfoUrl ? (
<span>
<DescriptionListItemTitle>

View file

@ -70,10 +70,15 @@
}
.originalLanguageName,
.network {
.network,
.genres {
margin-left: 8px;
}
.genres {
pointer-events: all;
}
.tvdbLink {
composes: link from '~Components/Link/Link.css';

View file

@ -3,6 +3,7 @@
interface CssExports {
'alreadyExistsIcon': string;
'content': string;
'genres': string;
'icons': string;
'network': string;
'originalLanguageName': string;

View file

@ -6,6 +6,7 @@ import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import MetadataAttribution from 'Components/MetadataAttribution';
import { icons, kinds, sizes } from 'Helpers/Props';
import SeriesGenres from 'Series/SeriesGenres';
import SeriesPoster from 'Series/SeriesPoster';
import translate from 'Utilities/String/translate';
import AddNewSeriesModal from './AddNewSeriesModal';
@ -56,6 +57,7 @@ class AddNewSeriesSearchResult extends Component {
year,
network,
originalLanguage,
genres,
status,
overview,
statistics,
@ -181,6 +183,18 @@ class AddNewSeriesSearchResult extends Component {
null
}
{
genres.length > 0 ?
<Label size={sizes.LARGE}>
<Icon
name={icons.GENRE}
size={13}
/>
<SeriesGenres className={styles.genres} genres={genres} />
</Label> :
null
}
{
seasonCount ?
<Label size={sizes.LARGE}>
@ -243,6 +257,7 @@ AddNewSeriesSearchResult.propTypes = {
year: PropTypes.number.isRequired,
network: PropTypes.string,
originalLanguage: PropTypes.object,
genres: PropTypes.arrayOf(PropTypes.string),
status: PropTypes.string.isRequired,
overview: PropTypes.string,
statistics: PropTypes.object.isRequired,
@ -254,4 +269,8 @@ AddNewSeriesSearchResult.propTypes = {
isSmallScreen: PropTypes.bool.isRequired
};
AddNewSeriesSearchResult.defaultProps = {
genres: []
};
export default AddNewSeriesSearchResult;

View file

@ -1,18 +1,10 @@
.inputContainer {
margin-right: 20px;
min-width: 150px;
div {
margin-top: 10px;
&:first-child {
margin-top: 0;
}
}
}
.label {
margin-bottom: 3px;
margin-bottom: 10px;
font-weight: bold;
}

View file

@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
import React from 'react';
import DocumentTitle from 'react-document-title';
@ -12,17 +13,21 @@ interface AppProps {
history: ConnectedRouterProps['history'];
}
const queryClient = new QueryClient();
function App({ store, history }: AppProps) {
return (
<DocumentTitle title={window.Sonarr.instanceName}>
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme />
<PageConnector>
<AppRoutes />
</PageConnector>
</ConnectedRouter>
</Provider>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme />
<PageConnector>
<AppRoutes />
</PageConnector>
</ConnectedRouter>
</Provider>
</QueryClientProvider>
</DocumentTitle>
);
}

View file

@ -5,7 +5,7 @@ import History from 'Activity/History/History';
import Queue from 'Activity/Queue/Queue';
import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
import CalendarPage from 'Calendar/CalendarPage';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
@ -72,7 +72,7 @@ function AppRoutes() {
Calendar
*/}
<Route path="/calendar" component={CalendarPageConnector} />
<Route path="/calendar" component={CalendarPage} />
{/*
Activity

View file

@ -1,11 +1,16 @@
import Column from 'Components/Table/Column';
import { SortDirection } from 'Helpers/Props/sortDirections';
import { ValidationFailure } from 'typings/pending';
import { FilterBuilderProp, PropertyFilter } from './AppState';
export interface Error {
responseJSON: {
message: string;
};
status?: number;
responseJSON:
| {
message: string | undefined;
}
| ValidationFailure[]
| undefined;
}
export interface AppSectionDeleteState {
@ -58,6 +63,16 @@ export interface AppSectionItemState<T> {
item: T;
}
export interface AppSectionProviderState<T>
extends AppSectionDeleteState,
AppSectionSaveState {
isFetching: boolean;
isPopulated: boolean;
error: Error;
items: T[];
pendingChanges: Partial<T>;
}
interface AppSectionState<T> {
isFetching: boolean;
isPopulated: boolean;

View file

@ -54,10 +54,12 @@ export interface CustomFilter {
export interface AppSectionState {
isConnected: boolean;
isReconnecting: boolean;
isSidebarVisible: boolean;
version: string;
prevVersion?: string;
dimensions: {
isSmallScreen: boolean;
isLargeScreen: boolean;
width: number;
height: number;
};
@ -70,6 +72,7 @@ interface AppState {
captcha: CaptchaAppState;
commands: CommandAppState;
episodeFiles: EpisodeFilesAppState;
episodeHistory: HistoryAppState;
episodes: EpisodesAppState;
episodesSelection: EpisodesAppState;
history: HistoryAppState;

View file

@ -1,10 +1,29 @@
import moment from 'moment';
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
import Episode from 'Episode/Episode';
import { CalendarView } from 'Calendar/calendarViews';
import { CalendarItem } from 'typings/Calendar';
interface CalendarOptions {
showEpisodeInformation: boolean;
showFinaleIcon: boolean;
showSpecialIcon: boolean;
showCutoffUnmetIcon: boolean;
collapseMultipleEpisodes: boolean;
fullColorEvents: boolean;
}
interface CalendarAppState
extends AppSectionState<Episode>,
AppSectionFilterState<Episode> {}
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

@ -0,0 +1,6 @@
import { AppSectionProviderState } from 'App/State/AppSectionState';
import Metadata from 'typings/Metadata';
type MetadataAppState = AppSectionProviderState<Metadata>;
export default MetadataAppState;

View file

@ -59,6 +59,8 @@ interface SeriesAppState
deleteOptions: {
addImportListExclusion: boolean;
};
pendingChanges: Partial<Series>;
}
export default SeriesAppState;

View file

@ -20,6 +20,7 @@ import NamingConfig from 'typings/Settings/NamingConfig';
import NamingExample from 'typings/Settings/NamingExample';
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import UiSettings from 'typings/Settings/UiSettings';
import MetadataAppState from './MetadataAppState';
export interface DownloadClientAppState
extends AppSectionState<DownloadClient>,
@ -36,8 +37,7 @@ export interface NamingAppState
extends AppSectionItemState<NamingConfig>,
AppSectionSaveState {}
export interface NamingExamplesAppState
extends AppSectionItemState<NamingExample> {}
export type NamingExamplesAppState = AppSectionItemState<NamingExample>;
export interface ImportListAppState
extends AppSectionState<ImportList>,
@ -97,6 +97,7 @@ interface SettingsAppState {
indexerFlags: IndexerFlagSettingsAppState;
indexers: IndexerAppState;
languages: LanguageSettingsAppState;
metadata: MetadataAppState;
naming: NamingAppState;
namingExamples: NamingExamplesAppState;
notifications: NotificationAppState;

View file

@ -1,9 +1,9 @@
import AppSectionState from 'App/State/AppSectionState';
import Episode from 'Episode/Episode';
interface WantedCutoffUnmetAppState extends AppSectionState<Episode> {}
type WantedCutoffUnmetAppState = AppSectionState<Episode>;
interface WantedMissingAppState extends AppSectionState<Episode> {}
type WantedMissingAppState = AppSectionState<Episode>;
interface WantedAppState {
cutoffUnmet: WantedCutoffUnmetAppState;

View file

@ -1,38 +0,0 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React from 'react';
import AgendaEventConnector from './AgendaEventConnector';
import styles from './Agenda.css';
function Agenda(props) {
const {
items
} = props;
return (
<div className={styles.agenda}>
{
items.map((item, index) => {
const momentDate = moment(item.airDateUtc);
const showDate = index === 0 ||
!moment(items[index - 1].airDateUtc).isSame(momentDate, 'day');
return (
<AgendaEventConnector
key={item.id}
episodeId={item.id}
showDate={showDate}
{...item}
/>
);
})
}
</div>
);
}
Agenda.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default Agenda;

View file

@ -0,0 +1,25 @@
import moment from 'moment';
import React from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import AgendaEvent from './AgendaEvent';
import styles from './Agenda.css';
function Agenda() {
const { items } = useSelector((state: AppState) => state.calendar);
return (
<div className={styles.agenda}>
{items.map((item, index) => {
const momentDate = moment(item.airDateUtc);
const showDate =
index === 0 ||
!moment(items[index - 1].airDateUtc).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

@ -1,254 +0,0 @@
import classNames from 'classnames';
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import episodeEntities from 'Episode/episodeEntities';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { icons, kinds } from 'Helpers/Props';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
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 {
id,
series,
episodeFile,
title,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
airDateUtc,
monitored,
unverifiedSceneNumbering,
finaleType,
hasFile,
grabbed,
queueItem,
showDate,
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
timeFormat,
longDateFormat,
colorImpairedMode
} = this.props;
const startTime = moment(airDateUtc);
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
const downloading = !!(queueItem || grabbed);
const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored);
const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
return (
<div className={styles.event}>
<Link
className={styles.underlay}
onPress={this.onPress}
/>
<div className={styles.overlay}>
<div className={styles.date}>
{
showDate &&
startTime.format(longDateFormat)
}
</div>
<div
className={classNames(
styles.eventWrapper,
styles[statusStyle],
colorImpairedMode && 'colorImpaired'
)}
>
<div className={styles.time}>
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
</div>
<div className={styles.seriesTitle}>
{series.title}
</div>
{
showEpisodeInformation &&
<div className={styles.seasonEpisodeNumber}>
{seasonNumber}x{padNumber(episodeNumber, 2)}
{
series.seriesType === 'anime' && absoluteEpisodeNumber &&
<span className={styles.absoluteEpisodeNumber}>({absoluteEpisodeNumber})</span>
}
<div className={styles.episodeSeparator}> - </div>
</div>
}
<div className={styles.episodeTitle}>
{
showEpisodeInformation &&
title
}
</div>
{
missingAbsoluteNumber &&
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/>
}
{
unverifiedSceneNumbering && !missingAbsoluteNumber ?
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('SceneNumberNotVerified')}
/> :
null
}
{
!!queueItem &&
<span className={styles.statusIcon}>
<CalendarEventQueueDetails
seriesType={series.seriesType}
seasonNumber={seasonNumber}
absoluteEpisodeNumber={absoluteEpisodeNumber}
{...queueItem}
/>
</span>
}
{
!queueItem && grabbed &&
<Icon
className={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('EpisodeIsDownloading')}
/>
}
{
showCutoffUnmetIcon &&
!!episodeFile &&
episodeFile.qualityCutoffNotMet &&
<Icon
className={styles.statusIcon}
name={icons.EPISODE_FILE}
kind={kinds.WARNING}
title={translate('QualityCutoffNotMet')}
/>
}
{
episodeNumber === 1 && seasonNumber > 0 &&
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.INFO}
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
/>
}
{
showFinaleIcon &&
finaleType ?
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.WARNING}
title={getFinaleTypeName(finaleType)}
/> :
null
}
{
showSpecialIcon &&
(episodeNumber === 0 || seasonNumber === 0) &&
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.PINK}
title={translate('Special')}
/>
}
</div>
</div>
<EpisodeDetailsModal
isOpen={this.state.isDetailsModalOpen}
episodeId={id}
episodeEntity={episodeEntities.CALENDAR}
seriesId={series.id}
episodeTitle={title}
showOpenSeriesButton={true}
onModalClose={this.onDetailsModalClose}
/>
</div>
);
}
}
AgendaEvent.propTypes = {
id: PropTypes.number.isRequired,
series: PropTypes.object.isRequired,
episodeFile: PropTypes.object,
title: PropTypes.string.isRequired,
seasonNumber: PropTypes.number.isRequired,
episodeNumber: PropTypes.number.isRequired,
absoluteEpisodeNumber: PropTypes.number,
airDateUtc: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
unverifiedSceneNumbering: PropTypes.bool,
finaleType: PropTypes.string,
hasFile: PropTypes.bool.isRequired,
grabbed: PropTypes.bool,
queueItem: PropTypes.object,
showDate: PropTypes.bool.isRequired,
showEpisodeInformation: PropTypes.bool.isRequired,
showFinaleIcon: PropTypes.bool.isRequired,
showSpecialIcon: PropTypes.bool.isRequired,
showCutoffUnmetIcon: PropTypes.bool.isRequired,
timeFormat: PropTypes.string.isRequired,
longDateFormat: PropTypes.string.isRequired,
colorImpairedMode: PropTypes.bool.isRequired
};
export default AgendaEvent;

View file

@ -0,0 +1,227 @@
import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import episodeEntities from 'Episode/episodeEntities';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import styles from './AgendaEvent.css';
interface AgendaEventProps {
id: number;
seriesId: number;
episodeFileId: number;
title: string;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber?: number;
airDateUtc: string;
monitored: boolean;
unverifiedSceneNumbering?: boolean;
finaleType?: string;
hasFile: boolean;
grabbed?: boolean;
showDate: boolean;
}
function AgendaEvent(props: AgendaEventProps) {
const {
id,
seriesId,
episodeFileId,
title,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
airDateUtc,
monitored,
unverifiedSceneNumbering,
finaleType,
hasFile,
grabbed,
showDate,
} = props;
const series = useSeries(seriesId)!;
const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useSelector(createQueueItemSelectorForHook(id));
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()
);
const {
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
} = useSelector((state: AppState) => state.calendar.options);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const startTime = moment(airDateUtc);
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
const downloading = !!(queueItem || grabbed);
const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle(
hasFile,
downloading,
startTime,
endTime,
isMonitored
);
const missingAbsoluteNumber =
series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
const handlePress = useCallback(() => {
setIsDetailsModalOpen(true);
}, []);
const handleDetailsModalClose = useCallback(() => {
setIsDetailsModalOpen(false);
}, []);
return (
<div className={styles.event}>
<Link className={styles.underlay} onPress={handlePress} />
<div className={styles.overlay}>
<div className={styles.date}>
{showDate && startTime.format(longDateFormat)}
</div>
<div
className={classNames(
styles.eventWrapper,
styles[statusStyle],
enableColorImpairedMode && 'colorImpaired'
)}
>
<div className={styles.time}>
{formatTime(airDateUtc, timeFormat)} -{' '}
{formatTime(endTime.toISOString(), timeFormat, {
includeMinuteZero: true,
})}
</div>
<div className={styles.seriesTitle}>{series.title}</div>
{showEpisodeInformation ? (
<div className={styles.seasonEpisodeNumber}>
{seasonNumber}x{padNumber(episodeNumber, 2)}
{series.seriesType === 'anime' && absoluteEpisodeNumber && (
<span className={styles.absoluteEpisodeNumber}>
({absoluteEpisodeNumber})
</span>
)}
<div className={styles.episodeSeparator}> - </div>
</div>
) : null}
<div className={styles.episodeTitle}>
{showEpisodeInformation ? title : null}
</div>
{missingAbsoluteNumber ? (
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/>
) : null}
{unverifiedSceneNumbering && !missingAbsoluteNumber ? (
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('SceneNumberNotVerified')}
/>
) : null}
{queueItem ? (
<span className={styles.statusIcon}>
<CalendarEventQueueDetails
seasonNumber={seasonNumber}
{...queueItem}
/>
</span>
) : null}
{!queueItem && grabbed ? (
<Icon
className={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('EpisodeIsDownloading')}
/>
) : null}
{showCutoffUnmetIcon &&
episodeFile &&
episodeFile.qualityCutoffNotMet ? (
<Icon
className={styles.statusIcon}
name={icons.EPISODE_FILE}
kind={kinds.WARNING}
title={translate('QualityCutoffNotMet')}
/>
) : null}
{episodeNumber === 1 && seasonNumber > 0 && (
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.INFO}
title={
seasonNumber === 1
? translate('SeriesPremiere')
: translate('SeasonPremiere')
}
/>
)}
{showFinaleIcon && finaleType ? (
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.WARNING}
title={getFinaleTypeName(finaleType)}
/>
) : null}
{showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? (
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.PINK}
title={translate('Special')}
/>
) : null}
</div>
</div>
<EpisodeDetailsModal
isOpen={isDetailsModalOpen}
episodeId={id}
episodeEntity={episodeEntities.CALENDAR}
seriesId={series.id}
episodeTitle={title}
showOpenSeriesButton={true}
onModalClose={handleDetailsModalClose}
/>
</div>
);
}
export default AgendaEvent;

View file

@ -1,30 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import AgendaEvent from './AgendaEvent';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
createSeriesSelector(),
createEpisodeFileSelector(),
createQueueItemSelector(),
createUISettingsSelector(),
(calendarOptions, series, episodeFile, queueItem, uiSettings) => {
return {
series,
episodeFile,
queueItem,
...calendarOptions,
timeFormat: uiSettings.timeFormat,
longDateFormat: uiSettings.longDateFormat,
colorImpairedMode: uiSettings.enableColorImpairedMode
};
}
);
}
export default connect(createMapStateToProps)(AgendaEvent);

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,170 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Episode from 'Episode/Episode';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props';
import {
clearCalendar,
fetchCalendar,
gotoCalendarToday,
} from 'Store/Actions/calendarActions';
import {
clearEpisodeFiles,
fetchEpisodeFiles,
} from 'Store/Actions/episodeFileActions';
import {
clearQueueDetails,
fetchQueueDetails,
} from 'Store/Actions/queueActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import {
registerPagePopulator,
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import Agenda from './Agenda/Agenda';
import CalendarDays from './Day/CalendarDays';
import DaysOfWeek from './Day/DaysOfWeek';
import CalendarHeader from './Header/CalendarHeader';
import styles from './Calendar.css';
const UPDATE_DELAY = 3600000; // 1 hour
function Calendar() {
const dispatch = useDispatch();
const requestCurrentPage = useCurrentPage();
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
const { isFetching, isPopulated, error, items, time, view } = useSelector(
(state: AppState) => state.calendar
);
const isRefreshingSeries = useSelector(
createCommandExecutingSelector(commandNames.REFRESH_SERIES)
);
const firstDayOfWeek = useSelector(
(state: AppState) => state.settings.ui.item.firstDayOfWeek
);
const wasRefreshingSeries = usePrevious(isRefreshingSeries);
const previousFirstDayOfWeek = usePrevious(firstDayOfWeek);
const previousItems = usePrevious(items);
const handleScheduleUpdate = useCallback(() => {
clearTimeout(updateTimeout.current);
function updateCalendar() {
dispatch(gotoCalendarToday());
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
}
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
}, [dispatch]);
useEffect(() => {
handleScheduleUpdate();
return () => {
dispatch(clearCalendar());
dispatch(clearQueueDetails());
dispatch(clearEpisodeFiles());
clearTimeout(updateTimeout.current);
};
}, [dispatch, handleScheduleUpdate]);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchCalendar());
} else {
dispatch(gotoCalendarToday());
}
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchQueueDetails({ time, view }));
dispatch(fetchCalendar({ time, view }));
};
registerPagePopulator(repopulate, [
'episodeFileUpdated',
'episodeFileDeleted',
]);
return () => {
unregisterPagePopulator(repopulate);
};
}, [time, view, dispatch]);
useEffect(() => {
handleScheduleUpdate();
}, [time, handleScheduleUpdate]);
useEffect(() => {
if (
previousFirstDayOfWeek != null &&
firstDayOfWeek !== previousFirstDayOfWeek
) {
dispatch(fetchCalendar({ time, view }));
}
}, [time, view, firstDayOfWeek, previousFirstDayOfWeek, dispatch]);
useEffect(() => {
if (wasRefreshingSeries && !isRefreshingSeries) {
dispatch(fetchCalendar({ time, view }));
}
}, [time, view, isRefreshingSeries, wasRefreshingSeries, dispatch]);
useEffect(() => {
if (!previousItems || hasDifferentItems(items, previousItems)) {
const episodeIds = selectUniqueIds<Episode, number>(items, 'id');
const episodeFileIds = selectUniqueIds<Episode, number>(
items,
'episodeFileId'
);
if (items.length) {
dispatch(fetchQueueDetails({ episodeIds }));
}
if (episodeFileIds.length) {
dispatch(fetchEpisodeFiles({ episodeFileIds }));
}
}
}, [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,196 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import * as calendarActions from 'Store/Actions/calendarActions';
import { clearEpisodeFiles, fetchEpisodeFiles } from 'Store/Actions/episodeFileActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import Calendar from './Calendar';
const UPDATE_DELAY = 3600000; // 1 hour
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
(state) => state.settings.ui.item.firstDayOfWeek,
createCommandExecutingSelector(commandNames.REFRESH_SERIES),
(calendar, firstDayOfWeek, isRefreshingSeries) => {
return {
...calendar,
isRefreshingSeries,
firstDayOfWeek
};
}
);
}
const mapDispatchToProps = {
...calendarActions,
fetchEpisodeFiles,
clearEpisodeFiles,
fetchQueueDetails,
clearQueueDetails
};
class CalendarConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.updateTimeoutId = null;
}
componentDidMount() {
const {
useCurrentPage,
fetchCalendar,
gotoCalendarToday
} = this.props;
registerPagePopulator(this.repopulate, ['episodeFileUpdated', 'episodeFileDeleted']);
if (useCurrentPage) {
fetchCalendar();
} else {
gotoCalendarToday();
}
this.scheduleUpdate();
}
componentDidUpdate(prevProps) {
const {
items,
time,
view,
isRefreshingSeries,
firstDayOfWeek
} = this.props;
if (hasDifferentItems(prevProps.items, items)) {
const episodeIds = selectUniqueIds(items, 'id');
const episodeFileIds = selectUniqueIds(items, 'episodeFileId');
if (items.length) {
this.props.fetchQueueDetails({ episodeIds });
}
if (episodeFileIds.length) {
this.props.fetchEpisodeFiles({ episodeFileIds });
}
}
if (prevProps.time !== time) {
this.scheduleUpdate();
}
if (prevProps.firstDayOfWeek !== firstDayOfWeek) {
this.props.fetchCalendar({ time, view });
}
if (prevProps.isRefreshingSeries && !isRefreshingSeries) {
this.props.fetchCalendar({ time, view });
}
}
componentWillUnmount() {
unregisterPagePopulator(this.repopulate);
this.props.clearCalendar();
this.props.clearQueueDetails();
this.props.clearEpisodeFiles();
this.clearUpdateTimeout();
}
//
// Control
repopulate = () => {
const {
time,
view
} = this.props;
this.props.fetchQueueDetails({ time, view });
this.props.fetchCalendar({ time, view });
};
scheduleUpdate = () => {
this.clearUpdateTimeout();
this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY);
};
clearUpdateTimeout = () => {
if (this.updateTimeoutId) {
clearTimeout(this.updateTimeoutId);
}
};
updateCalendar = () => {
this.props.gotoCalendarToday();
this.scheduleUpdate();
};
//
// Listeners
onCalendarViewChange = (view) => {
this.props.setCalendarView({ view });
};
onTodayPress = () => {
this.props.gotoCalendarToday();
};
onPreviousPress = () => {
this.props.gotoCalendarPreviousRange();
};
onNextPress = () => {
this.props.gotoCalendarNextRange();
};
//
// Render
render() {
return (
<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,
isRefreshingSeries: PropTypes.bool.isRequired,
setCalendarView: PropTypes.func.isRequired,
gotoCalendarToday: PropTypes.func.isRequired,
gotoCalendarPreviousRange: PropTypes.func.isRequired,
gotoCalendarNextRange: PropTypes.func.isRequired,
clearCalendar: PropTypes.func.isRequired,
fetchCalendar: PropTypes.func.isRequired,
fetchEpisodeFiles: PropTypes.func.isRequired,
clearEpisodeFiles: PropTypes.func.isRequired,
fetchQueueDetails: PropTypes.func.isRequired,
clearQueueDetails: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector);

View file

@ -1,197 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Measure from 'Components/Measure';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { align, icons } from 'Helpers/Props';
import NoSeries from 'Series/NoSeries';
import 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 {
missingEpisodeIds,
onSearchMissingPress
} = this.props;
onSearchMissingPress(missingEpisodeIds);
};
//
// Render
render() {
const {
selectedFilterKey,
filters,
customFilters,
hasSeries,
missingEpisodeIds,
isRssSyncExecuting,
isSearchingForMissing,
useCurrentPage,
onRssSyncPress,
onFilterSelect
} = this.props;
const {
isCalendarLinkModalOpen,
isOptionsModalOpen
} = this.state;
const isMeasured = this.state.width > 0;
const PageComponent = hasSeries ? CalendarConnector : NoSeries;
return (
<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={!missingEpisodeIds.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={!hasSeries}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={CalendarFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody
className={styles.calendarPageBody}
innerClassName={styles.calendarInnerPageBody}
>
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
{
isMeasured ?
<PageComponent
useCurrentPage={useCurrentPage}
/> :
<div />
}
</Measure>
{
hasSeries &&
<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,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
hasSeries: PropTypes.bool.isRequired,
missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired,
isRssSyncExecuting: PropTypes.bool.isRequired,
isSearchingForMissing: PropTypes.bool.isRequired,
useCurrentPage: PropTypes.bool.isRequired,
onSearchMissingPress: PropTypes.func.isRequired,
onDaysCountChange: PropTypes.func.isRequired,
onRssSyncPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired
};
export default CalendarPage;

View file

@ -0,0 +1,226 @@
import moment from 'moment';
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Measure from 'Components/Measure';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { align, icons } from 'Helpers/Props';
import NoSeries from 'Series/NoSeries';
import {
searchMissing,
setCalendarDaysCount,
setCalendarFilter,
} from 'Store/Actions/calendarActions';
import { executeCommand } from 'Store/Actions/commandActions';
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
import { isCommandExecuting } from 'Utilities/Command';
import isBefore from 'Utilities/Date/isBefore';
import translate from 'Utilities/String/translate';
import Calendar from './Calendar';
import CalendarFilterModal from './CalendarFilterModal';
import CalendarLinkModal from './iCal/CalendarLinkModal';
import Legend from './Legend/Legend';
import CalendarOptionsModal from './Options/CalendarOptionsModal';
import styles from './CalendarPage.css';
const MINIMUM_DAY_WIDTH = 120;
function createMissingEpisodeIdsSelector() {
return createSelector(
(state: AppState) => state.calendar.start,
(state: AppState) => state.calendar.end,
(state: AppState) => state.calendar.items,
(state: AppState) => state.queue.details.items,
(start, end, episodes, queueDetails) => {
return episodes.reduce<number[]>((acc, episode) => {
const airDateUtc = episode.airDateUtc;
if (
!episode.episodeFileId &&
moment(airDateUtc).isAfter(start) &&
moment(airDateUtc).isBefore(end) &&
isBefore(episode.airDateUtc) &&
!queueDetails.some(
(details) => !!details.episode && details.episode.id === episode.id
)
) {
acc.push(episode.id);
}
return acc;
}, []);
}
);
}
function createIsSearchingSelector() {
return createSelector(
(state: AppState) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(
commands.find((command) => {
return command.id === searchMissingCommandId;
})
);
}
);
}
function CalendarPage() {
const dispatch = useDispatch();
const { selectedFilterKey, filters } = useSelector(
(state: AppState) => state.calendar
);
const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector());
const isSearchingForMissing = useSelector(createIsSearchingSelector());
const isRssSyncExecuting = useSelector(
createCommandExecutingSelector(commandNames.RSS_SYNC)
);
const customFilters = useSelector(createCustomFiltersSelector('calendar'));
const hasSeries = !!useSelector(createSeriesCountSelector());
const [isCalendarLinkModalOpen, setIsCalendarLinkModalOpen] = useState(false);
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
const [width, setWidth] = useState(0);
const isMeasured = width > 0;
const PageComponent = hasSeries ? Calendar : NoSeries;
const handleMeasure = useCallback(
({ width: newWidth }: { width: number }) => {
setWidth(newWidth);
const dayCount = Math.max(
3,
Math.min(7, Math.floor(newWidth / MINIMUM_DAY_WIDTH))
);
dispatch(setCalendarDaysCount({ dayCount }));
},
[dispatch]
);
const handleGetCalendarLinkPress = useCallback(() => {
setIsCalendarLinkModalOpen(true);
}, []);
const handleGetCalendarLinkModalClose = useCallback(() => {
setIsCalendarLinkModalOpen(false);
}, []);
const handleOptionsPress = useCallback(() => {
setIsOptionsModalOpen(true);
}, []);
const handleOptionsModalClose = useCallback(() => {
setIsOptionsModalOpen(false);
}, []);
const handleRssSyncPress = useCallback(() => {
dispatch(
executeCommand({
name: commandNames.RSS_SYNC,
})
);
}, [dispatch]);
const handleSearchMissingPress = useCallback(() => {
dispatch(searchMissing({ episodeIds: missingEpisodeIds }));
}, [missingEpisodeIds, dispatch]);
const handleFilterSelect = useCallback(
(key: string) => {
dispatch(setCalendarFilter({ selectedFilterKey: key }));
},
[dispatch]
);
return (
<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={!missingEpisodeIds.length}
isSpinning={isSearchingForMissing}
onPress={handleSearchMissingPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label={translate('Options')}
iconName={icons.POSTER}
onPress={handleOptionsPress}
/>
<FilterMenu
alignMenu={align.RIGHT}
isDisabled={!hasSeries}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={CalendarFilterModal}
onFilterSelect={handleFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody
className={styles.calendarPageBody}
innerClassName={styles.calendarInnerPageBody}
>
<Measure whitelist={['width']} onMeasure={handleMeasure}>
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
</Measure>
{hasSeries && <Legend />}
</PageContentBody>
<CalendarLinkModal
isOpen={isCalendarLinkModalOpen}
onModalClose={handleGetCalendarLinkModalClose}
/>
<CalendarOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={handleOptionsModalClose}
/>
</PageContent>
);
}
export default CalendarPage;

View file

@ -1,117 +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 createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { isCommandExecuting } from 'Utilities/Command';
import isBefore from 'Utilities/Date/isBefore';
import CalendarPage from './CalendarPage';
function createMissingEpisodeIdsSelector() {
return createSelector(
(state) => state.calendar.start,
(state) => state.calendar.end,
(state) => state.calendar.items,
(state) => state.queue.details.items,
(start, end, episodes, queueDetails) => {
return episodes.reduce((acc, episode) => {
const airDateUtc = episode.airDateUtc;
if (
!episode.episodeFileId &&
moment(airDateUtc).isAfter(start) &&
moment(airDateUtc).isBefore(end) &&
isBefore(episode.airDateUtc) &&
!queueDetails.some((details) => !!details.episode && details.episode.id === episode.id)
) {
acc.push(episode.id);
}
return acc;
}, []);
}
);
}
function createIsSearchingSelector() {
return createSelector(
(state) => state.calendar.searchMissingCommandId,
createCommandsSelector(),
(searchMissingCommandId, commands) => {
if (searchMissingCommandId == null) {
return false;
}
return isCommandExecuting(commands.find((command) => {
return command.id === searchMissingCommandId;
}));
}
);
}
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.selectedFilterKey,
(state) => state.calendar.filters,
createCustomFiltersSelector('calendar'),
createSeriesCountSelector(),
createUISettingsSelector(),
createMissingEpisodeIdsSelector(),
createCommandExecutingSelector(commandNames.RSS_SYNC),
createIsSearchingSelector(),
(
selectedFilterKey,
filters,
customFilters,
seriesCount,
uiSettings,
missingEpisodeIds,
isRssSyncExecuting,
isSearchingForMissing
) => {
return {
selectedFilterKey,
filters,
customFilters,
colorImpairedMode: uiSettings.enableColorImpairedMode,
hasSeries: !!seriesCount,
missingEpisodeIds,
isRssSyncExecuting,
isSearchingForMissing
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onRssSyncPress() {
dispatch(executeCommand({
name: commandNames.RSS_SYNC
}));
},
onSearchMissingPress(episodeIds) {
dispatch(searchMissing({ episodeIds }));
},
onDaysCountChange(dayCount) {
dispatch(setCalendarDaysCount({ dayCount }));
},
onFilterSelect(selectedFilterKey) {
dispatch(setCalendarFilter({ selectedFilterKey }));
}
};
}
export default withCurrentPage(
connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage)
);

View file

@ -1,25 +1,104 @@
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 CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector';
import Series from 'Series/Series';
import CalendarEventGroup, { CalendarEvent } from 'typings/CalendarEventGroup';
import CalendarEvent from 'Calendar/Events/CalendarEvent';
import CalendarEventGroup from 'Calendar/Events/CalendarEventGroup';
import {
CalendarEvent as CalendarEventModel,
CalendarEventGroup as CalendarEventGroupModel,
CalendarItem,
} from 'typings/Calendar';
import styles from './CalendarDay.css';
function sort(items: (CalendarEventModel | CalendarEventGroupModel)[]) {
return items.sort((a, b) => {
const aDate = a.isGroup
? moment(a.events[0].airDateUtc).unix()
: moment(a.airDateUtc).unix();
const bDate = b.isGroup
? moment(b.events[0].airDateUtc).unix()
: moment(b.airDateUtc).unix();
return aDate - bDate;
});
}
function createCalendarEventsConnector(date: string) {
return createSelector(
(state: AppState) => state.calendar.items,
(state: AppState) => state.calendar.options.collapseMultipleEpisodes,
(items, collapseMultipleEpisodes) => {
const momentDate = moment(date);
const filtered = items.filter((item) => {
return momentDate.isSame(moment(item.airDateUtc), 'day');
});
if (!collapseMultipleEpisodes) {
return sort(
filtered.map((item) => ({
isGroup: false,
...item,
}))
);
}
const groupedObject = Object.groupBy(
filtered,
(item: CalendarItem) => `${item.seriesId}-${item.seasonNumber}`
);
const grouped = Object.entries(groupedObject).reduce<
(CalendarEventModel | CalendarEventGroupModel)[]
>((acc, [, events]) => {
if (!events) {
return acc;
}
if (events.length === 1) {
acc.push({
isGroup: false,
...events[0],
});
} else {
acc.push({
isGroup: true,
seriesId: events[0].seriesId,
seasonNumber: events[0].seasonNumber,
episodeIds: events.map((event) => event.id),
events: events.sort(
(a, b) =>
moment(a.airDateUtc).unix() - moment(b.airDateUtc).unix()
),
});
}
return acc;
}, []);
return sort(grouped);
}
);
}
interface CalendarDayProps {
date: string;
time: string;
isTodaysDate: boolean;
events: (CalendarEvent | CalendarEventGroup)[];
view: string;
onEventModalOpenToggle(...args: unknown[]): unknown;
onEventModalOpenToggle(isOpen: boolean): unknown;
}
function CalendarDay(props: CalendarDayProps) {
const { date, time, isTodaysDate, events, view, onEventModalOpenToggle } =
props;
function CalendarDay({
date,
isTodaysDate,
onEventModalOpenToggle,
}: CalendarDayProps) {
const { time, view } = useSelector((state: AppState) => state.calendar);
const events = useSelector(createCalendarEventsConnector(date));
const ref = React.useRef<HTMLDivElement>(null);
@ -53,7 +132,7 @@ function CalendarDay(props: CalendarDayProps) {
{events.map((event) => {
if (event.isGroup) {
return (
<CalendarEventGroupConnector
<CalendarEventGroup
key={event.seriesId}
{...event}
onEventModalOpenToggle={onEventModalOpenToggle}
@ -62,11 +141,11 @@ function CalendarDay(props: CalendarDayProps) {
}
return (
<CalendarEventConnector
<CalendarEvent
key={event.id}
{...event}
episodeId={event.id}
series={event.series as Series}
seriesId={event.seriesId}
airDateUtc={event.airDateUtc as string}
onEventModalOpenToggle={onEventModalOpenToggle}
/>

View file

@ -1,91 +0,0 @@
import _ from 'lodash';
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import CalendarDay from './CalendarDay';
function sort(items) {
return _.sortBy(items, (item) => {
if (item.isGroup) {
return moment(item.events[0].airDateUtc).unix();
}
return moment(item.airDateUtc).unix();
});
}
function createCalendarEventsConnector() {
return createSelector(
(state, { date }) => date,
(state) => state.calendar.items,
(state) => state.calendar.options.collapseMultipleEpisodes,
(date, items, collapseMultipleEpisodes) => {
const filtered = _.filter(items, (item) => {
return moment(date).isSame(moment(item.airDateUtc), 'day');
});
if (!collapseMultipleEpisodes) {
return sort(filtered);
}
const groupedObject = _.groupBy(filtered, (item) => `${item.seriesId}-${item.seasonNumber}`);
const grouped = [];
Object.keys(groupedObject).forEach((key) => {
const events = groupedObject[key];
if (events.length === 1) {
grouped.push(events[0]);
} else {
grouped.push({
isGroup: true,
seriesId: events[0].seriesId,
seasonNumber: events[0].seasonNumber,
episodeIds: events.map((event) => event.id),
events: _.sortBy(events, (item) => moment(item.airDateUtc).unix())
});
}
});
const sorted = sort(grouped);
return sorted;
}
);
}
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
createCalendarEventsConnector(),
(calendar, events) => {
return {
time: calendar.time,
view: calendar.view,
events
};
}
);
}
class CalendarDayConnector extends Component {
//
// Render
render() {
return (
<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,135 @@
import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as calendarViews from 'Calendar/calendarViews';
import {
gotoCalendarNextRange,
gotoCalendarPreviousRange,
} from 'Store/Actions/calendarActions';
import CalendarDay from './CalendarDay';
import styles from './CalendarDays.css';
function CalendarDays() {
const dispatch = useDispatch();
const { dates, view } = useSelector((state: AppState) => state.calendar);
const isSidebarVisible = useSelector(
(state: AppState) => state.app.isSidebarVisible
);
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
const touchStart = useRef<number | null>(null);
const isEventModalOpen = useRef(false);
const [todaysDate, setTodaysDate] = useState(
moment().startOf('day').toISOString()
);
const handleEventModalOpenToggle = useCallback((isOpen: boolean) => {
isEventModalOpen.current = isOpen;
}, []);
const scheduleUpdate = useCallback(() => {
clearTimeout(updateTimeout.current);
const todaysDate = moment().startOf('day');
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
setTodaysDate(todaysDate.toISOString());
updateTimeout.current = setTimeout(scheduleUpdate, diff);
}, []);
const handleTouchStart = useCallback(
(event: TouchEvent) => {
const touches = event.touches;
const currentTouch = touches[0].pageX;
if (touches.length !== 1) {
return;
}
if (currentTouch < 50 || isSidebarVisible || isEventModalOpen.current) {
return;
}
touchStart.current = currentTouch;
},
[isSidebarVisible]
);
const handleTouchEnd = useCallback(
(event: TouchEvent) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
if (!touchStart.current) {
return;
}
if (
currentTouch > touchStart.current &&
currentTouch - touchStart.current > 100
) {
dispatch(gotoCalendarPreviousRange());
} else if (
currentTouch < touchStart.current &&
touchStart.current - currentTouch > 100
) {
dispatch(gotoCalendarNextRange());
}
touchStart.current = null;
},
[dispatch]
);
const handleTouchCancel = useCallback(() => {
touchStart.current = null;
}, []);
const handleTouchMove = useCallback(() => {
if (!touchStart.current) {
return;
}
}, []);
useEffect(() => {
if (view === calendarViews.MONTH) {
scheduleUpdate();
}
}, [view, scheduleUpdate]);
useEffect(() => {
window.addEventListener('touchstart', handleTouchStart);
window.addEventListener('touchend', handleTouchEnd);
window.addEventListener('touchcancel', handleTouchCancel);
window.addEventListener('touchmove', handleTouchMove);
return () => {
window.removeEventListener('touchstart', handleTouchStart);
window.removeEventListener('touchend', handleTouchEnd);
window.removeEventListener('touchcancel', handleTouchCancel);
window.removeEventListener('touchmove', handleTouchMove);
};
}, [handleTouchStart, handleTouchEnd, handleTouchCancel, handleTouchMove]);
return (
<div
className={classNames(styles.days, styles[view as keyof typeof styles])}
>
{dates.map((date) => {
return (
<CalendarDay
key={date}
date={date}
isTodaysDate={date === todaysDate}
onEventModalOpenToggle={handleEventModalOpenToggle}
/>
);
})}
</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

@ -1,267 +0,0 @@
import classNames from 'classnames';
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import episodeEntities from 'Episode/episodeEntities';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { icons, kinds } from 'Helpers/Props';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import CalendarEventQueueDetails from './CalendarEventQueueDetails';
import styles from './CalendarEvent.css';
class CalendarEvent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false
};
}
//
// Listeners
onPress = () => {
this.setState({ isDetailsModalOpen: true }, () => {
this.props.onEventModalOpenToggle(true);
});
};
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false }, () => {
this.props.onEventModalOpenToggle(false);
});
};
//
// Render
render() {
const {
id,
series,
episodeFile,
title,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
airDateUtc,
monitored,
unverifiedSceneNumbering,
finaleType,
hasFile,
grabbed,
queueItem,
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
fullColorEvents,
timeFormat,
colorImpairedMode
} = this.props;
if (!series) {
return null;
}
const startTime = moment(airDateUtc);
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
const isDownloading = !!(queueItem || grabbed);
const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, endTime, isMonitored);
const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
return (
<div
className={classNames(
styles.event,
styles[statusStyle],
colorImpairedMode && 'colorImpaired',
fullColorEvents && 'fullColor'
)}
>
<Link
className={styles.underlay}
onPress={this.onPress}
/>
<div className={styles.overlay} >
<div className={styles.info}>
<div className={styles.seriesTitle}>
{series.title}
</div>
<div
className={classNames(
styles.statusContainer,
fullColorEvents && 'fullColor'
)}
>
{
missingAbsoluteNumber ?
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/> :
null
}
{
unverifiedSceneNumbering && !missingAbsoluteNumber ?
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('SceneNumberNotVerified')}
/> :
null
}
{
queueItem ?
<span className={styles.statusIcon}>
<CalendarEventQueueDetails
{...queueItem}
fullColorEvents={fullColorEvents}
/>
</span> :
null
}
{
!queueItem && grabbed ?
<Icon
className={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('EpisodeIsDownloading')}
/> :
null
}
{
showCutoffUnmetIcon &&
!!episodeFile &&
episodeFile.qualityCutoffNotMet ?
<Icon
className={styles.statusIcon}
name={icons.EPISODE_FILE}
kind={kinds.WARNING}
title={translate('QualityCutoffNotMet')}
/> :
null
}
{
episodeNumber === 1 && seasonNumber > 0 ?
<Icon
className={styles.statusIcon}
name={icons.PREMIERE}
kind={kinds.INFO}
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
/> :
null
}
{
showFinaleIcon &&
finaleType ?
<Icon
className={styles.statusIcon}
name={finaleType === 'series' ? icons.FINALE_SERIES : icons.FINALE_SEASON}
kind={finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
title={getFinaleTypeName(finaleType)}
/> :
null
}
{
showSpecialIcon &&
(episodeNumber === 0 || seasonNumber === 0) ?
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.PINK}
title={translate('Special')}
/> :
null
}
</div>
</div>
{
showEpisodeInformation ?
<div className={styles.episodeInfo}>
<div className={styles.episodeTitle}>
{title}
</div>
<div>
{seasonNumber}x{padNumber(episodeNumber, 2)}
{
series.seriesType === 'anime' && absoluteEpisodeNumber ?
<span className={styles.absoluteEpisodeNumber}>({absoluteEpisodeNumber})</span> : null
}
</div>
</div> :
null
}
<div className={styles.airTime}>
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
</div>
</div>
<EpisodeDetailsModal
isOpen={this.state.isDetailsModalOpen}
episodeId={id}
episodeEntity={episodeEntities.CALENDAR}
seriesId={series.id}
episodeTitle={title}
showOpenSeriesButton={true}
onModalClose={this.onDetailsModalClose}
/>
</div>
);
}
}
CalendarEvent.propTypes = {
id: PropTypes.number.isRequired,
episodeId: PropTypes.number.isRequired,
series: PropTypes.object.isRequired,
episodeFile: PropTypes.object,
title: PropTypes.string.isRequired,
seasonNumber: PropTypes.number.isRequired,
episodeNumber: PropTypes.number.isRequired,
absoluteEpisodeNumber: PropTypes.number,
airDateUtc: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
unverifiedSceneNumbering: PropTypes.bool,
finaleType: 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.
showEpisodeInformation: PropTypes.bool,
showFinaleIcon: PropTypes.bool,
showSpecialIcon: PropTypes.bool,
showCutoffUnmetIcon: PropTypes.bool,
fullColorEvents: PropTypes.bool,
timeFormat: PropTypes.string,
colorImpairedMode: PropTypes.bool,
onEventModalOpenToggle: PropTypes.func
};
export default CalendarEvent;

View file

@ -0,0 +1,240 @@
import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
import episodeEntities from 'Episode/episodeEntities';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import CalendarEventQueueDetails from './CalendarEventQueueDetails';
import styles from './CalendarEvent.css';
interface CalendarEventProps {
id: number;
episodeId: number;
seriesId: number;
episodeFileId?: number;
title: string;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber?: number;
airDateUtc: string;
monitored: boolean;
unverifiedSceneNumbering?: boolean;
finaleType?: string;
hasFile: boolean;
grabbed?: boolean;
onEventModalOpenToggle: (isOpen: boolean) => void;
}
function CalendarEvent(props: CalendarEventProps) {
const {
id,
seriesId,
episodeFileId,
title,
seasonNumber,
episodeNumber,
absoluteEpisodeNumber,
airDateUtc,
monitored,
unverifiedSceneNumbering,
finaleType,
hasFile,
grabbed,
onEventModalOpenToggle,
} = props;
const series = useSeries(seriesId);
const episodeFile = useEpisodeFile(episodeFileId);
const queueItem = useSelector(createQueueItemSelectorForHook(id));
const { timeFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()
);
const {
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
fullColorEvents,
} = useSelector((state: AppState) => state.calendar.options);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const handlePress = useCallback(() => {
setIsDetailsModalOpen(true);
onEventModalOpenToggle(true);
}, [onEventModalOpenToggle]);
const handleDetailsModalClose = useCallback(() => {
setIsDetailsModalOpen(false);
onEventModalOpenToggle(false);
}, [onEventModalOpenToggle]);
if (!series) {
return null;
}
const startTime = moment(airDateUtc);
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
const isDownloading = !!(queueItem || grabbed);
const isMonitored = series.monitored && monitored;
const statusStyle = getStatusStyle(
hasFile,
isDownloading,
startTime,
endTime,
isMonitored
);
const missingAbsoluteNumber =
series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
return (
<div
className={classNames(
styles.event,
styles[statusStyle],
enableColorImpairedMode && 'colorImpaired',
fullColorEvents && 'fullColor'
)}
>
<Link className={styles.underlay} onPress={handlePress} />
<div className={styles.overlay}>
<div className={styles.info}>
<div className={styles.seriesTitle}>{series.title}</div>
<div
className={classNames(
styles.statusContainer,
fullColorEvents && 'fullColor'
)}
>
{missingAbsoluteNumber ? (
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/>
) : null}
{unverifiedSceneNumbering && !missingAbsoluteNumber ? (
<Icon
className={styles.statusIcon}
name={icons.WARNING}
title={translate('SceneNumberNotVerified')}
/>
) : null}
{queueItem ? (
<span className={styles.statusIcon}>
<CalendarEventQueueDetails {...queueItem} />
</span>
) : null}
{!queueItem && grabbed ? (
<Icon
className={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('EpisodeIsDownloading')}
/>
) : null}
{showCutoffUnmetIcon &&
!!episodeFile &&
episodeFile.qualityCutoffNotMet ? (
<Icon
className={styles.statusIcon}
name={icons.EPISODE_FILE}
kind={kinds.WARNING}
title={translate('QualityCutoffNotMet')}
/>
) : null}
{episodeNumber === 1 && seasonNumber > 0 ? (
<Icon
className={styles.statusIcon}
name={icons.PREMIERE}
kind={kinds.INFO}
title={
seasonNumber === 1
? translate('SeriesPremiere')
: translate('SeasonPremiere')
}
/>
) : null}
{showFinaleIcon && finaleType ? (
<Icon
className={styles.statusIcon}
name={
finaleType === 'series'
? icons.FINALE_SERIES
: icons.FINALE_SEASON
}
kind={finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
title={getFinaleTypeName(finaleType)}
/>
) : null}
{showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? (
<Icon
className={styles.statusIcon}
name={icons.INFO}
kind={kinds.PINK}
title={translate('Special')}
/>
) : null}
</div>
</div>
{showEpisodeInformation ? (
<div className={styles.episodeInfo}>
<div className={styles.episodeTitle}>{title}</div>
<div>
{seasonNumber}x{padNumber(episodeNumber, 2)}
{series.seriesType === 'anime' && absoluteEpisodeNumber ? (
<span className={styles.absoluteEpisodeNumber}>
({absoluteEpisodeNumber})
</span>
) : null}
</div>
</div>
) : null}
<div className={styles.airTime}>
{formatTime(airDateUtc, timeFormat)} -{' '}
{formatTime(endTime.toISOString(), timeFormat, {
includeMinuteZero: true,
})}
</div>
</div>
<EpisodeDetailsModal
isOpen={isDetailsModalOpen}
episodeId={id}
episodeEntity={episodeEntities.CALENDAR}
seriesId={series.id}
episodeTitle={title}
showOpenSeriesButton={true}
onModalClose={handleDetailsModalClose}
/>
</div>
);
}
export default CalendarEvent;

View file

@ -1,29 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CalendarEvent from './CalendarEvent';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
createSeriesSelector(),
createEpisodeFileSelector(),
createQueueItemSelector(),
createUISettingsSelector(),
(calendarOptions, series, episodeFile, queueItem, uiSettings) => {
return {
series,
episodeFile,
queueItem,
...calendarOptions,
timeFormat: uiSettings.timeFormat,
colorImpairedMode: uiSettings.enableColorImpairedMode
};
}
);
}
export default connect(createMapStateToProps)(CalendarEvent);

View file

@ -1,259 +0,0 @@
import classNames from 'classnames';
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { icons, kinds } from 'Helpers/Props';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import styles from './CalendarEventGroup.css';
function getEventsInfo(series, events) {
let files = 0;
let queued = 0;
let monitored = 0;
let absoluteEpisodeNumbers = 0;
events.forEach((event) => {
if (event.episodeFileId) {
files++;
}
if (event.queued) {
queued++;
}
if (series.monitored && event.monitored) {
monitored++;
}
if (event.absoluteEpisodeNumber) {
absoluteEpisodeNumbers++;
}
});
return {
allDownloaded: files === events.length,
anyQueued: queued > 0,
anyMonitored: monitored > 0,
allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length
};
}
class CalendarEventGroup extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isExpanded: false
};
}
//
// Listeners
onExpandPress = () => {
this.setState({ isExpanded: !this.state.isExpanded });
};
//
// Render
render() {
const {
series,
events,
isDownloading,
showEpisodeInformation,
showFinaleIcon,
timeFormat,
fullColorEvents,
colorImpairedMode,
onEventModalOpenToggle
} = this.props;
const { isExpanded } = this.state;
const {
allDownloaded,
anyQueued,
anyMonitored,
allAbsoluteEpisodeNumbers
} = getEventsInfo(series, events);
const anyDownloading = isDownloading || anyQueued;
const firstEpisode = events[0];
const lastEpisode = events[events.length -1];
const airDateUtc = firstEpisode.airDateUtc;
const startTime = moment(airDateUtc);
const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
const seasonNumber = firstEpisode.seasonNumber;
const statusStyle = getStatusStyle(allDownloaded, anyDownloading, startTime, endTime, anyMonitored);
const isMissingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !allAbsoluteEpisodeNumbers;
if (isExpanded) {
return (
<div>
{
events.map((event) => {
if (event.isGroup) {
return null;
}
return (
<CalendarEventConnector
key={event.id}
episodeId={event.id}
{...event}
onEventModalOpenToggle={onEventModalOpenToggle}
/>
);
})
}
<Link
className={styles.collapseContainer}
component="div"
onPress={this.onExpandPress}
>
<Icon
name={icons.COLLAPSE}
/>
</Link>
</div>
);
}
return (
<div
className={classNames(
styles.eventGroup,
styles[statusStyle],
colorImpairedMode && 'colorImpaired',
fullColorEvents && 'fullColor'
)}
>
<div className={styles.info}>
<div className={styles.seriesTitle}>
{series.title}
</div>
<div
className={classNames(
styles.statusContainer,
fullColorEvents && 'fullColor'
)}
>
{
isMissingAbsoluteNumber &&
<Icon
containerClassName={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/>
}
{
anyDownloading &&
<Icon
containerClassName={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('AnEpisodeIsDownloading')}
/>
}
{
firstEpisode.episodeNumber === 1 && seasonNumber > 0 &&
<Icon
containerClassName={styles.statusIcon}
name={icons.PREMIERE}
kind={kinds.INFO}
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
/>
}
{
showFinaleIcon &&
lastEpisode.finaleType ?
<Icon
containerClassName={styles.statusIcon}
name={lastEpisode.finaleType === 'series' ? icons.FINALE_SERIES : icons.FINALE_SEASON}
kind={lastEpisode.finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
title={getFinaleTypeName(lastEpisode.finaleType)}
/> : null
}
</div>
</div>
<div className={styles.airingInfo}>
<div className={styles.airTime}>
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
</div>
{
showEpisodeInformation ?
<div className={styles.episodeInfo}>
{seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-{padNumber(lastEpisode.episodeNumber, 2)}
{
series.seriesType === 'anime' &&
firstEpisode.absoluteEpisodeNumber &&
lastEpisode.absoluteEpisodeNumber &&
<span className={styles.absoluteEpisodeNumber}>
({firstEpisode.absoluteEpisodeNumber}-{lastEpisode.absoluteEpisodeNumber})
</span>
}
</div> :
<Link
className={styles.expandContainerInline}
component="div"
onPress={this.onExpandPress}
>
<Icon
name={icons.EXPAND}
/>
</Link>
}
</div>
{
showEpisodeInformation ?
<Link
className={styles.expandContainer}
component="div"
onPress={this.onExpandPress}
>
&nbsp;
<Icon
name={icons.EXPAND}
/>
&nbsp;
</Link> :
null
}
</div>
);
}
}
CalendarEventGroup.propTypes = {
// Most of these props come from the connector and are required, but TS is confused.
series: PropTypes.object,
events: PropTypes.arrayOf(PropTypes.object).isRequired,
isDownloading: PropTypes.bool,
showEpisodeInformation: PropTypes.bool,
showFinaleIcon: PropTypes.bool,
fullColorEvents: PropTypes.bool,
timeFormat: PropTypes.string,
colorImpairedMode: PropTypes.bool,
onEventModalOpenToggle: PropTypes.func.isRequired
};
export default CalendarEventGroup;

View file

@ -0,0 +1,253 @@
import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import getStatusStyle from 'Calendar/getStatusStyle';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import getFinaleTypeName from 'Episode/getFinaleTypeName';
import { icons, kinds } from 'Helpers/Props';
import useSeries from 'Series/useSeries';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { CalendarItem } from 'typings/Calendar';
import formatTime from 'Utilities/Date/formatTime';
import padNumber from 'Utilities/Number/padNumber';
import translate from 'Utilities/String/translate';
import CalendarEvent from './CalendarEvent';
import styles from './CalendarEventGroup.css';
function createIsDownloadingSelector(episodeIds: number[]) {
return createSelector(
(state: AppState) => state.queue.details,
(details) => {
return details.items.some((item) => {
return !!(item.episodeId && episodeIds.includes(item.episodeId));
});
}
);
}
interface CalendarEventGroupProps {
episodeIds: number[];
seriesId: number;
events: CalendarItem[];
onEventModalOpenToggle: (isOpen: boolean) => void;
}
function CalendarEventGroup({
episodeIds,
seriesId,
events,
onEventModalOpenToggle,
}: CalendarEventGroupProps) {
const isDownloading = useSelector(createIsDownloadingSelector(episodeIds));
const series = useSeries(seriesId)!;
const { timeFormat, enableColorImpairedMode } = useSelector(
createUISettingsSelector()
);
const { showEpisodeInformation, showFinaleIcon, fullColorEvents } =
useSelector((state: AppState) => state.calendar.options);
const [isExpanded, setIsExpanded] = useState(false);
const firstEpisode = events[0];
const lastEpisode = events[events.length - 1];
const airDateUtc = firstEpisode.airDateUtc;
const startTime = moment(airDateUtc);
const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
const seasonNumber = firstEpisode.seasonNumber;
const { allDownloaded, anyQueued, anyMonitored, allAbsoluteEpisodeNumbers } =
useMemo(() => {
let files = 0;
let queued = 0;
let monitored = 0;
let absoluteEpisodeNumbers = 0;
events.forEach((event) => {
if (event.episodeFileId) {
files++;
}
if (event.queued) {
queued++;
}
if (series.monitored && event.monitored) {
monitored++;
}
if (event.absoluteEpisodeNumber) {
absoluteEpisodeNumbers++;
}
});
return {
allDownloaded: files === events.length,
anyQueued: queued > 0,
anyMonitored: monitored > 0,
allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length,
};
}, [series, events]);
const anyDownloading = isDownloading || anyQueued;
const statusStyle = getStatusStyle(
allDownloaded,
anyDownloading,
startTime,
endTime,
anyMonitored
);
const isMissingAbsoluteNumber =
series.seriesType === 'anime' &&
seasonNumber > 0 &&
!allAbsoluteEpisodeNumbers;
const handleExpandPress = useCallback(() => {
setIsExpanded((state) => !state);
}, []);
if (isExpanded) {
return (
<div>
{events.map((event) => {
return (
<CalendarEvent
key={event.id}
episodeId={event.id}
{...event}
onEventModalOpenToggle={onEventModalOpenToggle}
/>
);
})}
<Link
className={styles.collapseContainer}
component="div"
onPress={handleExpandPress}
>
<Icon name={icons.COLLAPSE} />
</Link>
</div>
);
}
return (
<div
className={classNames(
styles.eventGroup,
styles[statusStyle],
enableColorImpairedMode && 'colorImpaired',
fullColorEvents && 'fullColor'
)}
>
<div className={styles.info}>
<div className={styles.seriesTitle}>{series.title}</div>
<div
className={classNames(
styles.statusContainer,
fullColorEvents && 'fullColor'
)}
>
{isMissingAbsoluteNumber ? (
<Icon
containerClassName={styles.statusIcon}
name={icons.WARNING}
title={translate('EpisodeMissingAbsoluteNumber')}
/>
) : null}
{anyDownloading ? (
<Icon
containerClassName={styles.statusIcon}
name={icons.DOWNLOADING}
title={translate('AnEpisodeIsDownloading')}
/>
) : null}
{firstEpisode.episodeNumber === 1 && seasonNumber > 0 ? (
<Icon
containerClassName={styles.statusIcon}
name={icons.PREMIERE}
kind={kinds.INFO}
title={
seasonNumber === 1
? translate('SeriesPremiere')
: translate('SeasonPremiere')
}
/>
) : null}
{showFinaleIcon && lastEpisode.finaleType ? (
<Icon
containerClassName={styles.statusIcon}
name={
lastEpisode.finaleType === 'series'
? icons.FINALE_SERIES
: icons.FINALE_SEASON
}
kind={
lastEpisode.finaleType === 'series'
? kinds.DANGER
: kinds.WARNING
}
title={getFinaleTypeName(lastEpisode.finaleType)}
/>
) : null}
</div>
</div>
<div className={styles.airingInfo}>
<div className={styles.airTime}>
{formatTime(airDateUtc, timeFormat)} -{' '}
{formatTime(endTime.toISOString(), timeFormat, {
includeMinuteZero: true,
})}
</div>
{showEpisodeInformation ? (
<div className={styles.episodeInfo}>
{seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-
{padNumber(lastEpisode.episodeNumber, 2)}
{series.seriesType === 'anime' &&
firstEpisode.absoluteEpisodeNumber &&
lastEpisode.absoluteEpisodeNumber ? (
<span className={styles.absoluteEpisodeNumber}>
({firstEpisode.absoluteEpisodeNumber}-
{lastEpisode.absoluteEpisodeNumber})
</span>
) : null}
</div>
) : (
<Link
className={styles.expandContainerInline}
component="div"
onPress={handleExpandPress}
>
<Icon name={icons.EXPAND} />
</Link>
)}
</div>
{showEpisodeInformation ? (
<Link
className={styles.expandContainer}
component="div"
onPress={handleExpandPress}
>
&nbsp;
<Icon name={icons.EXPAND} />
&nbsp;
</Link>
) : null}
</div>
);
}
export default CalendarEventGroup;

View file

@ -1,37 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CalendarEventGroup from './CalendarEventGroup';
function createIsDownloadingSelector() {
return createSelector(
(state, { episodeIds }) => episodeIds,
(state) => state.queue.details,
(episodeIds, details) => {
return details.items.some((item) => {
return !!(item.episodeId && episodeIds.includes(item.episodeId));
});
}
);
}
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
createSeriesSelector(),
createIsDownloadingSelector(),
createUISettingsSelector(),
(calendarOptions, series, isDownloading, uiSettings) => {
return {
series,
isDownloading,
...calendarOptions,
timeFormat: uiSettings.timeFormat,
colorImpairedMode: uiSettings.enableColorImpairedMode
};
}
);
}
export default connect(createMapStateToProps)(CalendarEventGroup);

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 translate('Agenda');
}
let startFormat = 'MMM D YYYY';
let endFormat = 'MMM D YYYY';
if (startMoment.isSame(endMoment, 'month')) {
startFormat = 'MMM D';
endFormat = 'D YYYY';
} else if (startMoment.isSame(endMoment, 'year')) {
startFormat = 'MMM D';
endFormat = 'MMM D YYYY';
}
return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`;
}
// 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,221 @@
import moment from 'moment';
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { CalendarView } from 'Calendar/calendarViews';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
import { align, icons } from 'Helpers/Props';
import {
gotoCalendarNextRange,
gotoCalendarPreviousRange,
gotoCalendarToday,
setCalendarView,
} from 'Store/Actions/calendarActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import translate from 'Utilities/String/translate';
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
import styles from './CalendarHeader.css';
function CalendarHeader() {
const dispatch = useDispatch();
const { isFetching, view, time, start, end } = useSelector(
(state: AppState) => state.calendar
);
const { isSmallScreen, isLargeScreen } = useSelector(
createDimensionsSelector()
);
const { longDateFormat } = useSelector(createUISettingsSelector());
const handleViewChange = useCallback(
(newView: CalendarView) => {
dispatch(setCalendarView({ view: newView }));
},
[dispatch]
);
const handleTodayPress = useCallback(() => {
dispatch(gotoCalendarToday());
}, [dispatch]);
const handlePreviousPress = useCallback(() => {
dispatch(gotoCalendarPreviousRange());
}, [dispatch]);
const handleNextPress = useCallback(() => {
dispatch(gotoCalendarNextRange());
}, [dispatch]);
const title = useMemo(() => {
const timeMoment = moment(time);
const startMoment = moment(start);
const endMoment = moment(end);
if (view === 'day') {
return timeMoment.format(longDateFormat);
} else if (view === 'month') {
return timeMoment.format('MMMM YYYY');
} else if (view === 'agenda') {
return translate('Agenda');
}
let startFormat = 'MMM D YYYY';
let endFormat = 'MMM D YYYY';
if (startMoment.isSame(endMoment, 'month')) {
startFormat = 'MMM D';
endFormat = 'D YYYY';
} else if (startMoment.isSame(endMoment, 'year')) {
startFormat = 'MMM D';
endFormat = 'MMM D YYYY';
}
return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(
endFormat
)}`;
}, [time, start, end, view, longDateFormat]);
return (
<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,85 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { gotoCalendarNextRange, gotoCalendarPreviousRange, gotoCalendarToday, setCalendarView } from 'Store/Actions/calendarActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CalendarHeader from './CalendarHeader';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
createDimensionsSelector(),
createUISettingsSelector(),
(calendar, dimensions, uiSettings) => {
const result = _.pick(calendar, [
'isFetching',
'view',
'time',
'start',
'end'
]);
result.isSmallScreen = dimensions.isSmallScreen;
result.collapseViewButtons = dimensions.isLargeScreen;
result.longDateFormat = uiSettings.longDateFormat;
return result;
}
);
}
const mapDispatchToProps = {
setCalendarView,
gotoCalendarToday,
gotoCalendarPreviousRange,
gotoCalendarNextRange
};
class CalendarHeaderConnector extends Component {
//
// Listeners
onViewChange = (view) => {
this.props.setCalendarView({ view });
};
onTodayPress = () => {
this.props.gotoCalendarToday();
};
onPreviousPress = () => {
this.props.gotoCalendarPreviousRange();
};
onNextPress = () => {
this.props.gotoCalendarNextRange();
};
//
// Render
render() {
return (
<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,20 +1,22 @@
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) {
function Legend() {
const view = useSelector((state: AppState) => state.calendar.view);
const {
view,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
fullColorEvents,
colorImpairedMode
} = props;
} = useSelector((state: AppState) => state.calendar.options);
const { enableColorImpairedMode } = useSelector(createUISettingsSelector());
const iconsToShow = [];
const isAgendaView = view === 'agenda';
@ -56,7 +58,7 @@ function Legend(props) {
if (showCutoffUnmetIcon) {
iconsToShow.push(
<LegendIconItem
name={translate('Cutoff Not Met')}
name={translate('CutoffNotMet')}
icon={icons.EPISODE_FILE}
kind={kinds.WARNING}
fullColorEvents={fullColorEvents}
@ -73,7 +75,7 @@ function Legend(props) {
tooltip={translate('CalendarLegendEpisodeUnairedTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
colorImpairedMode={enableColorImpairedMode}
/>
<LegendItem
@ -81,7 +83,7 @@ function Legend(props) {
tooltip={translate('CalendarLegendEpisodeUnmonitoredTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
colorImpairedMode={enableColorImpairedMode}
/>
</div>
@ -92,7 +94,7 @@ function Legend(props) {
tooltip={translate('CalendarLegendEpisodeOnAirTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
colorImpairedMode={enableColorImpairedMode}
/>
<LegendItem
@ -100,7 +102,7 @@ function Legend(props) {
tooltip={translate('CalendarLegendEpisodeMissingTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
colorImpairedMode={enableColorImpairedMode}
/>
</div>
@ -110,7 +112,7 @@ function Legend(props) {
tooltip={translate('CalendarLegendEpisodeDownloadingTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
colorImpairedMode={enableColorImpairedMode}
/>
<LegendItem
@ -118,7 +120,7 @@ function Legend(props) {
tooltip={translate('CalendarLegendEpisodeDownloadedTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
colorImpairedMode={enableColorImpairedMode}
/>
</div>
@ -134,30 +136,15 @@ function Legend(props) {
{iconsToShow[0]}
</div>
{
iconsToShow.length > 1 &&
<div>
{iconsToShow[1]}
{iconsToShow[2]}
</div>
}
{
iconsToShow.length > 3 &&
<div>
{iconsToShow[3]}
</div>
}
{iconsToShow.length > 1 ? (
<div>
{iconsToShow[1]}
{iconsToShow[2]}
</div>
) : null}
{iconsToShow.length > 3 ? <div>{iconsToShow[3]}</div> : null}
</div>
);
}
Legend.propTypes = {
view: PropTypes.string.isRequired,
showFinaleIcon: PropTypes.bool.isRequired,
showSpecialIcon: PropTypes.bool.isRequired,
showCutoffUnmetIcon: PropTypes.bool.isRequired,
fullColorEvents: PropTypes.bool.isRequired,
colorImpairedMode: PropTypes.bool.isRequired
};
export default Legend;

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,17 +1,26 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { CalendarStatus } from 'typings/Calendar';
import titleCase from 'Utilities/String/titleCase';
import styles from './LegendItem.css';
function LegendItem(props) {
interface LegendItemProps {
name?: string;
status: CalendarStatus;
tooltip: string;
isAgendaView: boolean;
fullColorEvents: boolean;
colorImpairedMode: boolean;
}
function LegendItem(props: LegendItemProps) {
const {
name,
status,
tooltip,
isAgendaView,
fullColorEvents,
colorImpairedMode
colorImpairedMode,
} = props;
return (
@ -29,13 +38,4 @@ function LegendItem(props) {
);
}
LegendItem.propTypes = {
name: PropTypes.string,
status: PropTypes.string.isRequired,
tooltip: PropTypes.string.isRequired,
isAgendaView: PropTypes.bool.isRequired,
fullColorEvents: PropTypes.bool.isRequired,
colorImpairedMode: PropTypes.bool.isRequired
};
export default LegendItem;

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,276 +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 {
collapseMultipleEpisodes,
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
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('CollapseMultipleEpisodes')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="collapseMultipleEpisodes"
value={collapseMultipleEpisodes}
helpText={translate('CollapseMultipleEpisodesHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowEpisodeInformation')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showEpisodeInformation"
value={showEpisodeInformation}
helpText={translate('ShowEpisodeInformationHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForFinales')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showFinaleIcon"
value={showFinaleIcon}
helpText={translate('IconForFinalesHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForSpecials')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSpecialIcon"
value={showSpecialIcon}
helpText={translate('IconForSpecialsHelpText')}
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 = {
collapseMultipleEpisodes: PropTypes.bool.isRequired,
showEpisodeInformation: PropTypes.bool.isRequired,
showFinaleIcon: PropTypes.bool.isRequired,
showSpecialIcon: PropTypes.bool.isRequired,
showCutoffUnmetIcon: PropTypes.bool.isRequired,
firstDayOfWeek: PropTypes.number.isRequired,
calendarWeekColumnHeader: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
enableColorImpairedMode: PropTypes.bool.isRequired,
fullColorEvents: PropTypes.bool.isRequired,
dispatchSetCalendarOption: PropTypes.func.isRequired,
dispatchSaveUISettings: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CalendarOptionsModalContent;

View file

@ -0,0 +1,228 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import {
firstDayOfWeekOptions,
timeFormatOptions,
weekColumnOptions,
} from 'Settings/UI/UISettings';
import { setCalendarOption } from 'Store/Actions/calendarActions';
import { saveUISettings } from 'Store/Actions/settingsActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { InputChanged } from 'typings/inputs';
import UiSettings from 'typings/Settings/UiSettings';
import translate from 'Utilities/String/translate';
interface CalendarOptionsModalContentProps {
onModalClose: () => void;
}
function CalendarOptionsModalContent({
onModalClose,
}: CalendarOptionsModalContentProps) {
const dispatch = useDispatch();
const {
collapseMultipleEpisodes,
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
fullColorEvents,
} = useSelector((state: AppState) => state.calendar.options);
const uiSettings = useSelector(createUISettingsSelector());
const [state, setState] = useState<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('CollapseMultipleEpisodes')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="collapseMultipleEpisodes"
value={collapseMultipleEpisodes}
helpText={translate('CollapseMultipleEpisodesHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowEpisodeInformation')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showEpisodeInformation"
value={showEpisodeInformation}
helpText={translate('ShowEpisodeInformationHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForFinales')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showFinaleIcon"
value={showFinaleIcon}
helpText={translate('IconForFinalesHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForSpecials')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSpecialIcon"
value={showSpecialIcon}
helpText={translate('IconForSpecialsHelpText')}
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,7 +1,13 @@
/* eslint max-params: 0 */
import moment from 'moment';
import { CalendarStatus } from 'typings/Calendar';
function getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored) {
function getStatusStyle(
hasFile: boolean,
downloading: boolean,
startTime: moment.Moment,
endTime: moment.Moment,
isMonitored: boolean
): CalendarStatus {
const currentTime = moment();
if (hasFile) {

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,222 +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,
premieresOnly,
asAllDay,
tags
} = state;
let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`;
if (unmonitored) {
icalUrl += 'unmonitored=true&';
}
if (premieresOnly) {
icalUrl += 'premieresOnly=true&';
}
if (asAllDay) {
icalUrl += 'asAllDay=true&';
}
if (tags.length) {
icalUrl += `tags=${tags.toString()}&`;
}
icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`;
const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`;
const iCalWebCalUrl = `webcal://${icalUrl}`;
return {
iCalHttpUrl,
iCalWebCalUrl
};
}
class CalendarLinkModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const defaultState = {
unmonitored: false,
premieresOnly: false,
asAllDay: false,
tags: []
};
const urls = getUrls(defaultState);
this.state = {
...defaultState,
...urls
};
}
//
// Listeners
onInputChange = ({ name, value }) => {
const state = {
...this.state,
[name]: value
};
const urls = getUrls(state);
this.setState({
[name]: value,
...urls
});
};
onLinkFocus = (event) => {
event.target.select();
};
//
// Render
render() {
const {
onModalClose
} = this.props;
const {
unmonitored,
premieresOnly,
asAllDay,
tags,
iCalHttpUrl,
iCalWebCalUrl
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('CalendarFeed')}
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('IncludeUnmonitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="unmonitored"
value={unmonitored}
helpText={translate('ICalIncludeUnmonitoredEpisodesHelpText')}
onChange={this.onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SeasonPremieresOnly')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="premieresOnly"
value={premieresOnly}
helpText={translate('ICalSeasonPremieresOnlyHelpText')}
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('ICalTagsSeriesHelpText')}
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,166 @@
import React, { FocusEvent, useCallback, useMemo, useState } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputButton from 'Components/Form/FormInputButton';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import ClipboardButton from 'Components/Link/ClipboardButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
interface CalendarLinkModalContentProps {
onModalClose: () => void;
}
function CalendarLinkModalContent({
onModalClose,
}: CalendarLinkModalContentProps) {
const [state, setState] = useState({
unmonitored: false,
premieresOnly: false,
asAllDay: false,
tags: [],
});
const { unmonitored, premieresOnly, asAllDay, tags } = state;
const handleInputChange = useCallback(({ name, value }: InputChanged) => {
setState((prevState) => ({ ...prevState, [name]: value }));
}, []);
const handleLinkFocus = useCallback(
(event: FocusEvent<HTMLInputElement, Element>) => {
event.target.select();
},
[]
);
const { iCalHttpUrl, iCalWebCalUrl } = useMemo(() => {
let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`;
if (unmonitored) {
icalUrl += 'unmonitored=true&';
}
if (premieresOnly) {
icalUrl += 'premieresOnly=true&';
}
if (asAllDay) {
icalUrl += 'asAllDay=true&';
}
if (tags.length) {
icalUrl += `tags=${tags.toString()}&`;
}
icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`;
return {
iCalHttpUrl: `${window.location.protocol}//${icalUrl}`,
iCalWebCalUrl: `webcal://${icalUrl}`,
};
}, [unmonitored, premieresOnly, asAllDay, tags]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('CalendarFeed')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('IncludeUnmonitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="unmonitored"
value={unmonitored}
helpText={translate('ICalIncludeUnmonitoredEpisodesHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SeasonPremieresOnly')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="premieresOnly"
value={premieresOnly}
helpText={translate('ICalSeasonPremieresOnlyHelpText')}
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('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.SERIES_TAG}
name="tags"
value={tags}
helpText={translate('ICalTagsSeriesHelpText')}
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

@ -14,13 +14,14 @@ function FormInputButton({
className = styles.button,
canSpin = false,
isLastButton = true,
kind = kinds.PRIMARY,
...otherProps
}: FormInputButtonProps) {
if (canSpin) {
return (
<SpinnerButton
className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY}
kind={kind}
{...otherProps}
/>
);
@ -29,7 +30,7 @@ function FormInputButton({
return (
<Button
className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY}
kind={kind}
{...otherProps}
/>
);

View file

@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { FocusEvent, ReactNode } from 'react';
import Link from 'Components/Link/Link';
import { inputTypes } from 'Helpers/Props';
import { InputType } from 'Helpers/Props/inputTypes';
@ -10,6 +10,7 @@ import CaptchaInput from './CaptchaInput';
import CheckInput from './CheckInput';
import { FormInputButtonProps } from './FormInputButton';
import FormInputHelpText from './FormInputHelpText';
import KeyValueListInput from './KeyValueListInput';
import NumberInput from './NumberInput';
import OAuthInput from './OAuthInput';
import PasswordInput from './PasswordInput';
@ -18,6 +19,7 @@ import DownloadClientSelectInput from './Select/DownloadClientSelectInput';
import EnhancedSelectInput from './Select/EnhancedSelectInput';
import IndexerFlagsSelectInput from './Select/IndexerFlagsSelectInput';
import IndexerSelectInput from './Select/IndexerSelectInput';
import LanguageSelectInput from './Select/LanguageSelectInput';
import MonitorEpisodesSelectInput from './Select/MonitorEpisodesSelectInput';
import MonitorNewItemsSelectInput from './Select/MonitorNewItemsSelectInput';
import ProviderDataSelectInput from './Select/ProviderOptionSelectInput';
@ -47,6 +49,12 @@ function getComponent(type: InputType) {
case inputTypes.DEVICE:
return DeviceInput;
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
case inputTypes.LANGUAGE_SELECT:
return LanguageSelectInput;
case inputTypes.MONITOR_EPISODES_SELECT:
return MonitorEpisodesSelectInput;
@ -137,15 +145,18 @@ interface FormInputGroupProps<T> {
autoFocus?: boolean;
includeNoChange?: boolean;
includeNoChangeDisabled?: boolean;
valueOptions?: object;
selectedValueOptions?: object;
indexerFlags?: number;
pending?: boolean;
canEdit?: boolean;
includeAny?: boolean;
delimiters?: string[];
readOnly?: boolean;
errors?: (ValidationMessage | ValidationError)[];
warnings?: (ValidationMessage | ValidationWarning)[];
onChange: (args: T) => void;
onFocus?: (event: FocusEvent<HTMLInputElement>) => void;
}
function FormInputGroup<T>(props: FormInputGroupProps<T>) {
@ -247,14 +258,7 @@ function FormInputGroup<T>(props: FormInputGroupProps<T>) {
{helpLink ? <Link to={helpLink}>{translate('MoreInfo')}</Link> : null}
{errors.map((error, index) => {
return 'message' in error ? (
<FormInputHelpText
key={index}
text={error.message}
isError={true}
isCheckInput={checkInput}
/>
) : (
return 'errorMessage' in error ? (
<FormInputHelpText
key={index}
text={error.errorMessage}
@ -263,23 +267,30 @@ function FormInputGroup<T>(props: FormInputGroupProps<T>) {
isError={true}
isCheckInput={checkInput}
/>
) : (
<FormInputHelpText
key={index}
text={error.message}
isError={true}
isCheckInput={checkInput}
/>
);
})}
{warnings.map((warning, index) => {
return 'message' in warning ? (
return 'errorMessage' in warning ? (
<FormInputHelpText
key={index}
text={warning.message}
text={warning.errorMessage}
link={warning.infoLink}
tooltip={warning.detailedDescription}
isWarning={true}
isCheckInput={checkInput}
/>
) : (
<FormInputHelpText
key={index}
text={warning.errorMessage}
link={warning.infoLink}
tooltip={warning.detailedDescription}
text={warning.message}
isWarning={true}
isCheckInput={checkInput}
/>

View file

@ -0,0 +1,21 @@
.inputContainer {
composes: input from '~Components/Form/Input.css';
position: relative;
min-height: 35px;
height: auto;
&.isFocused {
outline: 0;
border-color: var(--inputFocusBorderColor);
box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), 0 0 8px var(--inputFocusBoxShadowColor);
}
}
.hasError {
composes: hasError from '~Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from '~Components/Form/Input.css';
}

View file

@ -0,0 +1,10 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'hasError': string;
'hasWarning': string;
'inputContainer': string;
'isFocused': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -0,0 +1,104 @@
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { InputOnChange } from 'typings/inputs';
import KeyValueListInputItem from './KeyValueListInputItem';
import styles from './KeyValueListInput.css';
interface KeyValue {
key: string;
value: string;
}
export interface KeyValueListInputProps {
className?: string;
name: string;
value: KeyValue[];
hasError?: boolean;
hasWarning?: boolean;
keyPlaceholder?: string;
valuePlaceholder?: string;
onChange: InputOnChange<KeyValue[]>;
}
function KeyValueListInput({
className = styles.inputContainer,
name,
value = [],
hasError = false,
hasWarning = false,
keyPlaceholder,
valuePlaceholder,
onChange,
}: KeyValueListInputProps): JSX.Element {
const [isFocused, setIsFocused] = useState(false);
const handleItemChange = useCallback(
(index: number | null, itemValue: KeyValue) => {
const newValue = [...value];
if (index === null) {
newValue.push(itemValue);
} else {
newValue.splice(index, 1, itemValue);
}
onChange({ name, value: newValue });
},
[value, name, onChange]
);
const handleRemoveItem = useCallback(
(index: number) => {
const newValue = [...value];
newValue.splice(index, 1);
onChange({ name, value: newValue });
},
[value, name, onChange]
);
const onFocus = useCallback(() => setIsFocused(true), []);
const onBlur = useCallback(() => {
setIsFocused(false);
const newValue = value.reduce((acc: KeyValue[], v) => {
if (v.key || v.value) {
acc.push(v);
}
return acc;
}, []);
if (newValue.length !== value.length) {
onChange({ name, value: newValue });
}
}, [value, name, onChange]);
return (
<div
className={classNames(
className,
isFocused && styles.isFocused,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
>
{[...value, { key: '', value: '' }].map((v, index) => (
<KeyValueListInputItem
key={index}
index={index}
keyValue={v.key}
value={v.value}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
isNew={index === value.length}
onChange={handleItemChange}
onRemove={handleRemoveItem}
onFocus={onFocus}
onBlur={onBlur}
/>
))}
</div>
);
}
export default KeyValueListInput;

View file

@ -0,0 +1,35 @@
.itemContainer {
display: flex;
margin-bottom: 3px;
border-bottom: 1px solid var(--inputBorderColor);
&:last-child {
margin-bottom: 0;
border-bottom: 0;
}
}
.keyInputWrapper {
flex: 1 0 0;
}
.valueInputWrapper {
flex: 1 0 0;
min-width: 40px;
}
.buttonWrapper {
flex: 0 0 22px;
}
.keyInput,
.valueInput {
width: 100%;
border: none;
background-color: transparent;
color: var(--textColor);
&::placeholder {
color: var(--helpTextColor);
}
}

View file

@ -0,0 +1,12 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'buttonWrapper': string;
'itemContainer': string;
'keyInput': string;
'keyInputWrapper': string;
'valueInput': string;
'valueInputWrapper': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -0,0 +1,89 @@
import React, { useCallback } from 'react';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import TextInput from './TextInput';
import styles from './KeyValueListInputItem.css';
interface KeyValueListInputItemProps {
index: number;
keyValue: string;
value: string;
keyPlaceholder?: string;
valuePlaceholder?: string;
isNew: boolean;
onChange: (index: number, itemValue: { key: string; value: string }) => void;
onRemove: (index: number) => void;
onFocus: () => void;
onBlur: () => void;
}
function KeyValueListInputItem({
index,
keyValue,
value,
keyPlaceholder = 'Key',
valuePlaceholder = 'Value',
isNew,
onChange,
onRemove,
onFocus,
onBlur,
}: KeyValueListInputItemProps): JSX.Element {
const handleKeyChange = useCallback(
({ value: keyValue }: { value: string }) => {
onChange(index, { key: keyValue, value });
},
[index, value, onChange]
);
const handleValueChange = useCallback(
({ value }: { value: string }) => {
onChange(index, { key: keyValue, value });
},
[index, keyValue, onChange]
);
const handleRemovePress = useCallback(() => {
onRemove(index);
}, [index, onRemove]);
return (
<div className={styles.itemContainer}>
<div className={styles.keyInputWrapper}>
<TextInput
className={styles.keyInput}
name="key"
value={keyValue}
placeholder={keyPlaceholder}
onChange={handleKeyChange}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
<div className={styles.valueInputWrapper}>
<TextInput
className={styles.valueInput}
name="value"
value={value}
placeholder={valuePlaceholder}
onChange={handleValueChange}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
<div className={styles.buttonWrapper}>
{isNew ? null : (
<IconButton
name={icons.REMOVE}
tabIndex={-1}
onPress={handleRemovePress}
/>
)}
</div>
</div>
);
}
export default KeyValueListInputItem;

View file

@ -16,3 +16,7 @@
height: 35px;
}
.fileBrowserMiddleButton {
composes: middleButton from '~./FormInputButton.css';
}

View file

@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'fileBrowserButton': string;
'fileBrowserMiddleButton': string;
'hasFileBrowser': string;
'inputWrapper': string;
'pathMatch': string;

View file

@ -1,3 +1,4 @@
import classNames from 'classnames';
import React, {
KeyboardEvent,
SyntheticEvent,
@ -29,6 +30,7 @@ interface PathInputProps {
value?: string;
placeholder?: string;
includeFiles: boolean;
hasButton?: boolean;
hasFileBrowser?: boolean;
onChange: (change: InputChanged<string>) => void;
}
@ -96,6 +98,7 @@ export function PathInputInternal(props: PathInputInternalProps) {
value: inputValue = '',
paths,
includeFiles,
hasButton,
hasFileBrowser = true,
onChange,
onFetchPaths,
@ -229,9 +232,12 @@ export function PathInputInternal(props: PathInputInternalProps) {
/>
{hasFileBrowser ? (
<div>
<>
<FormInputButton
className={styles.fileBrowserButton}
className={classNames(
styles.fileBrowserButton,
hasButton && styles.fileBrowserMiddleButton
)}
onPress={handleFileBrowserOpenPress}
>
<Icon name={icons.FOLDER_OPEN} />
@ -245,7 +251,7 @@ export function PathInputInternal(props: PathInputInternalProps) {
onChange={onChange}
onModalClose={handleFileBrowserModalClose}
/>
</div>
</>
) : null}
</div>
);

View file

@ -14,6 +14,8 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.CHECK;
case 'device':
return inputTypes.DEVICE;
case 'keyValueList':
return inputTypes.KEY_VALUE_LIST;
case 'password':
return inputTypes.PASSWORD;
case 'number':
@ -136,6 +138,8 @@ ProviderFieldFormGroup.propTypes = {
type: PropTypes.string.isRequired,
advanced: PropTypes.bool.isRequired,
hidden: PropTypes.string,
isDisabled: PropTypes.bool,
provider: PropTypes.string,
pending: PropTypes.bool.isRequired,
errors: PropTypes.arrayOf(PropTypes.object).isRequired,
warnings: PropTypes.arrayOf(PropTypes.object).isRequired,

View file

@ -29,6 +29,8 @@ import HintedSelectInputOption from './HintedSelectInputOption';
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import styles from './EnhancedSelectInput.css';
const MINIMUM_DISTANCE_FROM_EDGE = 10;
function isArrowKey(keyCode: number) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
@ -189,14 +191,9 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleComputeMaxHeight = useCallback((data: any) => {
const { top, bottom } = data.offsets.reference;
const windowHeight = window.innerHeight;
if (/^botton/.test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE;
return data;
}, []);
@ -233,18 +230,12 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
}, [handleWindowClick]);
const handlePress = useCallback(() => {
if (isOpen) {
removeListener();
} else {
addListener();
}
if (!isOpen && onOpen) {
onOpen();
}
setIsOpen(!isOpen);
}, [isOpen, setIsOpen, addListener, removeListener, onOpen]);
}, [isOpen, setIsOpen, onOpen]);
const handleSelect = useCallback(
(newValue: ArrayElement<V>) => {
@ -411,6 +402,16 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
}
});
useEffect(() => {
if (isOpen) {
addListener();
} else {
removeListener();
}
return removeListener;
}, [isOpen, addListener, removeListener]);
return (
<div>
<Manager>
@ -504,6 +505,10 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
enabled: true,
fn: handleComputeMaxHeight,
},
preventOverflow: {
enabled: true,
boundariesElement: 'viewport',
},
}}
>
{({ ref, style, scheduleUpdate }) => {

View file

@ -1,43 +1,95 @@
import React, { useMemo } from 'react';
import { EnhancedSelectInputChanged } from 'typings/inputs';
import React, { useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
import Language from 'Language/Language';
import createFilteredLanguagesSelector from 'Store/Selectors/createFilteredLanguagesSelector';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput, {
EnhancedSelectInputValue,
} from './EnhancedSelectInput';
interface LanguageSelectInputProps {
interface LanguageSelectInputOnChangeProps {
name: string;
value: number;
values: EnhancedSelectInputValue<number>[];
onChange: (change: EnhancedSelectInputChanged<number>) => void;
value: number | string | Language;
}
function LanguageSelectInput({
values,
interface LanguageSelectInputProps {
name: string;
value: number | string | Language;
includeNoChange: boolean;
includeNoChangeDisabled?: boolean;
includeMixed: boolean;
onChange: (payload: LanguageSelectInputOnChangeProps) => void;
}
export default function LanguageSelectInput({
value,
includeNoChange,
includeNoChangeDisabled,
includeMixed,
onChange,
...otherProps
}: LanguageSelectInputProps) {
const mappedValues = useMemo(() => {
const minId = values.reduce(
(min: number, v) => (v.key < 1 ? v.key : min),
values[0].key
const { items } = useSelector(createFilteredLanguagesSelector(true));
const values = useMemo(() => {
const result: EnhancedSelectInputValue<number | string>[] = items.map(
(item) => {
return {
key: item.id,
value: item.name,
};
}
);
return values.map(({ key, value }) => {
return {
key,
value,
dividerAfter: minId < 1 ? key === minId : false,
};
});
}, [values]);
if (includeNoChange) {
result.unshift({
key: 'noChange',
value: translate('NoChange'),
isDisabled: includeNoChangeDisabled,
});
}
if (includeMixed) {
result.unshift({
key: 'mixed',
value: `(${translate('Mixed')})`,
isDisabled: true,
});
}
return result;
}, [includeNoChange, includeNoChangeDisabled, includeMixed, items]);
const selectValue =
typeof value === 'number' || typeof value === 'string' ? value : value.id;
const handleChange = useCallback(
(payload: LanguageSelectInputOnChangeProps) => {
if (typeof value === 'number') {
onChange(payload);
} else {
const language = items.find((i) => i.id === payload.value);
onChange({
...payload,
value: language
? {
id: language.id,
name: language.name,
}
: ({ id: payload.value } as Language),
});
}
},
[value, items, onChange]
);
return (
<EnhancedSelectInput
{...otherProps}
values={mappedValues}
onChange={onChange}
value={selectValue}
values={values}
onChange={handleChange}
/>
);
}
export default LanguageSelectInput;

View file

@ -3,7 +3,10 @@ import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { addRootFolder } from 'Store/Actions/rootFolderActions';
import {
addRootFolder,
fetchRootFolders,
} from 'Store/Actions/rootFolderActions';
import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
@ -131,7 +134,7 @@ function RootFolderSelectInput({
const handleNewRootFolderSelect = useCallback(
({ value: newValue }: InputChanged<string>) => {
setNewRootFolderPath(newValue);
dispatch(addRootFolder(newValue));
dispatch(addRootFolder({ path: newValue }));
},
[setNewRootFolderPath, dispatch]
);
@ -189,6 +192,10 @@ function RootFolderSelectInput({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
dispatch(fetchRootFolders());
}, [dispatch]);
return (
<>
<EnhancedSelectInput

View file

@ -23,7 +23,7 @@ const VALID_TAG_REGEX = new RegExp('[^-_a-z0-9]', 'i');
function isValidTag(tagName: string) {
try {
return !VALID_TAG_REGEX.test(tagName);
} catch (e) {
} catch {
return false;
}
}

View file

@ -1,8 +1,8 @@
import React, { useCallback } from 'react';
import MiddleTruncate from 'react-middle-truncate';
import Label, { LabelProps } from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import MiddleTruncate from 'Components/MiddleTruncate';
import { icons } from 'Helpers/Props';
import { TagBase } from './TagInput';
import styles from './TagInputTag.css';
@ -58,7 +58,7 @@ function TagInputTag<T extends TagBase>({
tabIndex={-1}
onPress={handleDelete}
>
<MiddleTruncate text={String(tag.name)} start={10} end={10} />
<MiddleTruncate text={String(tag.name)} />
</Link>
{canEdit ? (

View file

@ -1,6 +1,7 @@
import classNames from 'classnames';
import React, {
ChangeEvent,
FocusEvent,
SyntheticEvent,
useCallback,
useEffect,
@ -25,7 +26,7 @@ export interface TextInputProps<T> {
min?: number;
max?: number;
onChange: (change: InputChanged<T> | FileInputChanged) => void;
onFocus?: (event: SyntheticEvent) => void;
onFocus?: (event: FocusEvent) => void;
onBlur?: (event: SyntheticEvent) => void;
onCopy?: (event: SyntheticEvent) => void;
onSelectionChange?: (start: number | null, end: number | null) => void;
@ -94,7 +95,7 @@ function TextInput<T>({
);
const handleFocus = useCallback(
(event: SyntheticEvent) => {
(event: FocusEvent) => {
onFocus?.(event);
selectionChanged();

View file

@ -18,7 +18,7 @@ export interface IconProps
kind?: Extract<Kind, keyof typeof styles>;
size?: number;
isSpinning?: FontAwesomeIconProps['spin'];
title?: string | (() => string);
title?: string | (() => string) | null;
}
export default function Icon({

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

@ -0,0 +1,63 @@
import React, { useEffect, useRef, useState } from 'react';
import useMeasure from 'Helpers/Hooks/useMeasure';
interface MiddleTruncateProps {
text: string;
}
function getTruncatedText(text: string, length: number) {
return `${text.slice(0, length)}...${text.slice(text.length - length)}`;
}
function MiddleTruncate({ text }: MiddleTruncateProps) {
const [containerRef, { width: containerWidth }] = useMeasure();
const [textRef, { width: textWidth }] = useMeasure();
const [truncatedText, setTruncatedText] = useState(text);
const truncatedTextRef = useRef(text);
useEffect(() => {
setTruncatedText(text);
}, [text]);
useEffect(() => {
if (!containerWidth || !textWidth) {
return;
}
if (textWidth <= containerWidth) {
return;
}
const characterLength = textWidth / text.length;
const charactersToRemove =
Math.ceil(text.length - containerWidth / characterLength) + 3;
let length = Math.ceil(text.length / 2 - charactersToRemove / 2);
let updatedText = getTruncatedText(text, length);
// Make sure if the text is still too long, we keep reducing the length
// each time we re-run this.
while (
updatedText.length >= truncatedTextRef.current.length &&
length > 10
) {
length--;
updatedText = getTruncatedText(text, length);
}
// Store the value in the ref so we can compare it in the next render,
// without triggering this effect every time we change the text.
truncatedTextRef.current = updatedText;
setTruncatedText(updatedText);
}, [text, truncatedTextRef, containerWidth, textWidth]);
return (
<div ref={containerRef} style={{ whiteSpace: 'nowrap' }}>
<div ref={textRef} style={{ display: 'inline-block' }}>
{truncatedText}
</div>
</div>
);
}
export default MiddleTruncate;

View file

@ -19,6 +19,7 @@
.modal {
position: relative;
display: flex;
max-width: 90%;
max-height: 90%;
border-radius: 6px;
opacity: 1;

View file

@ -1,7 +1,7 @@
import React, { ComponentPropsWithoutRef } from 'react';
import styles from './TableRowCell.css';
export interface TableRowCellProps extends ComponentPropsWithoutRef<'td'> {}
export type TableRowCellProps = ComponentPropsWithoutRef<'td'>;
export default function TableRowCell({
className = styles.cell,

View file

@ -25,7 +25,9 @@ interface Episode extends ModelBase {
endTime?: string;
grabDate?: string;
seriesTitle?: string;
queued?: boolean;
series?: Series;
finaleType?: string;
}
export default Episode;

View file

@ -19,8 +19,8 @@ import {
clearReleases,
} from 'Store/Actions/releaseActions';
import translate from 'Utilities/String/translate';
import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
import EpisodeHistory from './History/EpisodeHistory';
import EpisodeSearch from './Search/EpisodeSearch';
import SeasonEpisodeNumber from './SeasonEpisodeNumber';
import EpisodeSummary from './Summary/EpisodeSummary';
import styles from './EpisodeDetailsModalContent.css';
@ -168,13 +168,13 @@ function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) {
<TabPanel>
<div className={styles.tabContent}>
<EpisodeHistoryConnector episodeId={episodeId} />
<EpisodeHistory episodeId={episodeId} />
</div>
</TabPanel>
<TabPanel>
{/* Don't wrap in tabContent so we not have a top margin */}
<EpisodeSearchConnector
<EpisodeSearch
episodeId={episodeId}
startInteractiveSearch={startInteractiveSearch}
onModalClose={onModalClose}

View file

@ -1,130 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EpisodeHistoryRow from './EpisodeHistoryRow';
const columns = [
{
name: 'eventType',
isVisible: true
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isVisible: true
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: true
},
{
name: 'quality',
label: () => translate('Quality'),
isVisible: true
},
{
name: 'customFormats',
label: () => translate('CustomFormats'),
isSortable: false,
isVisible: true
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore')
}),
isSortable: true,
isVisible: true
},
{
name: 'date',
label: () => translate('Date'),
isVisible: true
},
{
name: 'actions',
isVisible: true
}
];
class EpisodeHistory extends Component {
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
onMarkAsFailedPress
} = this.props;
const hasItems = !!items.length;
if (isFetching) {
return (
<LoadingIndicator />
);
}
if (!isFetching && !!error) {
return (
<Alert kind={kinds.DANGER}>{translate('EpisodeHistoryLoadError')}</Alert>
);
}
if (isPopulated && !hasItems && !error) {
return (
<Alert kind={kinds.INFO}>{translate('NoEpisodeHistory')}</Alert>
);
}
if (isPopulated && hasItems && !error) {
return (
<Table
columns={columns}
>
<TableBody>
{
items.map((item) => {
return (
<EpisodeHistoryRow
key={item.id}
{...item}
onMarkAsFailedPress={onMarkAsFailedPress}
/>
);
})
}
</TableBody>
</Table>
);
}
return null;
}
}
EpisodeHistory.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onMarkAsFailedPress: PropTypes.func.isRequired
};
EpisodeHistory.defaultProps = {
selectedTab: 'details'
};
export default EpisodeHistory;

View file

@ -0,0 +1,129 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, kinds } from 'Helpers/Props';
import {
clearEpisodeHistory,
episodeHistoryMarkAsFailed,
fetchEpisodeHistory,
} from 'Store/Actions/episodeHistoryActions';
import translate from 'Utilities/String/translate';
import EpisodeHistoryRow from './EpisodeHistoryRow';
const columns: Column[] = [
{
name: 'eventType',
label: '',
isVisible: true,
},
{
name: 'sourceTitle',
label: () => translate('SourceTitle'),
isVisible: true,
},
{
name: 'languages',
label: () => translate('Languages'),
isVisible: true,
},
{
name: 'quality',
label: () => translate('Quality'),
isVisible: true,
},
{
name: 'customFormats',
label: () => translate('CustomFormats'),
isSortable: false,
isVisible: true,
},
{
name: 'customFormatScore',
label: React.createElement(Icon, {
name: icons.SCORE,
title: () => translate('CustomFormatScore'),
}),
isSortable: true,
isVisible: true,
},
{
name: 'date',
label: () => translate('Date'),
isVisible: true,
},
{
name: 'actions',
label: '',
isVisible: true,
},
];
interface EpisodeHistoryProps {
episodeId: number;
}
function EpisodeHistory({ episodeId }: EpisodeHistoryProps) {
const dispatch = useDispatch();
const { items, isFetching, isPopulated, error } = useSelector(
(state: AppState) => state.episodeHistory
);
const handleMarkAsFailedPress = useCallback(
(historyId: number) => {
dispatch(episodeHistoryMarkAsFailed({ historyId, episodeId }));
},
[episodeId, dispatch]
);
const hasItems = !!items.length;
useEffect(() => {
dispatch(fetchEpisodeHistory({ episodeId }));
return () => {
dispatch(clearEpisodeHistory());
};
}, [episodeId, dispatch]);
if (isFetching) {
return <LoadingIndicator />;
}
if (!isFetching && !!error) {
return (
<Alert kind={kinds.DANGER}>{translate('EpisodeHistoryLoadError')}</Alert>
);
}
if (isPopulated && !hasItems && !error) {
return <Alert kind={kinds.INFO}>{translate('NoEpisodeHistory')}</Alert>;
}
if (isPopulated && hasItems && !error) {
return (
<Table columns={columns}>
<TableBody>
{items.map((item) => {
return (
<EpisodeHistoryRow
key={item.id}
{...item}
onMarkAsFailedPress={handleMarkAsFailedPress}
/>
);
})}
</TableBody>
</Table>
);
}
return null;
}
export default EpisodeHistory;

View file

@ -1,63 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearEpisodeHistory, episodeHistoryMarkAsFailed, fetchEpisodeHistory } from 'Store/Actions/episodeHistoryActions';
import EpisodeHistory from './EpisodeHistory';
function createMapStateToProps() {
return createSelector(
(state) => state.episodeHistory,
(episodeHistory) => {
return episodeHistory;
}
);
}
const mapDispatchToProps = {
fetchEpisodeHistory,
clearEpisodeHistory,
episodeHistoryMarkAsFailed
};
class EpisodeHistoryConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchEpisodeHistory({ episodeId: this.props.episodeId });
}
componentWillUnmount() {
this.props.clearEpisodeHistory();
}
//
// Listeners
onMarkAsFailedPress = (historyId) => {
this.props.episodeHistoryMarkAsFailed({ historyId, episodeId: this.props.episodeId });
};
//
// Render
render() {
return (
<EpisodeHistory
{...this.props}
onMarkAsFailedPress={this.onMarkAsFailedPress}
/>
);
}
}
EpisodeHistoryConnector.propTypes = {
episodeId: PropTypes.number.isRequired,
fetchEpisodeHistory: PropTypes.func.isRequired,
clearEpisodeHistory: PropTypes.func.isRequired,
episodeHistoryMarkAsFailed: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeHistoryConnector);

View file

@ -1,177 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import HistoryDetails from 'Activity/History/Details/HistoryDetails';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import styles from './EpisodeHistoryRow.css';
function getTitle(eventType) {
switch (eventType) {
case 'grabbed': return 'Grabbed';
case 'seriesFolderImported': return 'Series Folder Imported';
case 'downloadFolderImported': return 'Download Folder Imported';
case 'downloadFailed': return 'Download Failed';
case 'episodeFileDeleted': return 'Episode File Deleted';
case 'episodeFileRenamed': return 'Episode File Renamed';
default: return 'Unknown';
}
}
class EpisodeHistoryRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isMarkAsFailedModalOpen: false
};
}
//
// Listeners
onMarkAsFailedPress = () => {
this.setState({ isMarkAsFailedModalOpen: true });
};
onConfirmMarkAsFailed = () => {
this.props.onMarkAsFailedPress(this.props.id);
this.setState({ isMarkAsFailedModalOpen: false });
};
onMarkAsFailedModalClose = () => {
this.setState({ isMarkAsFailedModalOpen: false });
};
//
// Render
render() {
const {
eventType,
sourceTitle,
languages,
quality,
qualityCutoffNotMet,
customFormats,
customFormatScore,
date,
data,
downloadId
} = this.props;
const {
isMarkAsFailedModalOpen
} = this.state;
return (
<TableRow>
<HistoryEventTypeCell
eventType={eventType}
data={data}
/>
<TableRowCell>
{sourceTitle}
</TableRowCell>
<TableRowCell>
<EpisodeLanguages languages={languages} />
</TableRowCell>
<TableRowCell>
<EpisodeQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</TableRowCell>
<TableRowCell>
<EpisodeFormats formats={customFormats} />
</TableRowCell>
<TableRowCell>
{formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell>
<RelativeDateCell
date={date}
includeSeconds={true}
includeTime={true}
/>
<TableRowCell className={styles.actions}>
<Popover
anchor={
<Icon
name={icons.INFO}
/>
}
title={getTitle(eventType)}
body={
<HistoryDetails
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
/>
}
position={tooltipPositions.LEFT}
/>
{
eventType === 'grabbed' &&
<IconButton
title={translate('MarkAsFailed')}
name={icons.REMOVE}
size={14}
onPress={this.onMarkAsFailedPress}
/>
}
</TableRowCell>
<ConfirmModal
isOpen={isMarkAsFailedModalOpen}
kind={kinds.DANGER}
title={translate('MarkAsFailed')}
message={translate('MarkAsFailedConfirmation', { sourceTitle })}
confirmLabel={translate('MarkAsFailed')}
onConfirm={this.onConfirmMarkAsFailed}
onCancel={this.onMarkAsFailedModalClose}
/>
</TableRow>
);
}
}
EpisodeHistoryRow.propTypes = {
id: PropTypes.number.isRequired,
eventType: PropTypes.string.isRequired,
sourceTitle: PropTypes.string.isRequired,
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
quality: PropTypes.object.isRequired,
qualityCutoffNotMet: PropTypes.bool.isRequired,
customFormats: PropTypes.arrayOf(PropTypes.object),
customFormatScore: PropTypes.number.isRequired,
date: PropTypes.string.isRequired,
data: PropTypes.object.isRequired,
downloadId: PropTypes.string,
onMarkAsFailedPress: PropTypes.func.isRequired
};
export default EpisodeHistoryRow;

View file

@ -0,0 +1,151 @@
import React, { useCallback, useState } from 'react';
import HistoryDetails from 'Activity/History/Details/HistoryDetails';
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import Popover from 'Components/Tooltip/Popover';
import EpisodeFormats from 'Episode/EpisodeFormats';
import EpisodeLanguages from 'Episode/EpisodeLanguages';
import EpisodeQuality from 'Episode/EpisodeQuality';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import CustomFormat from 'typings/CustomFormat';
import { HistoryData, HistoryEventType } from 'typings/History';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import styles from './EpisodeHistoryRow.css';
function getTitle(eventType: HistoryEventType) {
switch (eventType) {
case 'grabbed':
return 'Grabbed';
case 'seriesFolderImported':
return 'Series Folder Imported';
case 'downloadFolderImported':
return 'Download Folder Imported';
case 'downloadFailed':
return 'Download Failed';
case 'episodeFileDeleted':
return 'Episode File Deleted';
case 'episodeFileRenamed':
return 'Episode File Renamed';
default:
return 'Unknown';
}
}
interface EpisodeHistoryRowProps {
id: number;
eventType: HistoryEventType;
sourceTitle: string;
languages: Language[];
quality: QualityModel;
qualityCutoffNotMet: boolean;
customFormats: CustomFormat[];
customFormatScore: number;
date: string;
data: HistoryData;
downloadId?: string;
onMarkAsFailedPress: (id: number) => void;
}
function EpisodeHistoryRow({
id,
eventType,
sourceTitle,
languages,
quality,
qualityCutoffNotMet,
customFormats,
customFormatScore,
date,
data,
downloadId,
onMarkAsFailedPress,
}: EpisodeHistoryRowProps) {
const [isMarkAsFailedModalOpen, setIsMarkAsFailedModalOpen] = useState(false);
const handleMarkAsFailedPress = useCallback(() => {
setIsMarkAsFailedModalOpen(true);
}, []);
const handleConfirmMarkAsFailed = useCallback(() => {
onMarkAsFailedPress(id);
setIsMarkAsFailedModalOpen(false);
}, [id, onMarkAsFailedPress]);
const handleMarkAsFailedModalClose = useCallback(() => {
setIsMarkAsFailedModalOpen(false);
}, []);
return (
<TableRow>
<HistoryEventTypeCell eventType={eventType} data={data} />
<TableRowCell>{sourceTitle}</TableRowCell>
<TableRowCell>
<EpisodeLanguages languages={languages} />
</TableRowCell>
<TableRowCell>
<EpisodeQuality
quality={quality}
isCutoffNotMet={qualityCutoffNotMet}
/>
</TableRowCell>
<TableRowCell>
<EpisodeFormats formats={customFormats} />
</TableRowCell>
<TableRowCell>
{formatCustomFormatScore(customFormatScore, customFormats.length)}
</TableRowCell>
<RelativeDateCell date={date} includeSeconds={true} includeTime={true} />
<TableRowCell className={styles.actions}>
<Popover
anchor={<Icon name={icons.INFO} />}
title={getTitle(eventType)}
body={
<HistoryDetails
eventType={eventType}
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
/>
}
position={tooltipPositions.LEFT}
/>
{eventType === 'grabbed' && (
<IconButton
title={translate('MarkAsFailed')}
name={icons.REMOVE}
size={14}
onPress={handleMarkAsFailedPress}
/>
)}
</TableRowCell>
<ConfirmModal
isOpen={isMarkAsFailedModalOpen}
kind={kinds.DANGER}
title={translate('MarkAsFailed')}
message={translate('MarkAsFailedConfirmation', { sourceTitle })}
confirmLabel={translate('MarkAsFailed')}
onConfirm={handleConfirmMarkAsFailed}
onCancel={handleMarkAsFailedModalClose}
/>
</TableRow>
);
}
export default EpisodeHistoryRow;

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