Compare commits

...

311 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
Weblate
4e9ef57e3d Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: mytelegrambot <lacsonluxur@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ko/
Translation: Servarr/Sonarr
2024-11-03 20:54:14 -08:00
Bogdan
59f3be0813 Show a series path as example in Mount Health Check 2024-11-03 20:53:48 -08:00
Mark McDowall
fb540040ef New: Filter queue by status
Closes #7196
2024-11-03 20:53:38 -08:00
Mark McDowall
b8af3af9f1 Fixed: Filtering queue by multiple qualities 2024-11-03 20:53:38 -08:00
Mark McDowall
78cf13d341 Increase retries for DebouncerFixture 2024-11-03 20:53:23 -08:00
Mark McDowall
978349e241 New: Reject files during import that have no audio tracks
Closes #7298
2024-11-03 20:53:23 -08:00
Mark McDowall
a77bf64352 New: Monitor New Seasons column for series list
Closes #7311
2024-11-03 20:53:14 -08:00
Bogdan
832de3e75e Fixed: Root folder existence for import lists health check 2024-11-03 20:53:05 -08:00
Bogdan
8d4ba77b12 Fixed: New values for custom filters 2024-11-03 20:52:47 -08:00
Bogdan
409823c7e8 Fixed: Interactive searches when using Escape to close previous searches 2024-11-03 20:51:31 -08:00
Aviad Levy
8e636d7a37 Fixed: Telegram notification link text 2024-11-03 20:51:19 -08:00
Bogdan
38c0135d7c Fixed: Loading queue with pending releases for deleted series 2024-11-03 20:49:55 -08:00
Bogdan
22005dc8c5 Fixed: Cleaning the French preposition 'à' from titles 2024-11-03 20:49:22 -08:00
Mark McDowall
73208e2f60 New: Include source path with Webhook import event episode file 2024-11-03 20:49:12 -08:00
Mark McDowall
1df0ba9e5a Fixed: Use download client name for history column 2024-11-03 20:49:03 -08:00
Mark McDowall
020ed32fcf Use current time for cache break in development 2024-11-03 20:48:56 -08:00
Mark McDowall
3ddc6ac6de New: Favorite folders in Manual Import
Closes #5891
2024-11-03 20:48:56 -08:00
Mark McDowall
0f225b05c0 Rename Manage Custom Formats to Manage Formats 2024-11-03 20:48:42 -08:00
Mark McDowall
e006b40532 New: Add individual edit to Manage Custom Formats
Closes #5905
2024-11-03 20:48:42 -08:00
Mark McDowall
e88f25d3bf Fixed: Parse version after quality in renamed files
Closes #7302
2024-11-03 20:48:29 -08:00
Mark McDowall
1fcfb88d2a New: Use instance name in PWA manifest
Closes #7315
2024-11-03 20:48:16 -08:00
Weblate
804eaa1227 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Lars <lars.erik.heloe@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: http://translate.servarr.com/projects/servarr/sonarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nb_NO/
Translation: Servarr/Sonarr
2024-11-03 20:48:10 -08:00
Bogdan
c41e3ce1e3 Update paths mapping translations for series specific 2024-10-26 14:54:40 -07:00
Mark McDowall
682d2b4e1b
Convert Form Components to TypeScript 2024-10-26 14:54:23 -07:00
Weblate
c114e2ddb7 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translation: Servarr/Sonarr
2024-10-26 14:54:07 -07:00
BarbUk
f8a879f4c1 Update System.Text.Json to version 6.0.10 2024-10-26 14:22:31 -07:00
Bogdan
33139d4b53
Fixed: Status check for completed directories in Deluge 2024-10-26 14:22:16 -07:00
Mark McDowall
de69d8ec7e Update JetBrains logos 2024-10-26 14:21:06 -07:00
Mark McDowall
03b9c957b8
New: Episode mappings in .plexmatch metadata files
Closes #5784
2024-10-26 14:20:55 -07:00
Mark McDowall
41ddacc395 New: Improve parsing absolute followed by standard numbering
Closes #7246
2024-10-26 14:20:41 -07:00
Mark McDowall
8a558b379a New: Maintain '...' in naming format
Closes #7290
2024-10-26 14:20:19 -07:00
Bogdan
240a0339be Fixed: Changing series to another root folder without moving files 2024-10-26 14:20:11 -07:00
Bogdan
ff724b7f40
Fixed: Initial state for qBittorrent v5.0 2024-10-26 14:19:47 -07:00
Bogdan
fcf68d9259 Fix settings fetching failure for updates 2024-10-26 14:19:19 -07:00
Bogdan
404e6d68ea Cleanse exceptions in event logs 2024-10-26 14:18:46 -07:00
Bogdan
df672487cf Improve message for grab errors due to no matching tags
Co-authored-by: zakary <zak@ary.dev>
2024-10-26 14:18:38 -07:00
Bogdan
0bc4903954 Inherit trigger from pushed command models 2024-10-26 14:18:28 -07:00
Bogdan
10b55bbee6
Fixed: Natural sorting for tags list in the UI
Closes #7295
2024-10-26 14:18:11 -07:00
Bogdan
20ef22be94
New: Real time UI updates for provider changes 2024-10-26 14:17:46 -07:00
Bogdan
57534db2f8
New: Display tags on import list cards 2024-10-26 14:17:18 -07:00
Bogdan
1e89a1a3cb Include exception message in SkyHook failure message 2024-10-26 14:15:53 -07:00
Bogdan
f502eaffe3 Bump frontend packages 2024-10-26 14:15:42 -07:00
Bogdan
fe40d83aa4
Fixed: Dedupe releases for single daily and anime episode searches
Closes #7288
2024-10-26 14:15:27 -07:00
Bogdan
07374de747
Fixed: Matched alternative titles and tags in series search results 2024-10-26 14:14:58 -07:00
Hadrien Patte
135b5c2ddd
Use OperatingSystem class to get OS information 2024-10-26 14:14:20 -07:00
Weblate
0784f56b9a Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ardenet <1213193613@qq.com>
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/zh_CN/
Translation: Servarr/Sonarr
2024-10-26 14:14:05 -07:00
Mark McDowall
562e0dd7c0 Bump version to 4.0.10 2024-10-25 17:33:17 -07:00
Weblate
28599f87af Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: DNArjen <dna.visser@gmail.com>
Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: JoseFilipeFerreira <jose.filipe.matos.ferreira@gmail.com>
Co-authored-by: Kuzmich <kuzmich55@gmail.com>
Co-authored-by: Lars <lars.erik.heloe@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: anne <gagatebis@hotmail.com>
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/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt/
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/ru/
Translation: Servarr/Sonarr
2024-10-25 17:33:09 -07:00
Sonarr
86446a7686 Automated API Docs update
ignore-downstream
2024-10-07 15:36:01 -07:00
Bogdan
2f1793d87a Filename examples specific for daily and anime naming 2024-10-07 15:35:54 -07:00
Bogdan
a641f2897a Convert Naming options to TypeScript 2024-10-07 15:35:54 -07:00
Bogdan
32fa63d24d Convert FormInputButton to TypeScript 2024-10-07 15:35:54 -07:00
Weblate
ebfa000375 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translation: Servarr/Sonarr
2024-10-07 15:31:53 -07:00
Mark McDowall
39074b0b1d New: Use 307 redirect for requests missing URL Base
Closes #7262
2024-10-07 15:29:59 -07:00
Mark McDowall
354ed96572 Fixed: Ignore free space check before grabbing if directory is missing
Closes #7273
2024-10-07 15:29:51 -07:00
Bogdan
c8f419b014 Use the first allowed quality for cutoff met rejection message with disabled upgrades 2024-10-07 15:29:44 -07:00
Bogdan
a001216957
Fixed: Cleaning paths for top level root folders 2024-10-07 18:29:30 -04:00
Bogdan
a6735e7a3f Fixed: Manual importing to nested series folders 2024-10-07 15:28:47 -07:00
Bogdan
ea0bfed700 Fixed: Validate path on series update 2024-10-07 15:27:53 -07:00
Bogdan
620220b269 Add new category for FL 2024-10-07 15:27:43 -07:00
Bogdan
c435fcd685
Fixed: Error updating providers with ID missing from JSON 2024-10-07 18:27:22 -04:00
Bogdan
3828e475cc Fixed: Copy to clipboard in non-secure contexts 2024-10-07 15:26:21 -07:00
Bogdan
e6e1078c15 Convert Release Profiles to TypeScript 2024-10-07 15:26:13 -07:00
Jared Ledvina
6660db22ec
Recompare file size after import file if necessary 2024-10-07 18:25:52 -04:00
Weblate
bc0fc623ee Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Ardenet <1213193613@qq.com>
Co-authored-by: Mathias <mathias@rodilbach.dk>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: angelsky11 <angelsky11@gmail.com>
Co-authored-by: jsain <josip.sain@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-10-07 15:24:30 -07:00
Mark McDowall
da610a1f40 New: Parse 'BEN THE MAN' release group
Closes #7255
2024-09-27 17:27:50 -07:00
Mark McDowall
6d0f10b877 Fixed: Ignore extra spaces in path when not running on Windows
Closes #7251
2024-09-27 17:27:37 -07:00
Mark McDowall
4f0e1c54c1 Fixed: Don't reject revision upgrades if profile doesn't allow upgrades 2024-09-27 17:27:27 -07:00
Bogdan
2f0ca42341
New: Ignore '.DS_Store' and '.unmanic' files 2024-09-27 20:27:17 -04:00
Bogdan
768af433d1 Display naming example errors when all fields are empty 2024-09-27 17:26:47 -07:00
Bogdan
8bf0298227 Fix translation for Custom Colon Replacement label 2024-09-27 17:26:47 -07:00
Robin Dadswell
a7cb264cc8
Fixed: Telegram log message including token 2024-09-27 20:26:29 -04:00
Bogdan
10302323af
Fixed: Parsing of Hybrid-Remux as Remux 2024-09-27 20:26:04 -04:00
Mark McDowall
dc1524c64f Fixed: Loading series images after placeholder in Safari 2024-09-27 17:25:30 -07:00
Mark McDowall
4d7a3d0909 New: Errors sending Telegram notifications when links aren't available
Closes #7240
2024-09-27 17:25:21 -07:00
Bogdan
30a52d11aa Fixed: Sorting queue by columns
Sort allowed keys

Co-authored-by: Mark McDowall <markus.mcd5@gmail.com>
2024-09-27 17:25:14 -07:00
Weblate
be4a9e9491 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: liuwqq <843384478@qq.com>
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/zh_CN/
Translation: Servarr/Sonarr
2024-09-27 17:25:02 -07:00
Sonarr
e196c1be69 Automated API Docs update
ignore-downstream
2024-09-21 10:32:16 -07:00
Mark McDowall
106ffd410c New: Persist sort in Select Episodes modal
Closes #7233
2024-09-21 10:17:09 -07:00
Mark McDowall
c199fd05d3 Fixed: Don't set last write time on episode files if difference is within the same second
Closes #7228
2024-09-21 10:16:59 -07:00
Mark McDowall
75fae9262c Update src/Sonarr.Http/Authentication/AuthenticationBuilderExtensions.cs
Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>
2024-09-21 10:16:52 -07:00
Mark McDowall
faf9173b3b Fixed: Unable to login when instance name contained brackets
Closes #7229
2024-09-21 10:16:52 -07:00
Bogdan
0fa8e24f48 New: Fetch up to 1000 series from Plex Watchlist 2024-09-21 10:16:43 -07:00
Mark McDowall
27da041388 Fixed: Reprocessing manual import items unable to detect sample
Closes #7221
2024-09-21 10:16:24 -07:00
Mark McDowall
ca38a9b577 Fixed: Aggregating media files with 576p resolution 2024-09-21 10:16:17 -07:00
Mark McDowall
4b72a0a4e8 Fixed: Rejections for Custom Format score increment 2024-09-21 10:16:17 -07:00
Mark McDowall
9875e550a8 Fixed: Adding Bluray 576p to some profiles 2024-09-21 10:16:17 -07:00
ManiMatter
c9aa59340c
Add 'includeSeries' and 'includeEpisodeFile' to Episode API endpoint 2024-09-21 13:16:05 -04:00
momo
30c36fdc3b
Fix description for API key as query parameter 2024-09-21 13:15:51 -04:00
Mark McDowall
3976e5daf7 Fixed: Interactive searches causing multiple requests to indexers 2024-09-21 10:12:13 -07:00
Bogdan
fca8c36156
Guard against using invalid sort keys 2024-09-21 13:12:01 -04:00
Stevie Robinson
85f53e8cb1
New: Parse KCRT as release group
Closes #7214
2024-09-21 13:10:44 -04:00
Weblate
a73a5cc85c Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: FloatStream <1213193613@qq.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
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/zh_CN/
Translation: Servarr/Sonarr
2024-09-21 10:10:20 -07:00
Mark McDowall
89d730cdfd Fixed: Links for Trakt and TVMaze in Gotify notifications 2024-09-21 10:10:03 -07:00
Treycos
99fc52039f
Convert ClipboardButton to TypeScript 2024-09-21 13:09:55 -04:00
Weblate
e6bd58453a Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
Translation: Servarr/Sonarr
2024-09-15 10:26:43 -07:00
Sonarr
9603f0b086 Automated API Docs update
ignore-downstream
2024-09-15 10:23:52 -07:00
Mark McDowall
d84c450094 New: Add exception to SSL Certificate validation message
Closes #7198
2024-09-15 10:23:29 -07:00
Mark McDowall
97ebaf2796 New: Use instance name in forms authentication cookie name
Closes #7199
2024-09-15 10:23:22 -07:00
Stevie Robinson
31bf9e313e
New: Add rating as option in sort dropdown on series overviews and posters views 2024-09-15 13:23:12 -04:00
Stevie Robinson
6cccacd4d7
Add workflow to close issue when labelled as support 2024-09-15 13:22:28 -04:00
Mark McDowall
3c857135c5 Gotify notification updates
New: Option to include links for Gotify notifications
New: Include images and links for Android
Closes #7190
2024-09-15 10:21:26 -07:00
Mark McDowall
750a9353f8 New: Add additional archive exentions
Closes #7191
2024-09-15 10:21:16 -07:00
Mark McDowall
71a19377d9
New: Add Bluray 576p quality
Closes #6203
2024-09-15 13:21:01 -04:00
Mark McDowall
4b5ff3927d
New: Check for available space before grabbing
Closes #7177
2024-09-15 13:20:42 -04:00
Mark McDowall
4d8a443681 Fixed: Replace illegal characters even when renaming is disabled
Closes #7183
2024-09-15 10:20:19 -07:00
Bogdan
6a332b40ac Fixed: Refresh tags after updating autotags 2024-09-15 10:20:19 -07:00
Bogdan
a929548ae3 Fixed: Linking autotags with tag specification to all tags 2024-09-15 10:20:19 -07:00
Mark McDowall
55363f4e3d Fixed: Don't parse language from series title for v2 releases
Closes #7182
2024-09-15 10:20:19 -07:00
Mark McDowall
f20ac9dc34 Fixed: Series links not opening on iOS 2024-09-15 10:20:13 -07:00
somniumV
8b20a9449c
New: Minimum Upgrade Score for Custom Formats
Closes #6800
2024-09-15 13:20:03 -04:00
Robert Dailey
24f03fc1e9
Add 'qualitydefinition/limits' endpoint to get size limitations 2024-09-15 13:19:08 -04:00
Weblate
5513d7bc5d Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: FloatStream <1213193613@qq.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Kuzmich55 <kuzmich55@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: genoher <genoher@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/
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/zh_CN/
Translation: Servarr/Sonarr
2024-09-15 10:17:38 -07:00
Mark McDowall
a9072ac460 Convert Progress Bars to TypeScript 2024-09-03 20:19:47 -07:00
Mark McDowall
55aaaa5c40
New: Add MDBList link to series details
Closes #7162
2024-09-03 23:19:36 -04:00
Mark McDowall
ee99c3895d Convert series images to TypeScript 2024-09-03 20:19:12 -07:00
Mark McDowall
e1e10e195c Convert NoSeries to TypeScript 2024-09-03 20:19:12 -07:00
Mark McDowall
0b9a212f33 Fixed: Links tooltip closing too quickly 2024-09-03 20:19:12 -07:00
Mark McDowall
0e384ee3aa New: Include seasons and episodes in Trakt import lists
Closes #7137
2024-09-03 20:18:59 -07:00
Sonarr
d903529389 Automated API Docs update
ignore-downstream
2024-09-02 13:27:43 -07:00
Mark McDowall
6f51e72d00
Fixed: Respect Quality cutoff if Custom Format cutoff isn't met
Closes #7132
2024-09-02 13:27:21 -07:00
Bogdan
66cead6b48 Cleanup History Details and a typo 2024-09-02 13:27:00 -07:00
Mark McDowall
7f0696c574 Fixed: Failing to import any file for series if one has bad encoding
Closes #7157
2024-09-02 13:26:50 -07:00
Mark McDowall
1584311914
New: Except language option for Language Custom Formats
Closes #7120
2024-09-02 13:26:35 -07:00
amdavie
278c7891a3
New: Scene and Nuked IndexerFlags for Newznab indexers
Closes #6932
2024-09-02 13:25:53 -07:00
Bogdan
0a0e03dca0 Convert Interactive Search to TypeScript 2024-09-02 13:25:05 -07:00
ManiMatter
546e9fd1d0
New: Last Searched column on Wanted screens 2024-09-02 13:24:55 -07:00
Weblate
c80bd81bb9 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Nota Inutilis <hugo@notainutilis.fr>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-09-02 13:24:04 -07:00
Mark McDowall
e1cbc4a782 Convert Components to TypeScript 2024-08-30 20:26:38 -07:00
Bogdan
53d8c9ba8d Fixed: Importing files without media info available 2024-08-30 20:26:22 -07:00
Bogdan
9136ee4ad9
Fixed: Forbid empty spaces in Release Profile restrictions 2024-08-30 23:25:32 -04:00
Bogdan
44fab9a96c
Fixed: Generating absolute episode file paths in webhook events
Closes #7149
2024-08-30 23:24:08 -04:00
Weblate
66e4b7c819 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Dream <seth.gecko.rr@gmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: MattiaPell <mattiapellegrini16@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: 极染 <poledye@icloud.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/it/
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/zh_CN/
Translation: Servarr/Sonarr
2024-08-30 20:21:48 -07:00
Bogdan
98c4cbdd13 Don't persist value for SslCertHash when checking for existence 2024-08-26 21:42:10 -07:00
Treycos
25d9f09a43
Convert SpinnerIcon to TypeScript 2024-08-27 00:41:58 -04:00
Treycos
7ea1301221
Convert TableRowCell to Typescript 2024-08-27 00:41:30 -04:00
Treycos
f033799d7a
Convert IconButton to Typescript 2024-08-27 00:41:10 -04:00
Mark McDowall
cfa2f4d4c6
Fixed: Queue header 2024-08-27 00:40:43 -04:00
Sonarr
882b54be61 Automated API Docs update
ignore-downstream
2024-08-26 21:40:30 -07:00
Bogdan
041fdd3929 Convert Episode and Season search to TypeScript
Co-authored-by: Mark McDowall <markus.mcd5@gmail.com>
2024-08-26 21:40:22 -07:00
Sonarr
4548dcdf97 Automated API Docs update
ignore-downstream
2024-08-25 17:27:45 -07:00
Bogdan
4e14ce022c New: Bulk manage custom formats 2024-08-25 17:27:30 -07:00
Bogdan
a9b93dd9c6 Fixed: Paths for renamed episode files in Custom Script and Webhook 2024-08-25 17:24:52 -07:00
Bogdan
50d7e8fed4 Fixed: Hide reboot and shutdown UI buttons on docker 2024-08-25 17:24:40 -07:00
Bogdan
402db9128c New: Bypass IP addresses ranges in proxies 2024-08-25 17:24:30 -07:00
bakerboy448
846333ddf0
Fixed: Trim spaces and empty values in Proxy Bypass List 2024-08-25 20:24:16 -04:00
Bogdan
dde28cbd7e Fix disabled style for monitor toggle button 2024-08-25 17:23:33 -07:00
Bogdan
8ceb306bf1
Fixed: Ensure Root Folder exists when Adding Series 2024-08-25 20:23:24 -04:00
Treycos
8af4246ff9
Updated code action fixall value for VSCode 2024-08-25 20:22:42 -04:00
Treycos
a2e06e9e65
Link polymorphic static typing 2024-08-25 20:21:50 -04:00
Treycos
ae7b187e41
Convert Icon to Typescript 2024-08-25 20:21:06 -04:00
Treycos
63b4998c8e
Convert Button to TypeScript 2024-08-25 20:20:52 -04:00
Mark McDowall
45665886d6 Bump version to 4.0.9 2024-08-25 16:52:30 -07:00
Weblate
860424ac22 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: Jason54 <jason54700.jg@gmail.com>
Co-authored-by: Kerk en IT <info@kerkenit.nl>
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/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nl/
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-08-25 16:52:16 -07:00
Mark McDowall
14005d8d10 Fixed: Limit redirects after login to local paths 2024-08-20 16:09:53 -07:00
Mark McDowall
da7d17f5e8 Fixed: PWA Manifest images
Closes #7125
2024-08-20 16:09:46 -07:00
Sonarr
ea331feb88 Automated API Docs update
ignore-downstream
2024-08-18 19:03:51 -07:00
Treycos
7dca9060ca
Convert SeriesTitleLink to TypeScript 2024-08-18 19:01:32 -07:00
kephasdev
8af12cc4e7
Fixed: Calculating Custom Formats with languages in queue 2024-08-18 19:00:55 -07:00
Bogdan
aa488019cf Bump babel packages 2024-08-18 19:00:01 -07:00
Bogdan
47a05ecb36 Use autoprefixer in UI build 2024-08-18 19:00:01 -07:00
martylukyy
35baebaf72
New: Configure log file size limit in UI 2024-08-18 18:59:43 -07:00
Mark McDowall
aedcd046fc
Fixed: PWA Manifest with URL base
Closes #7107
2024-08-18 18:58:29 -07:00
Bogdan
f45713bff8
Remove provider status on provider deletion 2024-08-18 18:58:10 -07:00
Mark McDowall
911a3d4c1e New: Parse spanish multi-episode releases 2024-08-18 18:57:25 -07:00
Mark McDowall
e16ace54a8 New: Optionally include Custom Format Score for Discord On File Import notifications 2024-08-18 18:57:17 -07:00
Stevie Robinson
84710a31bd
New: Track Kometa metadata files
Closes #6851
2024-08-18 18:57:04 -07:00
Weblate
093a239e77 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: Gabriel Markowski <gmarkowski62@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: YangForever88 <1026097197@qq.com>
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/pl/
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/zh_CN/
Translation: Servarr/Sonarr
2024-08-18 18:56:50 -07:00
Bogdan
ee69351733 Fixed: Switch to series rating for Discord notifications 2024-08-18 18:55:40 -07:00
Bogdan
e92a67ad78
New: Show indicator on poster for deleted series 2024-08-18 18:55:26 -07:00
Treycos
3eca63a67c
Convert Label to TypeScript 2024-08-18 18:54:30 -07:00
Treycos
8484a8beba
Convert First Run to TypeScript 2024-08-18 18:52:04 -07:00
Sonarr
cd3a1c18ab Automated API Docs update
ignore-downstream
2024-08-14 20:24:03 -07:00
967 changed files with 39886 additions and 24356 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.8
VERSION: 4.0.16
jobs:
backend:

29
.github/workflows/support-requests.yml vendored Normal file
View file

@ -0,0 +1,29 @@
name: 'Support Requests'
on:
issues:
types: [labeled, unlabeled, reopened]
permissions:
issues: write
jobs:
action:
runs-on: ubuntu-latest
if: github.repository == 'Sonarr/Sonarr'
steps:
- uses: dessant/support-requests@v4
with:
github-token: ${{ github.token }}
support-label: 'support'
issue-comment: >
:wave: @{issue-author}, we use the issue tracker exclusively
for bug reports and feature requests. However, this issue appears
to be a support request. Please use one of the support channels:
[forums](https://forums.sonarr.tv/), [subreddit](https://www.reddit.com/r/sonarr/),
[discord](https://discord.gg/Ex7FmFK), or [IRC](https://web.libera.chat/?channels=#sonarr)
for support/questions.
close-issue: true
issue-close-reason: 'not planned'
lock-issue: false
issue-lock-reason: 'off-topic'

3
.gitignore vendored
View file

@ -162,3 +162,6 @@ src/.idea/
# API doc generation
.config/
# Ignore Jetbrains IntelliJ Workspace Directories
.idea/

View file

@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="70px" height="70px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="-1.3318" y1="43.7371" x2="67.0419" y2="26.0967">
<stop offset="0.1237" style="stop-color:#7866FF"/>
<stop offset="0.5376" style="stop-color:#FE2EB6"/>
<stop offset="0.8548" style="stop-color:#FD0486"/>
</linearGradient>
<polygon style="fill:url(#SVGID_1_);" points="67.3,16 43.7,0 0,31.1 11.1,70 58.9,60.3 "/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="45.9148" y1="38.9098" x2="67.6577" y2="9.0989">
<stop offset="0.1237" style="stop-color:#FF0080"/>
<stop offset="0.2587" style="stop-color:#FE0385"/>
<stop offset="0.4109" style="stop-color:#FA0C92"/>
<stop offset="0.5713" style="stop-color:#F41BA9"/>
<stop offset="0.7363" style="stop-color:#EB2FC8"/>
<stop offset="0.8656" style="stop-color:#E343E6"/>
</linearGradient>
<polygon style="fill:url(#SVGID_2_);" points="67.3,16 43.7,0 38,15.7 38,47.8 70,47.8 "/>
</g>
<g>
<rect x="13.4" y="13.4" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.4" y="48.5" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<g>
<path style="fill:#FFFFFF;" d="M17.4,19.1h6.9c5.6,0,9.5,3.8,9.5,8.9V28c0,5-3.9,8.9-9.5,8.9h-6.9V19.1z M21.4,22.7v10.7h3
c3.2,0,5.4-2.2,5.4-5.3V28c0-3.2-2.2-5.4-5.4-5.4H21.4z"/>
<polygon style="fill:#FFFFFF;" points="40.3,22.7 34.9,22.7 34.9,19.1 49.6,19.1 49.6,22.7 44.2,22.7 44.2,37 40.3,37 "/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1,66 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="120.1px" height="130.2px" viewBox="0 0 120.1 130.2" style="enable-background:new 0 0 120.1 130.2;" xml:space="preserve"
>
<g>
<linearGradient id="XMLID_2_" gradientUnits="userSpaceOnUse" x1="31.8412" y1="120.5578" x2="110.2402" y2="73.24">
<stop offset="0" style="stop-color:#FCEE39"/>
<stop offset="1" style="stop-color:#F37B3D"/>
</linearGradient>
<path id="XMLID_3041_" style="fill:url(#XMLID_2_);" d="M118.6,71.8c0.9-0.8,1.4-1.9,1.5-3.2c0.1-2.6-1.8-4.7-4.4-4.9
c-1.2-0.1-2.4,0.4-3.3,1.1l0,0l-83.8,45.9c-1.9,0.8-3.6,2.2-4.7,4.1c-2.9,4.8-1.3,11,3.6,13.9c3.4,2,7.5,1.8,10.7-0.2l0,0l0,0
c0.2-0.2,0.5-0.3,0.7-0.5l78-54.8C117.3,72.9,118.4,72.1,118.6,71.8L118.6,71.8L118.6,71.8z"/>
<linearGradient id="XMLID_3_" gradientUnits="userSpaceOnUse" x1="48.3607" y1="6.9083" x2="119.9179" y2="69.5546">
<stop offset="0" style="stop-color:#EF5A6B"/>
<stop offset="0.57" style="stop-color:#F26F4E"/>
<stop offset="1" style="stop-color:#F37B3D"/>
</linearGradient>
<path id="XMLID_3049_" style="fill:url(#XMLID_3_);" d="M118.8,65.1L118.8,65.1L55,2.5C53.6,1,51.6,0,49.3,0
c-4.3,0-7.7,3.5-7.7,7.7v0c0,2.1,0.8,3.9,2.1,5.3l0,0l0,0c0.4,0.4,0.8,0.7,1.2,1l67.4,57.7l0,0c0.8,0.7,1.8,1.2,3,1.3
c2.6,0.1,4.7-1.8,4.9-4.4C120.2,67.3,119.7,66,118.8,65.1z"/>
<linearGradient id="XMLID_4_" gradientUnits="userSpaceOnUse" x1="52.9467" y1="63.6407" x2="10.5379" y2="37.1562">
<stop offset="0" style="stop-color:#7C59A4"/>
<stop offset="0.3852" style="stop-color:#AF4C92"/>
<stop offset="0.7654" style="stop-color:#DC4183"/>
<stop offset="0.957" style="stop-color:#ED3D7D"/>
</linearGradient>
<path id="XMLID_3042_" style="fill:url(#XMLID_4_);" d="M57.1,59.5C57,59.5,17.7,28.5,16.9,28l0,0l0,0c-0.6-0.3-1.2-0.6-1.8-0.9
c-5.8-2.2-12.2,0.8-14.4,6.6c-1.9,5.1,0.2,10.7,4.6,13.4l0,0l0,0C6,47.5,6.6,47.8,7.3,48c0.4,0.2,45.4,18.8,45.4,18.8l0,0
c1.8,0.8,3.9,0.3,5.1-1.2C59.3,63.7,59,61,57.1,59.5z"/>
<linearGradient id="XMLID_5_" gradientUnits="userSpaceOnUse" x1="52.1736" y1="3.7019" x2="10.7706" y2="37.8971">
<stop offset="0" style="stop-color:#EF5A6B"/>
<stop offset="0.364" style="stop-color:#EE4E72"/>
<stop offset="1" style="stop-color:#ED3D7D"/>
</linearGradient>
<path id="XMLID_3057_" style="fill:url(#XMLID_5_);" d="M49.3,0c-1.7,0-3.3,0.6-4.6,1.5L4.9,28.3c-0.1,0.1-0.2,0.1-0.2,0.2l-0.1,0
l0,0c-1.7,1.2-3.1,3-3.9,5.1C-1.5,39.4,1.5,45.9,7.3,48c3.6,1.4,7.5,0.7,10.4-1.4l0,0l0,0c0.7-0.5,1.3-1,1.8-1.6l34.6-31.2l0,0
c1.8-1.4,3-3.6,3-6.1v0C57.1,3.5,53.6,0,49.3,0z"/>
<g id="XMLID_3008_">
<rect id="XMLID_3033_" x="34.6" y="37.4" style="fill:#000000;" width="51" height="51"/>
<rect id="XMLID_3032_" x="39" y="78.8" style="fill:#FFFFFF;" width="19.1" height="3.2"/>
<g id="XMLID_3009_">
<path id="XMLID_3030_" style="fill:#FFFFFF;" d="M38.8,50.8l1.5-1.4c0.4,0.5,0.8,0.8,1.3,0.8c0.6,0,0.9-0.4,0.9-1.2l0-5.3l2.3,0
l0,5.3c0,1-0.3,1.8-0.8,2.3c-0.5,0.5-1.3,0.8-2.3,0.8C40.2,52.2,39.4,51.6,38.8,50.8z"/>
<path id="XMLID_3028_" style="fill:#FFFFFF;" d="M45.3,43.8l6.7,0v1.9l-4.4,0V47l4,0l0,1.8l-4,0l0,1.3l4.5,0l0,2l-6.7,0
L45.3,43.8z"/>
<path id="XMLID_3026_" style="fill:#FFFFFF;" d="M55,45.8l-2.5,0l0-2l7.3,0l0,2l-2.5,0l0,6.3l-2.3,0L55,45.8z"/>
<path id="XMLID_3022_" style="fill:#FFFFFF;" d="M39,54l4.3,0c1,0,1.8,0.3,2.3,0.7c0.3,0.3,0.5,0.8,0.5,1.4v0
c0,1-0.5,1.5-1.3,1.9c1,0.3,1.6,0.9,1.6,2v0c0,1.4-1.2,2.3-3.1,2.3l-4.3,0L39,54z M43.8,56.6c0-0.5-0.4-0.7-1-0.7l-1.5,0l0,1.5
l1.4,0C43.4,57.3,43.8,57.1,43.8,56.6L43.8,56.6z M43,59l-1.8,0l0,1.5H43c0.7,0,1.1-0.3,1.1-0.8v0C44.1,59.2,43.7,59,43,59z"/>
<path id="XMLID_3019_" style="fill:#FFFFFF;" d="M46.8,54l3.9,0c1.3,0,2.1,0.3,2.7,0.9c0.5,0.5,0.7,1.1,0.7,1.9v0
c0,1.3-0.7,2.1-1.7,2.6l2,2.9l-2.6,0l-1.7-2.5h-1l0,2.5l-2.3,0L46.8,54z M50.6,58c0.8,0,1.2-0.4,1.2-1v0c0-0.7-0.5-1-1.2-1
l-1.5,0v2H50.6z"/>
<path id="XMLID_3016_" style="fill:#FFFFFF;" d="M56.8,54l2.2,0l3.5,8.4l-2.5,0l-0.6-1.5l-3.2,0l-0.6,1.5l-2.4,0L56.8,54z
M58.8,59l-0.9-2.3L57,59L58.8,59z"/>
<path id="XMLID_3014_" style="fill:#FFFFFF;" d="M62.8,54l2.3,0l0,8.3l-2.3,0L62.8,54z"/>
<path id="XMLID_3012_" style="fill:#FFFFFF;" d="M65.7,54l2.1,0l3.4,4.4l0-4.4l2.3,0l0,8.3l-2,0L68,57.8l0,4.6l-2.3,0L65.7,54z"
/>
<path id="XMLID_3010_" style="fill:#FFFFFF;" d="M73.7,61.1l1.3-1.5c0.8,0.7,1.7,1,2.7,1c0.6,0,1-0.2,1-0.6v0
c0-0.4-0.3-0.5-1.4-0.8c-1.8-0.4-3.1-0.9-3.1-2.6v0c0-1.5,1.2-2.7,3.2-2.7c1.4,0,2.5,0.4,3.4,1.1l-1.2,1.6
c-0.8-0.5-1.6-0.8-2.3-0.8c-0.6,0-0.8,0.2-0.8,0.5v0c0,0.4,0.3,0.5,1.4,0.8c1.9,0.4,3.1,1,3.1,2.6v0c0,1.7-1.3,2.7-3.4,2.7
C76.1,62.5,74.7,62,73.7,61.1z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

View file

@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="70px" height="70px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="22.9451" y1="75.7869" x2="74.7868" y2="20.6415">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.4044" style="stop-color:#C41E57"/>
<stop offset="0.4677" style="stop-color:#C41E57"/>
<stop offset="0.6505" style="stop-color:#EB8523"/>
<stop offset="0.9516" style="stop-color:#FEBD11"/>
</linearGradient>
<polygon style="fill:url(#SVGID_1_);" points="49.8,15.2 36,36.7 58.4,70 70,23.1 "/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="17.7187" y1="73.2922" x2="69.5556" y2="18.1519">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.4044" style="stop-color:#C41E57"/>
<stop offset="0.4677" style="stop-color:#C41E57"/>
<stop offset="0.7043" style="stop-color:#EB8523"/>
</linearGradient>
<polygon style="fill:url(#SVGID_2_);" points="51.1,15.7 49,0 18.8,33.6 27.6,42.3 20.8,70 58.4,70 "/>
</g>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="1.8281" y1="53.4275" x2="48.8245" y2="9.2255">
<stop offset="1.612903e-002" style="stop-color:#B35BA3"/>
<stop offset="0.6613" style="stop-color:#C41E57"/>
</linearGradient>
<polygon style="fill:url(#SVGID_3_);" points="49,0 11.6,0 0,47.1 55.6,47.1 "/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="49.8935" y1="-11.5569" x2="48.8588" y2="24.0352">
<stop offset="0.5" style="stop-color:#C41E57"/>
<stop offset="0.6668" style="stop-color:#D13F48"/>
<stop offset="0.7952" style="stop-color:#D94F39"/>
<stop offset="0.8656" style="stop-color:#DD5433"/>
</linearGradient>
<polygon style="fill:url(#SVGID_4_);" points="55.3,47.1 51.1,15.7 49,0 41.7,23 "/>
</g>
<g>
<rect x="13.4" y="13.5" transform="matrix(-1 2.577289e-003 -2.577289e-003 -1 70.0288 70.081)" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.6" y="48.6" transform="matrix(1 -2.577289e-003 2.577289e-003 1 -0.1287 6.634109e-002)" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<path style="fill:#FFFFFF;" d="M17.4,19.1l8.2,0c2.3,0,4,0.6,5.2,1.8c1,1,1.5,2.4,1.5,4.1l0,0.1c0,1.5-0.3,2.6-1.1,3.5
c-0.7,0.9-1.6,1.6-2.8,2l4.4,6.4l-4.6,0l-3.7-5.5l-3.3,0l0,5.5l-3.9,0L17.4,19.1z M25.3,27.8c1,0,1.7-0.2,2.2-0.7
c0.5-0.5,0.8-1.1,0.8-1.8l0-0.1c0-0.9-0.3-1.5-0.8-1.9c-0.5-0.4-1.3-0.6-2.3-0.6l-3.9,0l0,5.1L25.3,27.8z"/>
<path style="fill:#FFFFFF;" d="M36,33.2l-1.9,0l0-3.3l2.5,0l0.6-3.8l-2.3,0l0-3.3l2.8,0l0.6-3.7l3.4,0l-0.6,3.7l3.7,0l0.6-3.7
l3.4,0l-0.6,3.7l1.9,0l0,3.3l-2.5,0L47,29.9l2.3,0l0,3.3l-2.8,0L45.8,37l-3.4,0l0.7-3.8l-3.7,0L38.7,37l-3.4,0L36,33.2z
M43.7,29.9l0.6-3.8l-3.7,0L40,29.9L43.7,29.9z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 70 70" style="enable-background:new 0 0 70 70;" xml:space="preserve">
<g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="1.7738" y1="31.2729" x2="40.1662" y2="31.2729">
<stop offset="0" style="stop-color:#905CFB"/>
<stop offset="6.772543e-002" style="stop-color:#776CF9"/>
<stop offset="0.1729" style="stop-color:#5681F7"/>
<stop offset="0.2865" style="stop-color:#3B92F5"/>
<stop offset="0.4097" style="stop-color:#269FF4"/>
<stop offset="0.5474" style="stop-color:#17A9F3"/>
<stop offset="0.7111" style="stop-color:#0FAEF2"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_1_);" d="M39.7,47.9l-6.1-34c-0.4-2.4-1.2-4.8-2.7-7.1c-2-3.2-5.2-5.4-8.8-6.3
C7.9-2.9-2.6,11.3,3.6,23.9c0,0,0,0,0,0l14.8,31.7c0.4,1,1,2,1.7,2.9c1.2,1.6,2.8,2.8,4.7,3.4C34.4,64.9,42.1,56.4,39.7,47.9z"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="5.3113" y1="9.6691" x2="69.2278" y2="43.8664">
<stop offset="0" style="stop-color:#905CFB"/>
<stop offset="6.772543e-002" style="stop-color:#776CF9"/>
<stop offset="0.1729" style="stop-color:#5681F7"/>
<stop offset="0.2865" style="stop-color:#3B92F5"/>
<stop offset="0.4097" style="stop-color:#269FF4"/>
<stop offset="0.5474" style="stop-color:#17A9F3"/>
<stop offset="0.7111" style="stop-color:#0FAEF2"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_2_);" d="M67.4,26.5c-1.4-2.2-3.4-3.9-5.7-4.9L25.5,1.7l0,0c-1-0.5-2.1-1-3.3-1.3
C6.7-3.2-4.4,13.8,5.5,27c1.5,2,3.6,3.6,6,4.5L48,47.9c0.8,0.5,1.6,0.8,2.5,1.1C64.5,53.4,75.1,38.6,67.4,26.5z"/>
<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="-19.2836" y1="70.8198" x2="55.9833" y2="33.1863">
<stop offset="0" style="stop-color:#3BEA62"/>
<stop offset="0.117" style="stop-color:#31DE80"/>
<stop offset="0.3025" style="stop-color:#24CEA8"/>
<stop offset="0.4844" style="stop-color:#1AC1C9"/>
<stop offset="0.6592" style="stop-color:#12B7DF"/>
<stop offset="0.8238" style="stop-color:#0EB2ED"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_3_);" d="M67.4,26.5c-1.8-2.8-4.6-4.8-7.9-5.6c-3.5-0.8-6.8-0.5-9.6,0.7L11.4,36.1
c0,0-0.2,0.1-0.6,0.4C0.9,40.4-4,53.3,4,64c1.8,2.4,4.3,4.2,7.1,5c5.3,1.6,10.1,1,14-1.1c0,0,0.1,0,0.1,0l37.6-20.1
c0,0,0,0,0.1-0.1C69.5,43.9,72.6,34.6,67.4,26.5z"/>
<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="38.9439" y1="5.8503" x2="5.4232" y2="77.5093">
<stop offset="0" style="stop-color:#3BEA62"/>
<stop offset="9.397750e-002" style="stop-color:#2FDB87"/>
<stop offset="0.196" style="stop-color:#24CEA8"/>
<stop offset="0.3063" style="stop-color:#1BC3C3"/>
<stop offset="0.4259" style="stop-color:#14BAD8"/>
<stop offset="0.5596" style="stop-color:#10B5E7"/>
<stop offset="0.7185" style="stop-color:#0DB1EF"/>
<stop offset="0.9677" style="stop-color:#0CB0F2"/>
</linearGradient>
<path style="fill:url(#SVGID_4_);" d="M50.3,12.8c1.2-2.7,1.1-6-0.9-9c-1.1-1.8-2.9-3-4.9-3.5c-4.5-1.1-8.3,1-10.1,4.2L3.5,42
c0,0,0,0,0,0.1C-0.9,47.9-1.6,56.5,4,64c1.8,2.4,4.3,4.2,7.1,5c10.5,3.3,19.3-2.5,22.1-10.8L50.3,12.8z"/>
</g>
<g>
<rect x="13.4" y="13.4" style="fill:#000000;" width="43.2" height="43.2"/>
<rect x="17.5" y="48.5" style="fill:#FFFFFF;" width="16.2" height="2.7"/>
<polygon style="fill:#FFFFFF;" points="22.9,22.7 17.5,22.7 17.5,19.1 32.3,19.1 32.3,22.7 26.8,22.7 26.8,37 22.9,37 "/>
<path style="fill:#FFFFFF;" d="M32.5,28.1L32.5,28.1c0-5.1,3.8-9.3,9.3-9.3c3.4,0,5.4,1.1,7.1,2.8l-2.5,2.9c-1.4-1.3-2.8-2-4.6-2
c-3,0-5.2,2.5-5.2,5.6V28c0,3.1,2.1,5.6,5.2,5.6c2,0,3.3-0.8,4.7-2.1l2.5,2.5c-1.8,2-3.9,3.2-7.3,3.2
C36.4,37.3,32.5,33.2,32.5,28.1"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4 KiB

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)
@ -33,7 +33,7 @@ Note: GitHub Issues are for Bugs and Feature Requests Only
- Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc.
- Automatically detects new episodes
- Can scan your existing library and download any missing episodes
- Can watch for better quality of the episodes you already have and do an automatic upgrade. *eg. from DVD to Blu-Ray*
- Can watch for better quality of the episodes you already have and do an automatic upgrade. _eg. from DVD to Blu-Ray_
- Automatic failed download handling will try another release if one fails
- Manual search so you can pick any release or to see why a release was not downloaded automatically
- Fully configurable episode renaming
@ -69,13 +69,17 @@ This project would not be possible without the support of our users and software
#### JetBrains
Thank you to [<img src="/Logo/Jetbrains/jetbrains.svg" alt="JetBrains" width="32"> JetBrains](http://www.jetbrains.com/) for providing us with free licenses to their great tools
Thank you to [<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.png" alt="JetBrains" width="96">](http://www.jetbrains.com/) for providing us with free licenses to their great tools
* [<img src="/Logo/Jetbrains/teamcity.svg" alt="TeamCity" width="32"> TeamCity](http://www.jetbrains.com/teamcity/)
* [<img src="/Logo/Jetbrains/resharper.svg" alt="ReSharper" width="32"> ReSharper](http://www.jetbrains.com/resharper/)
* [<img src="/Logo/Jetbrains/dottrace.svg" alt="dotTrace" width="32"> dotTrace](http://www.jetbrains.com/dottrace/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/TeamCity.png" alt="TeamCity" width="64">](http://www.jetbrains.com/teamcity/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/ReSharper.png" alt="ReSharper" width="64">](http://www.jetbrains.com/resharper/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/dotTrace.png" alt="dotTrace" width="64">](http://www.jetbrains.com/dottrace/)
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/Rider.png" alt="Rider" width="64">](http://www.jetbrains.com/rider/)
### Licenses
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- Copyright 2010-2023
- Copyright 2010-2024

View file

@ -210,7 +210,6 @@ module.exports = {
'no-undef-init': 'off',
'no-undefined': 'off',
'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }],
'no-use-before-define': 'error',
// Node.js and CommonJS
@ -364,7 +363,11 @@ module.exports = {
{
args: 'after-used',
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true
}
],
'@typescript-eslint/explicit-function-return-type': 'off',

View file

@ -9,7 +9,7 @@
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll": true
"source.fixAll": "explicit"
},
"typescript.preferences.quoteStyle": "single",

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,
@ -134,6 +134,12 @@ module.exports = (env) => {
{
source: 'frontend/src/Content/robots.txt',
destination: path.join(distFolder, 'Content/robots.txt')
},
// manifest.json and browserconfig.xml
{
source: 'frontend/src/Content/*.(json|xml)',
destination: path.join(distFolder, 'Content')
}
]
}
@ -181,7 +187,7 @@ module.exports = (env) => {
loose: true,
debug: false,
useBuiltIns: 'entry',
corejs: 3
corejs: '3.39'
}
]
]

View file

@ -16,6 +16,7 @@ const mixinsFiles = [
module.exports = {
plugins: [
'autoprefixer',
['postcss-mixins', {
mixinsFiles
}],

View file

@ -27,8 +27,6 @@ interface HistoryDetailsProps {
sourceTitle: string;
data: HistoryData;
downloadId?: string;
shortDateFormat: string;
timeFormat: string;
}
function HistoryDetails(props: HistoryDetailsProps) {
@ -43,6 +41,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
indexer,
releaseGroup,
seriesMatchType,
releaseSource,
customFormatScore,
nzbInfoUrl,
downloadClient,
@ -55,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
@ -90,6 +114,14 @@ function HistoryDetails(props: HistoryDetailsProps) {
/>
) : null}
{releaseSource ? (
<DescriptionListItem
descriptionClassName={styles.description}
title={translate('ReleaseSource')}
data={releaseSourceMessage}
/>
) : null}
{nzbInfoUrl ? (
<span>
<DescriptionListItemTitle>

View file

@ -38,8 +38,6 @@ interface HistoryDetailsModalProps {
data: HistoryData;
downloadId?: string;
isMarkingAsFailed: boolean;
shortDateFormat: string;
timeFormat: string;
onMarkAsFailedPress: () => void;
onModalClose: () => void;
}
@ -52,8 +50,6 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
data,
downloadId,
isMarkingAsFailed = false,
shortDateFormat,
timeFormat,
onMarkAsFailedPress,
onModalClose,
} = props;
@ -69,8 +65,6 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
sourceTitle={sourceTitle}
data={data}
downloadId={downloadId}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
/>
</ModalBody>

View file

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import IconButton from 'Components/Link/IconButton';
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
@ -20,7 +20,6 @@ import { QualityModel } from 'Quality/Quality';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CustomFormat from 'typings/CustomFormat';
import { HistoryData, HistoryEventType } from 'typings/History';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
@ -72,10 +71,6 @@ function HistoryRow(props: HistoryRowProps) {
const series = useSeries(seriesId);
const episode = useEpisode(episodeId, 'episodes');
const { shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const handleDetailsPress = useCallback(() => {
@ -200,9 +195,14 @@ function HistoryRow(props: HistoryRowProps) {
}
if (name === 'downloadClient') {
const downloadClientName =
'downloadClientName' in data ? data.downloadClientName : null;
const downloadClient =
'downloadClient' in data ? data.downloadClient : null;
return (
<TableRowCell key={name} className={styles.downloadClient}>
{'downloadClient' in data ? data.downloadClient : ''}
{downloadClientName ?? downloadClient ?? ''}
</TableRowCell>
);
}
@ -260,8 +260,6 @@ function HistoryRow(props: HistoryRowProps) {
data={data}
downloadId={downloadId}
isMarkingAsFailed={isMarkingAsFailed}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
onMarkAsFailedPress={handleMarkAsFailedPress}
onModalClose={handleDetailsModalClose}
/>

View file

@ -1,8 +1,8 @@
import React from 'react';
import Icon from 'Components/Icon';
import Icon, { IconProps } from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds } from 'Helpers/Props';
import TooltipPosition from 'Helpers/Props/TooltipPosition';
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
import {
QueueTrackedDownloadState,
QueueTrackedDownloadStatus,
@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) {
// status === 'downloading'
let iconName = icons.DOWNLOADING;
let iconKind = kinds.DEFAULT;
let iconKind: IconProps['kind'] = kinds.DEFAULT;
let title = translate('Downloading');
if (status === 'paused') {

View file

@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
@ -129,7 +130,8 @@ class AddNewSeries extends Component {
<div className={styles.helpText}>
{translate('AddNewSeriesError')}
</div>
<div>{getErrorMessage(error)}</div>
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
</div> : null
}

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,9 +13,12 @@ interface AppProps {
history: ConnectedRouterProps['history'];
}
const queryClient = new QueryClient();
function App({ store, history }: AppProps) {
return (
<DocumentTitle title={window.Sonarr.instanceName}>
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme />
@ -23,6 +27,7 @@ function App({ store, history }: AppProps) {
</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/SortDirection';
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

@ -1,17 +1,23 @@
import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CaptchaAppState from './CaptchaAppState';
import CommandAppState from './CommandAppState';
import EpisodeFilesAppState from './EpisodeFilesAppState';
import EpisodesAppState from './EpisodesAppState';
import HistoryAppState from './HistoryAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
import OAuthAppState from './OAuthAppState';
import ParseAppState from './ParseAppState';
import PathsAppState from './PathsAppState';
import ProviderOptionsAppState from './ProviderOptionsAppState';
import QueueAppState from './QueueAppState';
import ReleasesAppState from './ReleasesAppState';
import RootFolderAppState from './RootFolderAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
import SettingsAppState from './SettingsAppState';
import SystemAppState from './SystemAppState';
import TagsAppState from './TagsAppState';
import WantedAppState from './WantedAppState';
interface FilterBuilderPropOption {
id: string;
@ -48,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;
};
@ -61,20 +69,27 @@ interface AppState {
app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState;
captcha: CaptchaAppState;
commands: CommandAppState;
episodes: EpisodesAppState;
episodeFiles: EpisodeFilesAppState;
episodeHistory: HistoryAppState;
episodes: EpisodesAppState;
episodesSelection: EpisodesAppState;
history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
oAuth: OAuthAppState;
parse: ParseAppState;
paths: PathsAppState;
providerOptions: ProviderOptionsAppState;
queue: QueueAppState;
releases: ReleasesAppState;
rootFolders: RootFolderAppState;
series: SeriesAppState;
seriesIndex: SeriesIndexAppState;
settings: SettingsAppState;
system: SystemAppState;
tags: TagsAppState;
wanted: WantedAppState;
}
export default AppState;

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,11 @@
interface CaptchaAppState {
refreshing: false;
token: string;
siteKey: unknown;
secretToken: unknown;
ray: unknown;
stoken: unknown;
responseUrl: unknown;
}
export default CaptchaAppState;

View file

@ -1,11 +1,20 @@
import AppSectionState from 'App/State/AppSectionState';
import RecentFolder from 'InteractiveImport/Folder/RecentFolder';
import ImportMode from 'InteractiveImport/ImportMode';
import InteractiveImport from 'InteractiveImport/InteractiveImport';
interface FavoriteFolder {
folder: string;
}
interface RecentFolder {
folder: string;
lastUsed: string;
}
interface InteractiveImportAppState extends AppSectionState<InteractiveImport> {
originalItems: InteractiveImport[];
importMode: ImportMode;
favoriteFolders: FavoriteFolder[];
recentFolders: RecentFolder[];
}

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

@ -0,0 +1,9 @@
import { Error } from './AppSectionState';
interface OAuthAppState {
authorizing: boolean;
result: Record<string, unknown> | null;
error: Error;
}
export default OAuthAppState;

View file

@ -0,0 +1,29 @@
interface BasePath {
name: string;
path: string;
size: number;
lastModified: string;
}
interface File extends BasePath {
type: 'file';
}
interface Folder extends BasePath {
type: 'folder';
}
export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent';
export type Path = File | Folder;
interface PathsAppState {
currentPath: string;
isFetching: boolean;
isPopulated: boolean;
error: Error;
directories: Folder[];
files: File[];
parent: string | null;
}
export default PathsAppState;

View file

@ -0,0 +1,22 @@
import AppSectionState from 'App/State/AppSectionState';
import Field, { FieldSelectOption } from 'typings/Field';
export interface ProviderOptions {
fields?: Field[];
}
interface ProviderOptionsDevice {
id: string;
name: string;
}
interface ProviderOptionsAppState {
devices: AppSectionState<ProviderOptionsDevice>;
servers: AppSectionState<FieldSelectOption<unknown>>;
newznabCategories: AppSectionState<FieldSelectOption<unknown>>;
getProfiles: AppSectionState<FieldSelectOption<unknown>>;
getTags: AppSectionState<FieldSelectOption<unknown>>;
getRootFolders: AppSectionState<FieldSelectOption<unknown>>;
}
export default ProviderOptionsAppState;

View file

@ -0,0 +1,10 @@
import AppSectionState, {
AppSectionFilterState,
} from 'App/State/AppSectionState';
import Release from 'typings/Release';
interface ReleasesAppState
extends AppSectionState<Release>,
AppSectionFilterState<Release> {}
export default ReleasesAppState;

View file

@ -3,7 +3,7 @@ import AppSectionState, {
AppSectionSaveState,
} from 'App/State/AppSectionState';
import Column from 'Components/Table/Column';
import SortDirection from 'Helpers/Props/SortDirection';
import { SortDirection } from 'Helpers/Props/sortDirections';
import Series from 'Series/Series';
import { Filter, FilterBuilderProp } from './AppState';
@ -59,6 +59,8 @@ interface SeriesAppState
deleteOptions: {
addImportListExclusion: boolean;
};
pendingChanges: Partial<Series>;
}
export default SeriesAppState;

View file

@ -6,6 +6,7 @@ import AppSectionState, {
PagedAppSectionState,
} from 'App/State/AppSectionState';
import Language from 'Language/Language';
import CustomFormat from 'typings/CustomFormat';
import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList';
import ImportListExclusion from 'typings/ImportListExclusion';
@ -15,7 +16,11 @@ import IndexerFlag from 'typings/IndexerFlag';
import Notification from 'typings/Notification';
import QualityProfile from 'typings/QualityProfile';
import General from 'typings/Settings/General';
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>,
@ -24,7 +29,15 @@ export interface DownloadClientAppState
isTestingAll: boolean;
}
export type GeneralAppState = AppSectionItemState<General>;
export interface GeneralAppState
extends AppSectionItemState<General>,
AppSectionSaveState {}
export interface NamingAppState
extends AppSectionItemState<NamingConfig>,
AppSectionSaveState {}
export type NamingExamplesAppState = AppSectionItemState<NamingExample>;
export interface ImportListAppState
extends AppSectionState<ImportList>,
@ -46,6 +59,17 @@ export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>,
AppSectionItemSchemaState<QualityProfile> {}
export interface ReleaseProfilesAppState
extends AppSectionState<ReleaseProfile>,
AppSectionSaveState {
pendingChanges: Partial<ReleaseProfile>;
}
export interface CustomFormatAppState
extends AppSectionState<CustomFormat>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface ImportListOptionsSettingsAppState
extends AppSectionItemState<ImportListOptionsSettings>,
AppSectionSaveState {}
@ -64,6 +88,7 @@ export type UiSettingsAppState = AppSectionItemState<UiSettings>;
interface SettingsAppState {
advancedSettings: boolean;
customFormats: CustomFormatAppState;
downloadClients: DownloadClientAppState;
general: GeneralAppState;
importListExclusions: ImportListExclusionsSettingsAppState;
@ -72,8 +97,12 @@ interface SettingsAppState {
indexerFlags: IndexerFlagSettingsAppState;
indexers: IndexerAppState;
languages: LanguageSettingsAppState;
metadata: MetadataAppState;
naming: NamingAppState;
namingExamples: NamingExamplesAppState;
notifications: NotificationAppState;
qualityProfiles: QualityProfilesAppState;
releaseProfiles: ReleaseProfilesAppState;
ui: UiSettingsAppState;
}

View file

@ -0,0 +1,13 @@
import AppSectionState from 'App/State/AppSectionState';
import Episode from 'Episode/Episode';
type WantedCutoffUnmetAppState = AppSectionState<Episode>;
type WantedMissingAppState = AppSectionState<Episode>;
interface WantedAppState {
cutoffUnmet: WantedCutoffUnmetAppState;
missing: WantedMissingAppState;
}
export default WantedAppState;

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';
@ -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 &&
{iconsToShow.length > 1 ? (
<div>
{iconsToShow[1]}
{iconsToShow[2]}
</div>
}
{
iconsToShow.length > 3 &&
<div>
{iconsToShow[3]}
</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

@ -26,6 +26,7 @@ export interface CommandBody {
seriesId?: number;
seriesIds?: number[];
seasonNumber?: number;
episodeIds?: number[];
[key: string]: string | number | boolean | number[] | undefined;
}

View file

@ -1,34 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
import styles from './Alert.css';
function Alert(props) {
const { className, kind, children, ...otherProps } = props;
return (
<div
className={classNames(
className,
styles[kind]
)}
{...otherProps}
>
{children}
</div>
);
}
Alert.propTypes = {
className: PropTypes.string,
kind: PropTypes.oneOf(kinds.all),
children: PropTypes.node.isRequired
};
Alert.defaultProps = {
className: styles.alert,
kind: kinds.INFO
};
export default Alert;

View file

@ -0,0 +1,18 @@
import classNames from 'classnames';
import React from 'react';
import { Kind } from 'Helpers/Props/kinds';
import styles from './Alert.css';
interface AlertProps {
className?: string;
kind?: Extract<Kind, keyof typeof styles>;
children: React.ReactNode;
}
function Alert(props: AlertProps) {
const { className = styles.alert, kind = 'info', children } = props;
return <div className={classNames(className, styles[kind])}>{children}</div>;
}
export default Alert;

View file

@ -1,60 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
import styles from './Card.css';
class Card extends Component {
//
// Render
render() {
const {
className,
overlayClassName,
overlayContent,
children,
onPress
} = this.props;
if (overlayContent) {
return (
<div className={className}>
<Link
className={styles.underlay}
onPress={onPress}
/>
<div className={overlayClassName}>
{children}
</div>
</div>
);
}
return (
<Link
className={className}
onPress={onPress}
>
{children}
</Link>
);
}
}
Card.propTypes = {
className: PropTypes.string.isRequired,
overlayClassName: PropTypes.string.isRequired,
overlayContent: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired,
onPress: PropTypes.func.isRequired
};
Card.defaultProps = {
className: styles.card,
overlayClassName: styles.overlay,
overlayContent: false
};
export default Card;

View file

@ -0,0 +1,39 @@
import React from 'react';
import Link, { LinkProps } from 'Components/Link/Link';
import styles from './Card.css';
interface CardProps extends Pick<LinkProps, 'onPress'> {
// TODO: Consider using different properties for classname depending if it's overlaying content or not
className?: string;
overlayClassName?: string;
overlayContent?: boolean;
children: React.ReactNode;
}
function Card(props: CardProps) {
const {
className = styles.card,
overlayClassName = styles.overlay,
overlayContent = false,
children,
onPress,
} = props;
if (overlayContent) {
return (
<div className={className}>
<Link className={styles.underlay} onPress={onPress} />
<div className={overlayClassName}>{children}</div>
</div>
);
}
return (
<Link className={className} onPress={onPress}>
{children}
</Link>
);
}
export default Card;

View file

@ -1,138 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import styles from './CircularProgressBar.css';
class CircularProgressBar extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
progress: 0
};
}
componentDidMount() {
this._progressStep();
}
componentDidUpdate(prevProps) {
const progress = this.props.progress;
if (prevProps.progress !== progress) {
this._cancelProgressStep();
this._progressStep();
}
}
componentWillUnmount() {
this._cancelProgressStep();
}
//
// Control
_progressStep() {
this.requestAnimationFrame = window.requestAnimationFrame(() => {
this.setState({
progress: this.state.progress + 1
}, () => {
if (this.state.progress < this.props.progress) {
this._progressStep();
}
});
});
}
_cancelProgressStep() {
if (this.requestAnimationFrame) {
window.cancelAnimationFrame(this.requestAnimationFrame);
}
}
//
// Render
render() {
const {
className,
containerClassName,
size,
strokeWidth,
strokeColor,
showProgressText
} = this.props;
const progress = this.state.progress;
const center = size / 2;
const radius = center - strokeWidth;
const circumference = Math.PI * (radius * 2);
const sizeInPixels = `${size}px`;
const strokeDashoffset = ((100 - progress) / 100) * circumference;
const progressText = `${Math.round(progress)}%`;
return (
<div
className={containerClassName}
style={{
width: sizeInPixels,
height: sizeInPixels,
lineHeight: sizeInPixels
}}
>
<svg
className={className}
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
>
<circle
fill="transparent"
r={radius}
cx={center}
cy={center}
strokeDasharray={circumference}
style={{
stroke: strokeColor,
strokeWidth,
strokeDashoffset
}}
/>
</svg>
{
showProgressText &&
<div className={styles.circularProgressBarText}>
{progressText}
</div>
}
</div>
);
}
}
CircularProgressBar.propTypes = {
className: PropTypes.string,
containerClassName: PropTypes.string,
size: PropTypes.number,
progress: PropTypes.number.isRequired,
strokeWidth: PropTypes.number,
strokeColor: PropTypes.string,
showProgressText: PropTypes.bool
};
CircularProgressBar.defaultProps = {
className: styles.circularProgressBar,
containerClassName: styles.circularProgressBarContainer,
size: 60,
strokeWidth: 5,
strokeColor: '#35c5f4',
showProgressText: false
};
export default CircularProgressBar;

View file

@ -0,0 +1,99 @@
import React, { useCallback, useEffect, useState } from 'react';
import styles from './CircularProgressBar.css';
interface CircularProgressBarProps {
className?: string;
containerClassName?: string;
size?: number;
progress: number;
strokeWidth?: number;
strokeColor?: string;
showProgressText?: boolean;
}
function CircularProgressBar({
className = styles.circularProgressBar,
containerClassName = styles.circularProgressBarContainer,
size = 60,
strokeWidth = 5,
strokeColor = '#35c5f4',
showProgressText = false,
progress,
}: CircularProgressBarProps) {
const [currentProgress, setCurrentProgress] = useState(0);
const raf = React.useRef<number>(0);
const center = size / 2;
const radius = center - strokeWidth;
const circumference = Math.PI * (radius * 2);
const sizeInPixels = `${size}px`;
const strokeDashoffset = ((100 - currentProgress) / 100) * circumference;
const progressText = `${Math.round(currentProgress)}%`;
const handleAnimation = useCallback(
(p: number) => {
setCurrentProgress((prevProgress) => {
if (prevProgress < p) {
return prevProgress + Math.min(1, p - prevProgress);
}
return prevProgress;
});
},
[setCurrentProgress]
);
useEffect(() => {
if (progress > currentProgress) {
cancelAnimationFrame(raf.current);
raf.current = requestAnimationFrame(() => handleAnimation(progress));
}
}, [progress, currentProgress, handleAnimation]);
useEffect(
() => {
return () => cancelAnimationFrame(raf.current);
},
// We only want to run this effect once
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<div
className={containerClassName}
style={{
width: sizeInPixels,
height: sizeInPixels,
lineHeight: sizeInPixels,
}}
>
<svg
className={className}
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
>
<circle
fill="transparent"
r={radius}
cx={center}
cy={center}
strokeDasharray={circumference}
style={{
stroke: strokeColor,
strokeWidth,
strokeDashoffset,
}}
/>
</svg>
{showProgressText && (
<div className={styles.circularProgressBarText}>{progressText}</div>
)}
</div>
);
}
export default CircularProgressBar;

View file

@ -1,33 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import styles from './DescriptionList.css';
class DescriptionList extends Component {
//
// Render
render() {
const {
className,
children
} = this.props;
return (
<dl className={className}>
{children}
</dl>
);
}
}
DescriptionList.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.node
};
DescriptionList.defaultProps = {
className: styles.descriptionList
};
export default DescriptionList;

View file

@ -0,0 +1,15 @@
import React from 'react';
import styles from './DescriptionList.css';
interface DescriptionListProps {
className?: string;
children?: React.ReactNode;
}
function DescriptionList(props: DescriptionListProps) {
const { className = styles.descriptionList, children } = props;
return <dl className={className}>{children}</dl>;
}
export default DescriptionList;

View file

@ -1,46 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import DescriptionListItemDescription from './DescriptionListItemDescription';
import DescriptionListItemTitle from './DescriptionListItemTitle';
class DescriptionListItem extends Component {
//
// Render
render() {
const {
className,
titleClassName,
descriptionClassName,
title,
data
} = this.props;
return (
<div className={className}>
<DescriptionListItemTitle
className={titleClassName}
>
{title}
</DescriptionListItemTitle>
<DescriptionListItemDescription
className={descriptionClassName}
>
{data}
</DescriptionListItemDescription>
</div>
);
}
}
DescriptionListItem.propTypes = {
className: PropTypes.string,
titleClassName: PropTypes.string,
descriptionClassName: PropTypes.string,
title: PropTypes.string,
data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
};
export default DescriptionListItem;

View file

@ -0,0 +1,34 @@
import React from 'react';
import DescriptionListItemDescription, {
DescriptionListItemDescriptionProps,
} from './DescriptionListItemDescription';
import DescriptionListItemTitle, {
DescriptionListItemTitleProps,
} from './DescriptionListItemTitle';
interface DescriptionListItemProps {
className?: string;
titleClassName?: DescriptionListItemTitleProps['className'];
descriptionClassName?: DescriptionListItemDescriptionProps['className'];
title?: DescriptionListItemTitleProps['children'];
data?: DescriptionListItemDescriptionProps['children'];
}
function DescriptionListItem(props: DescriptionListItemProps) {
const { className, titleClassName, descriptionClassName, title, data } =
props;
return (
<div className={className}>
<DescriptionListItemTitle className={titleClassName}>
{title}
</DescriptionListItemTitle>
<DescriptionListItemDescription className={descriptionClassName}>
{data}
</DescriptionListItemDescription>
</div>
);
}
export default DescriptionListItem;

View file

@ -1,27 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './DescriptionListItemDescription.css';
function DescriptionListItemDescription(props) {
const {
className,
children
} = props;
return (
<dd className={className}>
{children}
</dd>
);
}
DescriptionListItemDescription.propTypes = {
className: PropTypes.string.isRequired,
children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
};
DescriptionListItemDescription.defaultProps = {
className: styles.description
};
export default DescriptionListItemDescription;

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