1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-03-06 13:30:00 -05:00

Compare commits

...

233 Commits

Author SHA1 Message Date
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
737 changed files with 33269 additions and 18739 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.9
VERSION: 4.0.13
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.0 KiB

View File

@@ -1,6 +1,6 @@
# <img width="24px" src="./Logo/256.png" alt="Sonarr"></img> Sonarr
# <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
@@ -52,7 +52,7 @@ This project exists thanks to all the people who contribute. [Contribute](CONTRI
### Supporters
This project would not be possible without the support of our users and software providers.
This project would not be possible without the support of our users and software providers.
[**Become a sponsor or backer**](https://opencollective.com/sonarr) to help us out!
#### Mega Sponsors
@@ -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
- [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)
- 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

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

View File

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

View File

@@ -195,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>
);
}

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

View File

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

View File

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

View File

@@ -1,12 +1,15 @@
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';
@@ -51,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;
};
@@ -64,14 +69,18 @@ interface AppState {
app: AppSectionState;
blocklist: BlocklistAppState;
calendar: CalendarAppState;
captcha: CaptchaAppState;
commands: CommandAppState;
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;

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

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

View File

@@ -16,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>,
@@ -29,6 +33,12 @@ 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>,
AppSectionDeleteState,
@@ -49,6 +59,12 @@ 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,
@@ -81,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

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

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import Agenda from './Agenda';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
(calendar) => {
return calendar;
}
);
}
export default connect(createMapStateToProps)(Agenda);

View File

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

View File

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

View File

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

View File

@@ -1,67 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import AgendaConnector from './Agenda/AgendaConnector';
import * as calendarViews from './calendarViews';
import CalendarDaysConnector from './Day/CalendarDaysConnector';
import DaysOfWeekConnector from './Day/DaysOfWeekConnector';
import CalendarHeaderConnector from './Header/CalendarHeaderConnector';
import styles from './Calendar.css';
class Calendar extends Component {
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
view
} = this.props;
return (
<div className={styles.calendar}>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
}
{
!error && isPopulated && view === calendarViews.AGENDA &&
<div className={styles.calendarContent}>
<CalendarHeaderConnector />
<AgendaConnector />
</div>
}
{
!error && isPopulated && view !== calendarViews.AGENDA &&
<div className={styles.calendarContent}>
<CalendarHeaderConnector />
<DaysOfWeekConnector />
<CalendarDaysConnector />
</div>
}
</div>
);
}
}
Calendar.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
view: PropTypes.string.isRequired
};
export default Calendar;

View File

@@ -0,0 +1,170 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Episode from 'Episode/Episode';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { kinds } from 'Helpers/Props';
import {
clearCalendar,
fetchCalendar,
gotoCalendarToday,
} from 'Store/Actions/calendarActions';
import {
clearEpisodeFiles,
fetchEpisodeFiles,
} from 'Store/Actions/episodeFileActions';
import {
clearQueueDetails,
fetchQueueDetails,
} from 'Store/Actions/queueActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import {
registerPagePopulator,
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import Agenda from './Agenda/Agenda';
import CalendarDays from './Day/CalendarDays';
import DaysOfWeek from './Day/DaysOfWeek';
import CalendarHeader from './Header/CalendarHeader';
import styles from './Calendar.css';
const UPDATE_DELAY = 3600000; // 1 hour
function Calendar() {
const dispatch = useDispatch();
const requestCurrentPage = useCurrentPage();
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
const { isFetching, isPopulated, error, items, time, view } = useSelector(
(state: AppState) => state.calendar
);
const isRefreshingSeries = useSelector(
createCommandExecutingSelector(commandNames.REFRESH_SERIES)
);
const firstDayOfWeek = useSelector(
(state: AppState) => state.settings.ui.item.firstDayOfWeek
);
const wasRefreshingSeries = usePrevious(isRefreshingSeries);
const previousFirstDayOfWeek = usePrevious(firstDayOfWeek);
const previousItems = usePrevious(items);
const handleScheduleUpdate = useCallback(() => {
clearTimeout(updateTimeout.current);
function updateCalendar() {
dispatch(gotoCalendarToday());
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
}
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
}, [dispatch]);
useEffect(() => {
handleScheduleUpdate();
return () => {
dispatch(clearCalendar());
dispatch(clearQueueDetails());
dispatch(clearEpisodeFiles());
clearTimeout(updateTimeout.current);
};
}, [dispatch, handleScheduleUpdate]);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchCalendar());
} else {
dispatch(gotoCalendarToday());
}
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchQueueDetails({ time, view }));
dispatch(fetchCalendar({ time, view }));
};
registerPagePopulator(repopulate, [
'episodeFileUpdated',
'episodeFileDeleted',
]);
return () => {
unregisterPagePopulator(repopulate);
};
}, [time, view, dispatch]);
useEffect(() => {
handleScheduleUpdate();
}, [time, handleScheduleUpdate]);
useEffect(() => {
if (
previousFirstDayOfWeek != null &&
firstDayOfWeek !== previousFirstDayOfWeek
) {
dispatch(fetchCalendar({ time, view }));
}
}, [time, view, firstDayOfWeek, previousFirstDayOfWeek, dispatch]);
useEffect(() => {
if (wasRefreshingSeries && !isRefreshingSeries) {
dispatch(fetchCalendar({ time, view }));
}
}, [time, view, isRefreshingSeries, wasRefreshingSeries, dispatch]);
useEffect(() => {
if (!previousItems || hasDifferentItems(items, previousItems)) {
const episodeIds = selectUniqueIds<Episode, number>(items, 'id');
const episodeFileIds = selectUniqueIds<Episode, number>(
items,
'episodeFileId'
);
if (items.length) {
dispatch(fetchQueueDetails({ episodeIds }));
}
if (episodeFileIds.length) {
dispatch(fetchEpisodeFiles({ episodeFileIds }));
}
}
}, [items, previousItems, dispatch]);
return (
<div className={styles.calendar}>
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
) : null}
{!error && isPopulated && view === 'agenda' ? (
<div className={styles.calendarContent}>
<CalendarHeader />
<Agenda />
</div>
) : null}
{!error && isPopulated && view !== 'agenda' ? (
<div className={styles.calendarContent}>
<CalendarHeader />
<DaysOfWeek />
<CalendarDays />
</div>
) : null}
</div>
);
}
export default Calendar;

View File

@@ -1,196 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import * as calendarActions from 'Store/Actions/calendarActions';
import { clearEpisodeFiles, fetchEpisodeFiles } from 'Store/Actions/episodeFileActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import Calendar from './Calendar';
const UPDATE_DELAY = 3600000; // 1 hour
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
(state) => state.settings.ui.item.firstDayOfWeek,
createCommandExecutingSelector(commandNames.REFRESH_SERIES),
(calendar, firstDayOfWeek, isRefreshingSeries) => {
return {
...calendar,
isRefreshingSeries,
firstDayOfWeek
};
}
);
}
const mapDispatchToProps = {
...calendarActions,
fetchEpisodeFiles,
clearEpisodeFiles,
fetchQueueDetails,
clearQueueDetails
};
class CalendarConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.updateTimeoutId = null;
}
componentDidMount() {
const {
useCurrentPage,
fetchCalendar,
gotoCalendarToday
} = this.props;
registerPagePopulator(this.repopulate, ['episodeFileUpdated', 'episodeFileDeleted']);
if (useCurrentPage) {
fetchCalendar();
} else {
gotoCalendarToday();
}
this.scheduleUpdate();
}
componentDidUpdate(prevProps) {
const {
items,
time,
view,
isRefreshingSeries,
firstDayOfWeek
} = this.props;
if (hasDifferentItems(prevProps.items, items)) {
const episodeIds = selectUniqueIds(items, 'id');
const episodeFileIds = selectUniqueIds(items, 'episodeFileId');
if (items.length) {
this.props.fetchQueueDetails({ episodeIds });
}
if (episodeFileIds.length) {
this.props.fetchEpisodeFiles({ episodeFileIds });
}
}
if (prevProps.time !== time) {
this.scheduleUpdate();
}
if (prevProps.firstDayOfWeek !== firstDayOfWeek) {
this.props.fetchCalendar({ time, view });
}
if (prevProps.isRefreshingSeries && !isRefreshingSeries) {
this.props.fetchCalendar({ time, view });
}
}
componentWillUnmount() {
unregisterPagePopulator(this.repopulate);
this.props.clearCalendar();
this.props.clearQueueDetails();
this.props.clearEpisodeFiles();
this.clearUpdateTimeout();
}
//
// Control
repopulate = () => {
const {
time,
view
} = this.props;
this.props.fetchQueueDetails({ time, view });
this.props.fetchCalendar({ time, view });
};
scheduleUpdate = () => {
this.clearUpdateTimeout();
this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY);
};
clearUpdateTimeout = () => {
if (this.updateTimeoutId) {
clearTimeout(this.updateTimeoutId);
}
};
updateCalendar = () => {
this.props.gotoCalendarToday();
this.scheduleUpdate();
};
//
// Listeners
onCalendarViewChange = (view) => {
this.props.setCalendarView({ view });
};
onTodayPress = () => {
this.props.gotoCalendarToday();
};
onPreviousPress = () => {
this.props.gotoCalendarPreviousRange();
};
onNextPress = () => {
this.props.gotoCalendarNextRange();
};
//
// Render
render() {
return (
<Calendar
{...this.props}
onCalendarViewChange={this.onCalendarViewChange}
onTodayPress={this.onTodayPress}
onPreviousPress={this.onPreviousPress}
onNextPress={this.onNextPress}
/>
);
}
}
CalendarConnector.propTypes = {
useCurrentPage: PropTypes.bool.isRequired,
time: PropTypes.string,
view: PropTypes.string.isRequired,
firstDayOfWeek: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isRefreshingSeries: PropTypes.bool.isRequired,
setCalendarView: PropTypes.func.isRequired,
gotoCalendarToday: PropTypes.func.isRequired,
gotoCalendarPreviousRange: PropTypes.func.isRequired,
gotoCalendarNextRange: PropTypes.func.isRequired,
clearCalendar: PropTypes.func.isRequired,
fetchCalendar: PropTypes.func.isRequired,
fetchEpisodeFiles: PropTypes.func.isRequired,
clearEpisodeFiles: PropTypes.func.isRequired,
fetchQueueDetails: PropTypes.func.isRequired,
clearQueueDetails: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector);

View File

@@ -1,197 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Measure from 'Components/Measure';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { align, icons } from 'Helpers/Props';
import NoSeries from 'Series/NoSeries';
import translate from 'Utilities/String/translate';
import CalendarConnector from './CalendarConnector';
import CalendarFilterModal from './CalendarFilterModal';
import CalendarLinkModal from './iCal/CalendarLinkModal';
import LegendConnector from './Legend/LegendConnector';
import CalendarOptionsModal from './Options/CalendarOptionsModal';
import styles from './CalendarPage.css';
const MINIMUM_DAY_WIDTH = 120;
class CalendarPage extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isCalendarLinkModalOpen: false,
isOptionsModalOpen: false,
width: 0
};
}
//
// Listeners
onMeasure = ({ width }) => {
this.setState({ width });
const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)));
this.props.onDaysCountChange(days);
};
onGetCalendarLinkPress = () => {
this.setState({ isCalendarLinkModalOpen: true });
};
onGetCalendarLinkModalClose = () => {
this.setState({ isCalendarLinkModalOpen: false });
};
onOptionsPress = () => {
this.setState({ isOptionsModalOpen: true });
};
onOptionsModalClose = () => {
this.setState({ isOptionsModalOpen: false });
};
onSearchMissingPress = () => {
const {
missingEpisodeIds,
onSearchMissingPress
} = this.props;
onSearchMissingPress(missingEpisodeIds);
};
//
// Render
render() {
const {
selectedFilterKey,
filters,
customFilters,
hasSeries,
missingEpisodeIds,
isRssSyncExecuting,
isSearchingForMissing,
useCurrentPage,
onRssSyncPress,
onFilterSelect
} = this.props;
const {
isCalendarLinkModalOpen,
isOptionsModalOpen
} = this.state;
const isMeasured = this.state.width > 0;
const PageComponent = hasSeries ? CalendarConnector : NoSeries;
return (
<PageContent title={translate('Calendar')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('ICalLink')}
iconName={icons.CALENDAR}
onPress={this.onGetCalendarLinkPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('RssSync')}
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
onPress={onRssSyncPress}
/>
<PageToolbarButton
label={translate('SearchForMissing')}
iconName={icons.SEARCH}
isDisabled={!missingEpisodeIds.length}
isSpinning={isSearchingForMissing}
onPress={this.onSearchMissingPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label={translate('Options')}
iconName={icons.POSTER}
onPress={this.onOptionsPress}
/>
<FilterMenu
alignMenu={align.RIGHT}
isDisabled={!hasSeries}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={CalendarFilterModal}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody
className={styles.calendarPageBody}
innerClassName={styles.calendarInnerPageBody}
>
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
{
isMeasured ?
<PageComponent
useCurrentPage={useCurrentPage}
/> :
<div />
}
</Measure>
{
hasSeries &&
<LegendConnector />
}
</PageContentBody>
<CalendarLinkModal
isOpen={isCalendarLinkModalOpen}
onModalClose={this.onGetCalendarLinkModalClose}
/>
<CalendarOptionsModal
isOpen={isOptionsModalOpen}
onModalClose={this.onOptionsModalClose}
/>
</PageContent>
);
}
}
CalendarPage.propTypes = {
selectedFilterKey: PropTypes.string.isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
hasSeries: PropTypes.bool.isRequired,
missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired,
isRssSyncExecuting: PropTypes.bool.isRequired,
isSearchingForMissing: PropTypes.bool.isRequired,
useCurrentPage: PropTypes.bool.isRequired,
onSearchMissingPress: PropTypes.func.isRequired,
onDaysCountChange: PropTypes.func.isRequired,
onRssSyncPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired
};
export default CalendarPage;

View File

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

View File

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

View File

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

View File

@@ -1,91 +0,0 @@
import _ from 'lodash';
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import CalendarDay from './CalendarDay';
function sort(items) {
return _.sortBy(items, (item) => {
if (item.isGroup) {
return moment(item.events[0].airDateUtc).unix();
}
return moment(item.airDateUtc).unix();
});
}
function createCalendarEventsConnector() {
return createSelector(
(state, { date }) => date,
(state) => state.calendar.items,
(state) => state.calendar.options.collapseMultipleEpisodes,
(date, items, collapseMultipleEpisodes) => {
const filtered = _.filter(items, (item) => {
return moment(date).isSame(moment(item.airDateUtc), 'day');
});
if (!collapseMultipleEpisodes) {
return sort(filtered);
}
const groupedObject = _.groupBy(filtered, (item) => `${item.seriesId}-${item.seasonNumber}`);
const grouped = [];
Object.keys(groupedObject).forEach((key) => {
const events = groupedObject[key];
if (events.length === 1) {
grouped.push(events[0]);
} else {
grouped.push({
isGroup: true,
seriesId: events[0].seriesId,
seasonNumber: events[0].seasonNumber,
episodeIds: events.map((event) => event.id),
events: _.sortBy(events, (item) => moment(item.airDateUtc).unix())
});
}
});
const sorted = sort(grouped);
return sorted;
}
);
}
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
createCalendarEventsConnector(),
(calendar, events) => {
return {
time: calendar.time,
view: calendar.view,
events
};
}
);
}
class CalendarDayConnector extends Component {
//
// Render
render() {
return (
<CalendarDay
{...this.props}
/>
);
}
}
CalendarDayConnector.propTypes = {
date: PropTypes.string.isRequired
};
export default connect(createMapStateToProps)(CalendarDayConnector);

View File

@@ -1,164 +0,0 @@
import classNames from 'classnames';
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import * as calendarViews from 'Calendar/calendarViews';
import isToday from 'Utilities/Date/isToday';
import CalendarDayConnector from './CalendarDayConnector';
import styles from './CalendarDays.css';
class CalendarDays extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._touchStart = null;
this.state = {
todaysDate: moment().startOf('day').toISOString(),
isEventModalOpen: false
};
this.updateTimeoutId = null;
}
// Lifecycle
componentDidMount() {
const view = this.props.view;
if (view === calendarViews.MONTH) {
this.scheduleUpdate();
}
window.addEventListener('touchstart', this.onTouchStart);
window.addEventListener('touchend', this.onTouchEnd);
window.addEventListener('touchcancel', this.onTouchCancel);
window.addEventListener('touchmove', this.onTouchMove);
}
componentWillUnmount() {
this.clearUpdateTimeout();
window.removeEventListener('touchstart', this.onTouchStart);
window.removeEventListener('touchend', this.onTouchEnd);
window.removeEventListener('touchcancel', this.onTouchCancel);
window.removeEventListener('touchmove', this.onTouchMove);
}
//
// Control
scheduleUpdate = () => {
this.clearUpdateTimeout();
const todaysDate = moment().startOf('day');
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
this.setState({ todaysDate: todaysDate.toISOString() });
this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
};
clearUpdateTimeout = () => {
if (this.updateTimeoutId) {
clearTimeout(this.updateTimeoutId);
}
};
//
// Listeners
onEventModalOpenToggle = (isEventModalOpen) => {
this.setState({ isEventModalOpen });
};
onTouchStart = (event) => {
const touches = event.touches;
const touchStart = touches[0].pageX;
if (touches.length !== 1) {
return;
}
if (
touchStart < 50 ||
this.props.isSidebarVisible ||
this.state.isEventModalOpen
) {
return;
}
this._touchStart = touchStart;
};
onTouchEnd = (event) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
if (!this._touchStart) {
return;
}
if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) {
this.props.onNavigatePrevious();
} else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) {
this.props.onNavigateNext();
}
this._touchStart = null;
};
onTouchCancel = (event) => {
this._touchStart = null;
};
onTouchMove = (event) => {
if (!this._touchStart) {
return;
}
};
//
// Render
render() {
const {
dates,
view
} = this.props;
return (
<div className={classNames(
styles.days,
styles[view]
)}
>
{
dates.map((date) => {
return (
<CalendarDayConnector
key={date}
date={date}
isTodaysDate={isToday(date)}
onEventModalOpenToggle={this.onEventModalOpenToggle}
/>
);
})
}
</div>
);
}
}
CalendarDays.propTypes = {
dates: PropTypes.arrayOf(PropTypes.string).isRequired,
view: PropTypes.string.isRequired,
isSidebarVisible: PropTypes.bool.isRequired,
onNavigatePrevious: PropTypes.func.isRequired,
onNavigateNext: PropTypes.func.isRequired
};
export default CalendarDays;

View File

@@ -0,0 +1,135 @@
import classNames from 'classnames';
import moment from 'moment';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as calendarViews from 'Calendar/calendarViews';
import {
gotoCalendarNextRange,
gotoCalendarPreviousRange,
} from 'Store/Actions/calendarActions';
import CalendarDay from './CalendarDay';
import styles from './CalendarDays.css';
function CalendarDays() {
const dispatch = useDispatch();
const { dates, view } = useSelector((state: AppState) => state.calendar);
const isSidebarVisible = useSelector(
(state: AppState) => state.app.isSidebarVisible
);
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
const touchStart = useRef<number | null>(null);
const isEventModalOpen = useRef(false);
const [todaysDate, setTodaysDate] = useState(
moment().startOf('day').toISOString()
);
const handleEventModalOpenToggle = useCallback((isOpen: boolean) => {
isEventModalOpen.current = isOpen;
}, []);
const scheduleUpdate = useCallback(() => {
clearTimeout(updateTimeout.current);
const todaysDate = moment().startOf('day');
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
setTodaysDate(todaysDate.toISOString());
updateTimeout.current = setTimeout(scheduleUpdate, diff);
}, []);
const handleTouchStart = useCallback(
(event: TouchEvent) => {
const touches = event.touches;
const currentTouch = touches[0].pageX;
if (touches.length !== 1) {
return;
}
if (currentTouch < 50 || isSidebarVisible || isEventModalOpen.current) {
return;
}
touchStart.current = currentTouch;
},
[isSidebarVisible]
);
const handleTouchEnd = useCallback(
(event: TouchEvent) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
if (!touchStart.current) {
return;
}
if (
currentTouch > touchStart.current &&
currentTouch - touchStart.current > 100
) {
dispatch(gotoCalendarPreviousRange());
} else if (
currentTouch < touchStart.current &&
touchStart.current - currentTouch > 100
) {
dispatch(gotoCalendarNextRange());
}
touchStart.current = null;
},
[dispatch]
);
const handleTouchCancel = useCallback(() => {
touchStart.current = null;
}, []);
const handleTouchMove = useCallback(() => {
if (!touchStart.current) {
return;
}
}, []);
useEffect(() => {
if (view === calendarViews.MONTH) {
scheduleUpdate();
}
}, [view, scheduleUpdate]);
useEffect(() => {
window.addEventListener('touchstart', handleTouchStart);
window.addEventListener('touchend', handleTouchEnd);
window.addEventListener('touchcancel', handleTouchCancel);
window.addEventListener('touchmove', handleTouchMove);
return () => {
window.removeEventListener('touchstart', handleTouchStart);
window.removeEventListener('touchend', handleTouchEnd);
window.removeEventListener('touchcancel', handleTouchCancel);
window.removeEventListener('touchmove', handleTouchMove);
};
}, [handleTouchStart, handleTouchEnd, handleTouchCancel, handleTouchMove]);
return (
<div
className={classNames(styles.days, styles[view as keyof typeof styles])}
>
{dates.map((date) => {
return (
<CalendarDay
key={date}
date={date}
isTodaysDate={date === todaysDate}
onEventModalOpenToggle={handleEventModalOpenToggle}
/>
);
})}
</div>
);
}
export default CalendarDays;

View File

@@ -1,25 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { gotoCalendarNextRange, gotoCalendarPreviousRange } from 'Store/Actions/calendarActions';
import CalendarDays from './CalendarDays';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
(state) => state.app.isSidebarVisible,
(calendar, isSidebarVisible) => {
return {
dates: calendar.dates,
view: calendar.view,
isSidebarVisible
};
}
);
}
const mapDispatchToProps = {
onNavigatePrevious: gotoCalendarPreviousRange,
onNavigateNext: gotoCalendarNextRange
};
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays);

View File

@@ -1,56 +0,0 @@
import classNames from 'classnames';
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import * as calendarViews from 'Calendar/calendarViews';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import styles from './DayOfWeek.css';
class DayOfWeek extends Component {
//
// Render
render() {
const {
date,
view,
isTodaysDate,
calendarWeekColumnHeader,
shortDateFormat,
showRelativeDates
} = this.props;
const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
const momentDate = moment(date);
let formatedDate = momentDate.format('dddd');
if (view === calendarViews.WEEK) {
formatedDate = momentDate.format(calendarWeekColumnHeader);
} else if (view === calendarViews.FORECAST) {
formatedDate = getRelativeDate({ date, shortDateFormat, showRelativeDates });
}
return (
<div className={classNames(
styles.dayOfWeek,
view === calendarViews.DAY && styles.isSingleDay,
highlightToday && styles.isToday
)}
>
{formatedDate}
</div>
);
}
}
DayOfWeek.propTypes = {
date: PropTypes.string.isRequired,
view: PropTypes.string.isRequired,
isTodaysDate: PropTypes.bool.isRequired,
calendarWeekColumnHeader: PropTypes.string.isRequired,
shortDateFormat: PropTypes.string.isRequired,
showRelativeDates: PropTypes.bool.isRequired
};
export default DayOfWeek;

View File

@@ -0,0 +1,54 @@
import classNames from 'classnames';
import moment from 'moment';
import React from 'react';
import * as calendarViews from 'Calendar/calendarViews';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import styles from './DayOfWeek.css';
interface DayOfWeekProps {
date: string;
view: string;
isTodaysDate: boolean;
calendarWeekColumnHeader: string;
shortDateFormat: string;
showRelativeDates: boolean;
}
function DayOfWeek(props: DayOfWeekProps) {
const {
date,
view,
isTodaysDate,
calendarWeekColumnHeader,
shortDateFormat,
showRelativeDates,
} = props;
const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
const momentDate = moment(date);
let formatedDate = momentDate.format('dddd');
if (view === calendarViews.WEEK) {
formatedDate = momentDate.format(calendarWeekColumnHeader);
} else if (view === calendarViews.FORECAST) {
formatedDate = getRelativeDate({
date,
shortDateFormat,
showRelativeDates,
});
}
return (
<div
className={classNames(
styles.dayOfWeek,
view === calendarViews.DAY && styles.isSingleDay,
highlightToday && styles.isToday
)}
>
{formatedDate}
</div>
);
}
export default DayOfWeek;

View File

@@ -1,97 +0,0 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import * as calendarViews from 'Calendar/calendarViews';
import DayOfWeek from './DayOfWeek';
import styles from './DaysOfWeek.css';
class DaysOfWeek extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
todaysDate: moment().startOf('day').toISOString()
};
this.updateTimeoutId = null;
}
// Lifecycle
componentDidMount() {
const view = this.props.view;
if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) {
this.scheduleUpdate();
}
}
componentWillUnmount() {
this.clearUpdateTimeout();
}
//
// Control
scheduleUpdate = () => {
this.clearUpdateTimeout();
const todaysDate = moment().startOf('day');
const diff = todaysDate.clone().add(1, 'day').diff(moment());
this.setState({
todaysDate: todaysDate.toISOString()
});
this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
};
clearUpdateTimeout = () => {
if (this.updateTimeoutId) {
clearTimeout(this.updateTimeoutId);
}
};
//
// Render
render() {
const {
dates,
view,
...otherProps
} = this.props;
if (view === calendarViews.AGENDA) {
return null;
}
return (
<div className={styles.daysOfWeek}>
{
dates.map((date) => {
return (
<DayOfWeek
key={date}
date={date}
view={view}
isTodaysDate={date === this.state.todaysDate}
{...otherProps}
/>
);
})
}
</div>
);
}
}
DaysOfWeek.propTypes = {
dates: PropTypes.arrayOf(PropTypes.string),
view: PropTypes.string.isRequired
};
export default DaysOfWeek;

View File

@@ -0,0 +1,60 @@
import moment from 'moment';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import * as calendarViews from 'Calendar/calendarViews';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import DayOfWeek from './DayOfWeek';
import styles from './DaysOfWeek.css';
function DaysOfWeek() {
const { dates, view } = useSelector((state: AppState) => state.calendar);
const { calendarWeekColumnHeader, shortDateFormat, showRelativeDates } =
useSelector(createUISettingsSelector());
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
const [todaysDate, setTodaysDate] = useState(
moment().startOf('day').toISOString()
);
const scheduleUpdate = useCallback(() => {
clearTimeout(updateTimeout.current);
const todaysDate = moment().startOf('day');
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
setTodaysDate(todaysDate.toISOString());
updateTimeout.current = setTimeout(scheduleUpdate, diff);
}, []);
useEffect(() => {
if (view !== calendarViews.AGENDA && view !== calendarViews.MONTH) {
scheduleUpdate();
}
}, [view, scheduleUpdate]);
if (view === calendarViews.AGENDA) {
return null;
}
return (
<div className={styles.daysOfWeek}>
{dates.map((date) => {
return (
<DayOfWeek
key={date}
date={date}
view={view}
isTodaysDate={date === todaysDate}
calendarWeekColumnHeader={calendarWeekColumnHeader}
shortDateFormat={shortDateFormat}
showRelativeDates={showRelativeDates}
/>
);
})}
</div>
);
}
export default DaysOfWeek;

View File

@@ -1,22 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import DaysOfWeek from './DaysOfWeek';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
createUISettingsSelector(),
(calendar, UiSettings) => {
return {
dates: calendar.dates.slice(0, 7),
view: calendar.view,
calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader,
shortDateFormat: UiSettings.shortDateFormat,
showRelativeDates: UiSettings.showRelativeDates
};
}
);
}
export default connect(createMapStateToProps)(DaysOfWeek);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,56 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import QueueDetails from 'Activity/Queue/QueueDetails';
import CircularProgressBar from 'Components/CircularProgressBar';
function CalendarEventQueueDetails(props) {
const {
title,
size,
sizeleft,
estimatedCompletionTime,
status,
trackedDownloadState,
trackedDownloadStatus,
statusMessages,
errorMessage
} = props;
const progress = size ? (100 - sizeleft / size * 100) : 0;
return (
<QueueDetails
title={title}
size={size}
sizeleft={sizeleft}
estimatedCompletionTime={estimatedCompletionTime}
status={status}
trackedDownloadState={trackedDownloadState}
trackedDownloadStatus={trackedDownloadStatus}
statusMessages={statusMessages}
errorMessage={errorMessage}
progressBar={
<CircularProgressBar
progress={progress}
size={20}
strokeWidth={2}
strokeColor={'#7a43b6'}
/>
}
/>
);
}
CalendarEventQueueDetails.propTypes = {
title: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
sizeleft: PropTypes.number.isRequired,
estimatedCompletionTime: PropTypes.string,
status: PropTypes.string.isRequired,
trackedDownloadState: PropTypes.string.isRequired,
trackedDownloadStatus: PropTypes.string.isRequired,
statusMessages: PropTypes.arrayOf(PropTypes.object),
errorMessage: PropTypes.string
};
export default CalendarEventQueueDetails;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import QueueDetails from 'Activity/Queue/QueueDetails';
import CircularProgressBar from 'Components/CircularProgressBar';
import {
QueueTrackedDownloadState,
QueueTrackedDownloadStatus,
StatusMessage,
} from 'typings/Queue';
interface CalendarEventQueueDetailsProps {
title: string;
size: number;
sizeleft: number;
estimatedCompletionTime?: string;
status: string;
trackedDownloadState: QueueTrackedDownloadState;
trackedDownloadStatus: QueueTrackedDownloadStatus;
statusMessages?: StatusMessage[];
errorMessage?: string;
}
function CalendarEventQueueDetails({
title,
size,
sizeleft,
estimatedCompletionTime,
status,
trackedDownloadState,
trackedDownloadStatus,
statusMessages,
errorMessage,
}: CalendarEventQueueDetailsProps) {
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
return (
<QueueDetails
title={title}
size={size}
sizeleft={sizeleft}
estimatedCompletionTime={estimatedCompletionTime}
status={status}
trackedDownloadState={trackedDownloadState}
trackedDownloadStatus={trackedDownloadStatus}
statusMessages={statusMessages}
errorMessage={errorMessage}
progressBar={
<CircularProgressBar
progress={progress}
size={20}
strokeWidth={2}
strokeColor="#7a43b6"
/>
}
/>
);
}
export default CalendarEventQueueDetails;

View File

@@ -1,268 +0,0 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import * as calendarViews from 'Calendar/calendarViews';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
import { align, icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
import styles from './CalendarHeader.css';
function getTitle(time, start, end, view, longDateFormat) {
const timeMoment = moment(time);
const startMoment = moment(start);
const endMoment = moment(end);
if (view === 'day') {
return timeMoment.format(longDateFormat);
} else if (view === 'month') {
return timeMoment.format('MMMM YYYY');
} else if (view === 'agenda') {
return translate('Agenda');
}
let startFormat = 'MMM D YYYY';
let endFormat = 'MMM D YYYY';
if (startMoment.isSame(endMoment, 'month')) {
startFormat = 'MMM D';
endFormat = 'D YYYY';
} else if (startMoment.isSame(endMoment, 'year')) {
startFormat = 'MMM D';
endFormat = 'MMM D YYYY';
}
return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`;
}
// TODO Convert to a stateful Component so we can track view internally when changed
class CalendarHeader extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
view: props.view
};
}
componentDidUpdate(prevProps) {
const view = this.props.view;
if (prevProps.view !== view) {
this.setState({ view });
}
}
//
// Listeners
onViewChange = (view) => {
this.setState({ view }, () => {
this.props.onViewChange(view);
});
};
//
// Render
render() {
const {
isFetching,
time,
start,
end,
longDateFormat,
isSmallScreen,
collapseViewButtons,
onTodayPress,
onPreviousPress,
onNextPress
} = this.props;
const view = this.state.view;
const title = getTitle(time, start, end, view, longDateFormat);
return (
<div>
{
isSmallScreen &&
<div className={styles.titleMobile}>
{title}
</div>
}
<div className={styles.header}>
<div className={styles.navigationButtons}>
<Button
buttonGroupPosition={align.LEFT}
isDisabled={view === calendarViews.AGENDA}
onPress={onPreviousPress}
>
<Icon name={icons.PAGE_PREVIOUS} />
</Button>
<Button
buttonGroupPosition={align.RIGHT}
isDisabled={view === calendarViews.AGENDA}
onPress={onNextPress}
>
<Icon name={icons.PAGE_NEXT} />
</Button>
<Button
className={styles.todayButton}
isDisabled={view === calendarViews.AGENDA}
onPress={onTodayPress}
>
{translate('Today')}
</Button>
</div>
{
!isSmallScreen &&
<div className={styles.titleDesktop}>
{title}
</div>
}
<div className={styles.viewButtonsContainer}>
{
isFetching &&
<LoadingIndicator
className={styles.loading}
size={20}
/>
}
{
collapseViewButtons ?
<Menu
className={styles.viewMenu}
alignMenu={align.RIGHT}
>
<MenuButton>
<Icon
name={icons.VIEW}
size={22}
/>
</MenuButton>
<MenuContent>
{
isSmallScreen ?
null :
<ViewMenuItem
name={calendarViews.MONTH}
selectedView={view}
onPress={this.onViewChange}
>
{translate('Month')}
</ViewMenuItem>
}
<ViewMenuItem
name={calendarViews.WEEK}
selectedView={view}
onPress={this.onViewChange}
>
{translate('Week')}
</ViewMenuItem>
<ViewMenuItem
name={calendarViews.FORECAST}
selectedView={view}
onPress={this.onViewChange}
>
{translate('Forecast')}
</ViewMenuItem>
<ViewMenuItem
name={calendarViews.DAY}
selectedView={view}
onPress={this.onViewChange}
>
{translate('Day')}
</ViewMenuItem>
<ViewMenuItem
name={calendarViews.AGENDA}
selectedView={view}
onPress={this.onViewChange}
>
{translate('Agenda')}
</ViewMenuItem>
</MenuContent>
</Menu> :
<div className={styles.viewButtons}>
<CalendarHeaderViewButton
view={calendarViews.MONTH}
selectedView={view}
buttonGroupPosition={align.LEFT}
onPress={this.onViewChange}
/>
<CalendarHeaderViewButton
view={calendarViews.WEEK}
selectedView={view}
buttonGroupPosition={align.CENTER}
onPress={this.onViewChange}
/>
<CalendarHeaderViewButton
view={calendarViews.FORECAST}
selectedView={view}
buttonGroupPosition={align.CENTER}
onPress={this.onViewChange}
/>
<CalendarHeaderViewButton
view={calendarViews.DAY}
selectedView={view}
buttonGroupPosition={align.CENTER}
onPress={this.onViewChange}
/>
<CalendarHeaderViewButton
view={calendarViews.AGENDA}
selectedView={view}
buttonGroupPosition={align.RIGHT}
onPress={this.onViewChange}
/>
</div>
}
</div>
</div>
</div>
);
}
}
CalendarHeader.propTypes = {
isFetching: PropTypes.bool.isRequired,
time: PropTypes.string.isRequired,
start: PropTypes.string.isRequired,
end: PropTypes.string.isRequired,
view: PropTypes.oneOf(calendarViews.all).isRequired,
isSmallScreen: PropTypes.bool.isRequired,
collapseViewButtons: PropTypes.bool.isRequired,
longDateFormat: PropTypes.string.isRequired,
onViewChange: PropTypes.func.isRequired,
onTodayPress: PropTypes.func.isRequired,
onPreviousPress: PropTypes.func.isRequired,
onNextPress: PropTypes.func.isRequired
};
export default CalendarHeader;

View File

@@ -0,0 +1,221 @@
import moment from 'moment';
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { CalendarView } from 'Calendar/calendarViews';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
import { align, icons } from 'Helpers/Props';
import {
gotoCalendarNextRange,
gotoCalendarPreviousRange,
gotoCalendarToday,
setCalendarView,
} from 'Store/Actions/calendarActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import translate from 'Utilities/String/translate';
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
import styles from './CalendarHeader.css';
function CalendarHeader() {
const dispatch = useDispatch();
const { isFetching, view, time, start, end } = useSelector(
(state: AppState) => state.calendar
);
const { isSmallScreen, isLargeScreen } = useSelector(
createDimensionsSelector()
);
const { longDateFormat } = useSelector(createUISettingsSelector());
const handleViewChange = useCallback(
(newView: CalendarView) => {
dispatch(setCalendarView({ view: newView }));
},
[dispatch]
);
const handleTodayPress = useCallback(() => {
dispatch(gotoCalendarToday());
}, [dispatch]);
const handlePreviousPress = useCallback(() => {
dispatch(gotoCalendarPreviousRange());
}, [dispatch]);
const handleNextPress = useCallback(() => {
dispatch(gotoCalendarNextRange());
}, [dispatch]);
const title = useMemo(() => {
const timeMoment = moment(time);
const startMoment = moment(start);
const endMoment = moment(end);
if (view === 'day') {
return timeMoment.format(longDateFormat);
} else if (view === 'month') {
return timeMoment.format('MMMM YYYY');
} else if (view === 'agenda') {
return translate('Agenda');
}
let startFormat = 'MMM D YYYY';
let endFormat = 'MMM D YYYY';
if (startMoment.isSame(endMoment, 'month')) {
startFormat = 'MMM D';
endFormat = 'D YYYY';
} else if (startMoment.isSame(endMoment, 'year')) {
startFormat = 'MMM D';
endFormat = 'MMM D YYYY';
}
return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(
endFormat
)}`;
}, [time, start, end, view, longDateFormat]);
return (
<div>
{isSmallScreen ? <div className={styles.titleMobile}>{title}</div> : null}
<div className={styles.header}>
<div className={styles.navigationButtons}>
<Button
buttonGroupPosition="left"
isDisabled={view === 'agenda'}
onPress={handlePreviousPress}
>
<Icon name={icons.PAGE_PREVIOUS} />
</Button>
<Button
buttonGroupPosition="right"
isDisabled={view === 'agenda'}
onPress={handleNextPress}
>
<Icon name={icons.PAGE_NEXT} />
</Button>
<Button
className={styles.todayButton}
isDisabled={view === 'agenda'}
onPress={handleTodayPress}
>
{translate('Today')}
</Button>
</div>
{isSmallScreen ? null : (
<div className={styles.titleDesktop}>{title}</div>
)}
<div className={styles.viewButtonsContainer}>
{isFetching ? (
<LoadingIndicator className={styles.loading} size={20} />
) : null}
{isLargeScreen ? (
<Menu className={styles.viewMenu} alignMenu={align.RIGHT}>
<MenuButton>
<Icon name={icons.VIEW} size={22} />
</MenuButton>
<MenuContent>
{isSmallScreen ? null : (
<ViewMenuItem
name="month"
selectedView={view}
onPress={handleViewChange}
>
{translate('Month')}
</ViewMenuItem>
)}
<ViewMenuItem
name="week"
selectedView={view}
onPress={handleViewChange}
>
{translate('Week')}
</ViewMenuItem>
<ViewMenuItem
name="forecast"
selectedView={view}
onPress={handleViewChange}
>
{translate('Forecast')}
</ViewMenuItem>
<ViewMenuItem
name="day"
selectedView={view}
onPress={handleViewChange}
>
{translate('Day')}
</ViewMenuItem>
<ViewMenuItem
name="agenda"
selectedView={view}
onPress={handleViewChange}
>
{translate('Agenda')}
</ViewMenuItem>
</MenuContent>
</Menu>
) : (
<>
<CalendarHeaderViewButton
view="month"
selectedView={view}
buttonGroupPosition="left"
onPress={handleViewChange}
/>
<CalendarHeaderViewButton
view="week"
selectedView={view}
buttonGroupPosition="center"
onPress={handleViewChange}
/>
<CalendarHeaderViewButton
view="forecast"
selectedView={view}
buttonGroupPosition="center"
onPress={handleViewChange}
/>
<CalendarHeaderViewButton
view="day"
selectedView={view}
buttonGroupPosition="center"
onPress={handleViewChange}
/>
<CalendarHeaderViewButton
view="agenda"
selectedView={view}
buttonGroupPosition="right"
onPress={handleViewChange}
/>
</>
)}
</div>
</div>
</div>
);
}
export default CalendarHeader;

View File

@@ -1,85 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { gotoCalendarNextRange, gotoCalendarPreviousRange, gotoCalendarToday, setCalendarView } from 'Store/Actions/calendarActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import CalendarHeader from './CalendarHeader';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar,
createDimensionsSelector(),
createUISettingsSelector(),
(calendar, dimensions, uiSettings) => {
const result = _.pick(calendar, [
'isFetching',
'view',
'time',
'start',
'end'
]);
result.isSmallScreen = dimensions.isSmallScreen;
result.collapseViewButtons = dimensions.isLargeScreen;
result.longDateFormat = uiSettings.longDateFormat;
return result;
}
);
}
const mapDispatchToProps = {
setCalendarView,
gotoCalendarToday,
gotoCalendarPreviousRange,
gotoCalendarNextRange
};
class CalendarHeaderConnector extends Component {
//
// Listeners
onViewChange = (view) => {
this.props.setCalendarView({ view });
};
onTodayPress = () => {
this.props.gotoCalendarToday();
};
onPreviousPress = () => {
this.props.gotoCalendarPreviousRange();
};
onNextPress = () => {
this.props.gotoCalendarNextRange();
};
//
// Render
render() {
return (
<CalendarHeader
{...this.props}
onViewChange={this.onViewChange}
onTodayPress={this.onTodayPress}
onPreviousPress={this.onPreviousPress}
onNextPress={this.onNextPress}
/>
);
}
}
CalendarHeaderConnector.propTypes = {
setCalendarView: PropTypes.func.isRequired,
gotoCalendarToday: PropTypes.func.isRequired,
gotoCalendarPreviousRange: PropTypes.func.isRequired,
gotoCalendarNextRange: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector);

View File

@@ -1,45 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import * as calendarViews from 'Calendar/calendarViews';
import Button from 'Components/Link/Button';
import titleCase from 'Utilities/String/titleCase';
// import styles from './CalendarHeaderViewButton.css';
class CalendarHeaderViewButton extends Component {
//
// Listeners
onPress = () => {
this.props.onPress(this.props.view);
};
//
// Render
render() {
const {
view,
selectedView,
...otherProps
} = this.props;
return (
<Button
isDisabled={selectedView === view}
{...otherProps}
onPress={this.onPress}
>
{titleCase(view)}
</Button>
);
}
}
CalendarHeaderViewButton.propTypes = {
view: PropTypes.oneOf(calendarViews.all).isRequired,
selectedView: PropTypes.oneOf(calendarViews.all).isRequired,
onPress: PropTypes.func.isRequired
};
export default CalendarHeaderViewButton;

View File

@@ -0,0 +1,34 @@
import React, { useCallback } from 'react';
import { CalendarView } from 'Calendar/calendarViews';
import Button, { ButtonProps } from 'Components/Link/Button';
import titleCase from 'Utilities/String/titleCase';
interface CalendarHeaderViewButtonProps
extends Omit<ButtonProps, 'children' | 'onPress'> {
view: CalendarView;
selectedView: CalendarView;
onPress: (view: CalendarView) => void;
}
function CalendarHeaderViewButton({
view,
selectedView,
onPress,
...otherProps
}: CalendarHeaderViewButtonProps) {
const handlePress = useCallback(() => {
onPress(view);
}, [view, onPress]);
return (
<Button
isDisabled={selectedView === view}
{...otherProps}
onPress={handlePress}
>
{titleCase(view)}
</Button>
);
}
export default CalendarHeaderViewButton;

View File

@@ -1,20 +1,22 @@
import PropTypes from 'prop-types';
import React from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { icons, kinds } from 'Helpers/Props';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import translate from 'Utilities/String/translate';
import LegendIconItem from './LegendIconItem';
import LegendItem from './LegendItem';
import styles from './Legend.css';
function Legend(props) {
function Legend() {
const view = useSelector((state: AppState) => state.calendar.view);
const {
view,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
fullColorEvents,
colorImpairedMode
} = props;
} = useSelector((state: AppState) => state.calendar.options);
const { enableColorImpairedMode } = useSelector(createUISettingsSelector());
const iconsToShow = [];
const isAgendaView = view === 'agenda';
@@ -56,7 +58,7 @@ function Legend(props) {
if (showCutoffUnmetIcon) {
iconsToShow.push(
<LegendIconItem
name={translate('Cutoff Not Met')}
name={translate('CutoffNotMet')}
icon={icons.EPISODE_FILE}
kind={kinds.WARNING}
fullColorEvents={fullColorEvents}
@@ -73,7 +75,7 @@ function Legend(props) {
tooltip={translate('CalendarLegendEpisodeUnairedTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
colorImpairedMode={enableColorImpairedMode}
/>
<LegendItem
@@ -81,7 +83,7 @@ function Legend(props) {
tooltip={translate('CalendarLegendEpisodeUnmonitoredTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
colorImpairedMode={enableColorImpairedMode}
/>
</div>
@@ -92,7 +94,7 @@ function Legend(props) {
tooltip={translate('CalendarLegendEpisodeOnAirTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
colorImpairedMode={enableColorImpairedMode}
/>
<LegendItem
@@ -100,7 +102,7 @@ function Legend(props) {
tooltip={translate('CalendarLegendEpisodeMissingTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
colorImpairedMode={enableColorImpairedMode}
/>
</div>
@@ -110,7 +112,7 @@ function Legend(props) {
tooltip={translate('CalendarLegendEpisodeDownloadingTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
colorImpairedMode={enableColorImpairedMode}
/>
<LegendItem
@@ -118,7 +120,7 @@ function Legend(props) {
tooltip={translate('CalendarLegendEpisodeDownloadedTooltip')}
isAgendaView={isAgendaView}
fullColorEvents={fullColorEvents}
colorImpairedMode={colorImpairedMode}
colorImpairedMode={enableColorImpairedMode}
/>
</div>
@@ -134,30 +136,15 @@ function Legend(props) {
{iconsToShow[0]}
</div>
{
iconsToShow.length > 1 &&
<div>
{iconsToShow[1]}
{iconsToShow[2]}
</div>
}
{
iconsToShow.length > 3 &&
<div>
{iconsToShow[3]}
</div>
}
{iconsToShow.length > 1 ? (
<div>
{iconsToShow[1]}
{iconsToShow[2]}
</div>
) : null}
{iconsToShow.length > 3 ? <div>{iconsToShow[3]}</div> : null}
</div>
);
}
Legend.propTypes = {
view: PropTypes.string.isRequired,
showFinaleIcon: PropTypes.bool.isRequired,
showSpecialIcon: PropTypes.bool.isRequired,
showCutoffUnmetIcon: PropTypes.bool.isRequired,
fullColorEvents: PropTypes.bool.isRequired,
colorImpairedMode: PropTypes.bool.isRequired
};
export default Legend;

View File

@@ -1,21 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import Legend from './Legend';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
(state) => state.calendar.view,
createUISettingsSelector(),
(calendarOptions, view, uiSettings) => {
return {
...calendarOptions,
view,
colorImpairedMode: uiSettings.enableColorImpairedMode
};
}
);
}
export default connect(createMapStateToProps)(Legend);

View File

@@ -1,43 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import styles from './LegendIconItem.css';
function LegendIconItem(props) {
const {
name,
fullColorEvents,
icon,
kind,
tooltip
} = props;
return (
<div
className={styles.legendIconItem}
title={tooltip}
>
<Icon
className={classNames(
styles.icon,
fullColorEvents && 'fullColorEvents'
)}
name={icon}
kind={kind}
/>
{name}
</div>
);
}
LegendIconItem.propTypes = {
name: PropTypes.string.isRequired,
fullColorEvents: PropTypes.bool.isRequired,
icon: PropTypes.object.isRequired,
kind: PropTypes.string.isRequired,
tooltip: PropTypes.string.isRequired
};
export default LegendIconItem;

View File

@@ -0,0 +1,33 @@
import { FontAwesomeIconProps } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import React from 'react';
import Icon, { IconProps } from 'Components/Icon';
import styles from './LegendIconItem.css';
interface LegendIconItemProps extends Pick<IconProps, 'kind'> {
name: string;
fullColorEvents: boolean;
icon: FontAwesomeIconProps['icon'];
tooltip: string;
}
function LegendIconItem(props: LegendIconItemProps) {
const { name, fullColorEvents, icon, kind, tooltip } = props;
return (
<div className={styles.legendIconItem} title={tooltip}>
<Icon
className={classNames(
styles.icon,
fullColorEvents && 'fullColorEvents'
)}
name={icon}
kind={kind}
/>
{name}
</div>
);
}
export default LegendIconItem;

View File

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

View File

@@ -1,29 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector';
function CalendarOptionsModal(props) {
const {
isOpen,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<CalendarOptionsModalContentConnector
onModalClose={onModalClose}
/>
</Modal>
);
}
CalendarOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CalendarOptionsModal;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import CalendarOptionsModalContent from './CalendarOptionsModalContent';
interface CalendarOptionsModalProps {
isOpen: boolean;
onModalClose: () => void;
}
function CalendarOptionsModal({
isOpen,
onModalClose,
}: CalendarOptionsModalProps) {
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<CalendarOptionsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default CalendarOptionsModal;

View File

@@ -1,276 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import { firstDayOfWeekOptions, timeFormatOptions, weekColumnOptions } from 'Settings/UI/UISettings';
import translate from 'Utilities/String/translate';
class CalendarOptionsModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode,
fullColorEvents
} = props;
this.state = {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode,
fullColorEvents
};
}
componentDidUpdate(prevProps) {
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
} = this.props;
if (
prevProps.firstDayOfWeek !== firstDayOfWeek ||
prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader ||
prevProps.timeFormat !== timeFormat ||
prevProps.enableColorImpairedMode !== enableColorImpairedMode
) {
this.setState({
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
});
}
}
//
// Listeners
onOptionInputChange = ({ name, value }) => {
const {
dispatchSetCalendarOption
} = this.props;
dispatchSetCalendarOption({ [name]: value });
};
onGlobalInputChange = ({ name, value }) => {
const {
dispatchSaveUISettings
} = this.props;
const setting = { [name]: value };
this.setState(setting, () => {
dispatchSaveUISettings(setting);
});
};
onLinkFocus = (event) => {
event.target.select();
};
//
// Render
render() {
const {
collapseMultipleEpisodes,
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
fullColorEvents,
onModalClose
} = this.props;
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('CalendarOptions')}
</ModalHeader>
<ModalBody>
<FieldSet legend={translate('Local')}>
<Form>
<FormGroup>
<FormLabel>{translate('CollapseMultipleEpisodes')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="collapseMultipleEpisodes"
value={collapseMultipleEpisodes}
helpText={translate('CollapseMultipleEpisodesHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowEpisodeInformation')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showEpisodeInformation"
value={showEpisodeInformation}
helpText={translate('ShowEpisodeInformationHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForFinales')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showFinaleIcon"
value={showFinaleIcon}
helpText={translate('IconForFinalesHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForSpecials')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSpecialIcon"
value={showSpecialIcon}
helpText={translate('IconForSpecialsHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForCutoffUnmet')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showCutoffUnmetIcon"
value={showCutoffUnmetIcon}
helpText={translate('IconForCutoffUnmetHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('FullColorEvents')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="fullColorEvents"
value={fullColorEvents}
helpText={translate('FullColorEventsHelpText')}
onChange={this.onOptionInputChange}
/>
</FormGroup>
</Form>
</FieldSet>
<FieldSet legend={translate('Global')}>
<Form>
<FormGroup>
<FormLabel>{translate('FirstDayOfWeek')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="firstDayOfWeek"
values={firstDayOfWeekOptions}
value={firstDayOfWeek}
onChange={this.onGlobalInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('WeekColumnHeader')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="calendarWeekColumnHeader"
values={weekColumnOptions}
value={calendarWeekColumnHeader}
onChange={this.onGlobalInputChange}
helpText={translate('WeekColumnHeaderHelpText')}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('TimeFormat')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="timeFormat"
values={timeFormatOptions}
value={timeFormat}
onChange={this.onGlobalInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('EnableColorImpairedMode')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enableColorImpairedMode"
value={enableColorImpairedMode}
helpText={translate('EnableColorImpairedModeHelpText')}
onChange={this.onGlobalInputChange}
/>
</FormGroup>
</Form>
</FieldSet>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
CalendarOptionsModalContent.propTypes = {
collapseMultipleEpisodes: PropTypes.bool.isRequired,
showEpisodeInformation: PropTypes.bool.isRequired,
showFinaleIcon: PropTypes.bool.isRequired,
showSpecialIcon: PropTypes.bool.isRequired,
showCutoffUnmetIcon: PropTypes.bool.isRequired,
firstDayOfWeek: PropTypes.number.isRequired,
calendarWeekColumnHeader: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
enableColorImpairedMode: PropTypes.bool.isRequired,
fullColorEvents: PropTypes.bool.isRequired,
dispatchSetCalendarOption: PropTypes.func.isRequired,
dispatchSaveUISettings: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CalendarOptionsModalContent;

View File

@@ -0,0 +1,228 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import {
firstDayOfWeekOptions,
timeFormatOptions,
weekColumnOptions,
} from 'Settings/UI/UISettings';
import { setCalendarOption } from 'Store/Actions/calendarActions';
import { saveUISettings } from 'Store/Actions/settingsActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { InputChanged } from 'typings/inputs';
import UiSettings from 'typings/Settings/UiSettings';
import translate from 'Utilities/String/translate';
interface CalendarOptionsModalContentProps {
onModalClose: () => void;
}
function CalendarOptionsModalContent({
onModalClose,
}: CalendarOptionsModalContentProps) {
const dispatch = useDispatch();
const {
collapseMultipleEpisodes,
showEpisodeInformation,
showFinaleIcon,
showSpecialIcon,
showCutoffUnmetIcon,
fullColorEvents,
} = useSelector((state: AppState) => state.calendar.options);
const uiSettings = useSelector(createUISettingsSelector());
const [state, setState] = useState<Partial<UiSettings>>({
firstDayOfWeek: uiSettings.firstDayOfWeek,
calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
timeFormat: uiSettings.timeFormat,
enableColorImpairedMode: uiSettings.enableColorImpairedMode,
});
const {
firstDayOfWeek,
calendarWeekColumnHeader,
timeFormat,
enableColorImpairedMode,
} = state;
const handleOptionInputChange = useCallback(
({ name, value }: InputChanged) => {
dispatch(setCalendarOption({ [name]: value }));
},
[dispatch]
);
const handleGlobalInputChange = useCallback(
({ name, value }: InputChanged) => {
setState((prevState) => ({ ...prevState, [name]: value }));
dispatch(saveUISettings({ [name]: value }));
},
[dispatch]
);
useEffect(() => {
setState({
firstDayOfWeek: uiSettings.firstDayOfWeek,
calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
timeFormat: uiSettings.timeFormat,
enableColorImpairedMode: uiSettings.enableColorImpairedMode,
});
}, [uiSettings]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('CalendarOptions')}</ModalHeader>
<ModalBody>
<FieldSet legend={translate('Local')}>
<Form>
<FormGroup>
<FormLabel>{translate('CollapseMultipleEpisodes')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="collapseMultipleEpisodes"
value={collapseMultipleEpisodes}
helpText={translate('CollapseMultipleEpisodesHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowEpisodeInformation')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showEpisodeInformation"
value={showEpisodeInformation}
helpText={translate('ShowEpisodeInformationHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForFinales')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showFinaleIcon"
value={showFinaleIcon}
helpText={translate('IconForFinalesHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForSpecials')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSpecialIcon"
value={showSpecialIcon}
helpText={translate('IconForSpecialsHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('IconForCutoffUnmet')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showCutoffUnmetIcon"
value={showCutoffUnmetIcon}
helpText={translate('IconForCutoffUnmetHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('FullColorEvents')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="fullColorEvents"
value={fullColorEvents}
helpText={translate('FullColorEventsHelpText')}
onChange={handleOptionInputChange}
/>
</FormGroup>
</Form>
</FieldSet>
<FieldSet legend={translate('Global')}>
<Form>
<FormGroup>
<FormLabel>{translate('FirstDayOfWeek')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="firstDayOfWeek"
values={firstDayOfWeekOptions}
value={firstDayOfWeek}
onChange={handleGlobalInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('WeekColumnHeader')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="calendarWeekColumnHeader"
values={weekColumnOptions}
value={calendarWeekColumnHeader}
helpText={translate('WeekColumnHeaderHelpText')}
onChange={handleGlobalInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('TimeFormat')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="timeFormat"
values={timeFormatOptions}
value={timeFormat}
onChange={handleGlobalInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('EnableColorImpairedMode')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enableColorImpairedMode"
value={enableColorImpairedMode}
helpText={translate('EnableColorImpairedModeHelpText')}
onChange={handleGlobalInputChange}
/>
</FormGroup>
</Form>
</FieldSet>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default CalendarOptionsModalContent;

View File

@@ -1,25 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setCalendarOption } from 'Store/Actions/calendarActions';
import { saveUISettings } from 'Store/Actions/settingsActions';
import CalendarOptionsModalContent from './CalendarOptionsModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.calendar.options,
(state) => state.settings.ui.item,
(options, uiSettings) => {
return {
...options,
...uiSettings
};
}
);
}
const mapDispatchToProps = {
dispatchSetCalendarOption: setCalendarOption,
dispatchSaveUISettings: saveUISettings
};
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent);

View File

@@ -5,3 +5,5 @@ export const FORECAST = 'forecast';
export const AGENDA = 'agenda';
export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA];
export type CalendarView = 'agenda' | 'day' | 'forecast' | 'month' | 'week';

View File

@@ -1,7 +1,13 @@
/* eslint max-params: 0 */
import moment from 'moment';
import { CalendarStatus } from 'typings/Calendar';
function getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored) {
function getStatusStyle(
hasFile: boolean,
downloading: boolean,
startTime: moment.Moment,
endTime: moment.Moment,
isMonitored: boolean
): CalendarStatus {
const currentTime = moment();
if (hasFile) {

View File

@@ -1,29 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector';
function CalendarLinkModal(props) {
const {
isOpen,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<CalendarLinkModalContentConnector
onModalClose={onModalClose}
/>
</Modal>
);
}
CalendarLinkModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CalendarLinkModal;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import CalendarLinkModalContent from './CalendarLinkModalContent';
interface CalendarLinkModalProps {
isOpen: boolean;
onModalClose: () => void;
}
function CalendarLinkModal(props: CalendarLinkModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<CalendarLinkModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default CalendarLinkModal;

View File

@@ -1,222 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputButton from 'Components/Form/FormInputButton';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import ClipboardButton from 'Components/Link/ClipboardButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
function getUrls(state) {
const {
unmonitored,
premieresOnly,
asAllDay,
tags
} = state;
let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`;
if (unmonitored) {
icalUrl += 'unmonitored=true&';
}
if (premieresOnly) {
icalUrl += 'premieresOnly=true&';
}
if (asAllDay) {
icalUrl += 'asAllDay=true&';
}
if (tags.length) {
icalUrl += `tags=${tags.toString()}&`;
}
icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`;
const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`;
const iCalWebCalUrl = `webcal://${icalUrl}`;
return {
iCalHttpUrl,
iCalWebCalUrl
};
}
class CalendarLinkModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const defaultState = {
unmonitored: false,
premieresOnly: false,
asAllDay: false,
tags: []
};
const urls = getUrls(defaultState);
this.state = {
...defaultState,
...urls
};
}
//
// Listeners
onInputChange = ({ name, value }) => {
const state = {
...this.state,
[name]: value
};
const urls = getUrls(state);
this.setState({
[name]: value,
...urls
});
};
onLinkFocus = (event) => {
event.target.select();
};
//
// Render
render() {
const {
onModalClose
} = this.props;
const {
unmonitored,
premieresOnly,
asAllDay,
tags,
iCalHttpUrl,
iCalWebCalUrl
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('CalendarFeed')}
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('IncludeUnmonitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="unmonitored"
value={unmonitored}
helpText={translate('ICalIncludeUnmonitoredEpisodesHelpText')}
onChange={this.onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SeasonPremieresOnly')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="premieresOnly"
value={premieresOnly}
helpText={translate('ICalSeasonPremieresOnlyHelpText')}
onChange={this.onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ICalShowAsAllDayEvents')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="asAllDay"
value={asAllDay}
helpText={translate('ICalShowAsAllDayEventsHelpText')}
onChange={this.onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
value={tags}
helpText={translate('ICalTagsSeriesHelpText')}
onChange={this.onInputChange}
/>
</FormGroup>
<FormGroup
size={sizes.LARGE}
>
<FormLabel>{translate('ICalFeed')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="iCalHttpUrl"
value={iCalHttpUrl}
readOnly={true}
helpText={translate('ICalFeedHelpText')}
buttons={[
<ClipboardButton
key="copy"
value={iCalHttpUrl}
kind={kinds.DEFAULT}
/>,
<FormInputButton
key="webcal"
kind={kinds.DEFAULT}
to={iCalWebCalUrl}
target="_blank"
noRouter={true}
>
<Icon name={icons.CALENDAR_O} />
</FormInputButton>
]}
onChange={this.onInputChange}
onFocus={this.onLinkFocus}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
CalendarLinkModalContent.propTypes = {
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CalendarLinkModalContent;

View File

@@ -0,0 +1,166 @@
import React, { FocusEvent, useCallback, useMemo, useState } from 'react';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputButton from 'Components/Form/FormInputButton';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import ClipboardButton from 'Components/Link/ClipboardButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
interface CalendarLinkModalContentProps {
onModalClose: () => void;
}
function CalendarLinkModalContent({
onModalClose,
}: CalendarLinkModalContentProps) {
const [state, setState] = useState({
unmonitored: false,
premieresOnly: false,
asAllDay: false,
tags: [],
});
const { unmonitored, premieresOnly, asAllDay, tags } = state;
const handleInputChange = useCallback(({ name, value }: InputChanged) => {
setState((prevState) => ({ ...prevState, [name]: value }));
}, []);
const handleLinkFocus = useCallback(
(event: FocusEvent<HTMLInputElement, Element>) => {
event.target.select();
},
[]
);
const { iCalHttpUrl, iCalWebCalUrl } = useMemo(() => {
let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`;
if (unmonitored) {
icalUrl += 'unmonitored=true&';
}
if (premieresOnly) {
icalUrl += 'premieresOnly=true&';
}
if (asAllDay) {
icalUrl += 'asAllDay=true&';
}
if (tags.length) {
icalUrl += `tags=${tags.toString()}&`;
}
icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`;
return {
iCalHttpUrl: `${window.location.protocol}//${icalUrl}`,
iCalWebCalUrl: `webcal://${icalUrl}`,
};
}, [unmonitored, premieresOnly, asAllDay, tags]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('CalendarFeed')}</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('IncludeUnmonitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="unmonitored"
value={unmonitored}
helpText={translate('ICalIncludeUnmonitoredEpisodesHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SeasonPremieresOnly')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="premieresOnly"
value={premieresOnly}
helpText={translate('ICalSeasonPremieresOnlyHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ICalShowAsAllDayEvents')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="asAllDay"
value={asAllDay}
helpText={translate('ICalShowAsAllDayEventsHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.SERIES_TAG}
name="tags"
value={tags}
helpText={translate('ICalTagsSeriesHelpText')}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup size={sizes.LARGE}>
<FormLabel>{translate('ICalFeed')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="iCalHttpUrl"
value={iCalHttpUrl}
readOnly={true}
helpText={translate('ICalFeedHelpText')}
buttons={[
<ClipboardButton
key="copy"
value={iCalHttpUrl}
kind={kinds.DEFAULT}
/>,
<FormInputButton
key="webcal"
kind={kinds.DEFAULT}
to={iCalWebCalUrl}
target="_blank"
noRouter={true}
>
<Icon name={icons.CALENDAR_O} />
</FormInputButton>,
]}
onChange={handleInputChange}
onFocus={handleLinkFocus}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
export default CalendarLinkModalContent;

View File

@@ -1,17 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import CalendarLinkModalContent from './CalendarLinkModalContent';
function createMapStateToProps() {
return createSelector(
createTagsSelector(),
(tagList) => {
return {
tagList
};
}
);
}
export default connect(createMapStateToProps)(CalendarLinkModalContent);

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'Components/Alert';
import PathInput from 'Components/Form/PathInput';
import { PathInputInternal } from 'Components/Form/PathInput';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
@@ -151,7 +151,7 @@ function FileBrowserModalContent(props: FileBrowserModalContentProps) {
</Alert>
) : null}
<PathInput
<PathInputInternal
className={styles.pathInput}
placeholder={translate('FileBrowserPlaceholderText')}
hasFileBrowser={false}

View File

@@ -13,6 +13,7 @@ import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue';
import QueueStatusFilterBuilderRowValue from './QueueStatusFilterBuilderRowValue';
import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue';
import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
@@ -80,6 +81,9 @@ function getRowValueConnector(selectedFilterBuilderProp) {
case filterBuilderValueTypes.QUALITY_PROFILE:
return QualityProfileFilterBuilderRowValue;
case filterBuilderValueTypes.QUEUE_STATUS:
return QueueStatusFilterBuilderRowValue;
case filterBuilderValueTypes.SEASONS_MONITORED_STATUS:
return SeasonsMonitoredStatusFilterBuilderRowValue;

View File

@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TagInput from 'Components/Form/TagInput';
import TagInput from 'Components/Form/Tag/TagInput';
import { filterBuilderTypes, filterBuilderValueTypes, kinds } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import convertToBytes from 'Utilities/Number/convertToBytes';

View File

@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import TagInputTag from 'Components/Form/TagInputTag';
import TagInputTag from 'Components/Form/Tag/TagInputTag';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './FilterBuilderRowValueTag.css';

View File

@@ -0,0 +1,67 @@
import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
import FilterBuilderRowValueProps from './FilterBuilderRowValueProps';
const statusTagList = [
{
id: 'queued',
get name() {
return translate('Queued');
},
},
{
id: 'paused',
get name() {
return translate('Paused');
},
},
{
id: 'downloading',
get name() {
return translate('Downloading');
},
},
{
id: 'completed',
get name() {
return translate('Completed');
},
},
{
id: 'failed',
get name() {
return translate('Failed');
},
},
{
id: 'warning',
get name() {
return translate('Warning');
},
},
{
id: 'delay',
get name() {
return translate('Delay');
},
},
{
id: 'downloadClientUnavailable',
get name() {
return translate('DownloadClientUnavailable');
},
},
{
id: 'fallback',
get name() {
return translate('Fallback');
},
},
];
function QueueStatusFilterBuilderRowValue(props: FilterBuilderRowValueProps) {
return <FilterBuilderRowValue {...props} tagList={statusTagList} />;
}
export default QueueStatusFilterBuilderRowValue;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
const seriesStatusList = [
const statusTagList = [
{
id: 'continuing',
get name() {
@@ -32,7 +32,7 @@ const seriesStatusList = [
function SeriesStatusFilterBuilderRowValue(props) {
return (
<FilterBuilderRowValue
tagList={seriesStatusList}
tagList={statusTagList}
{...props}
/>
);

View File

@@ -1,98 +0,0 @@
import jdu from 'jdu';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import AutoSuggestInput from './AutoSuggestInput';
class AutoCompleteInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
suggestions: []
};
}
//
// Control
getSuggestionValue(item) {
return item;
}
renderSuggestion(item) {
return item;
}
//
// Listeners
onInputChange = (event, { newValue }) => {
this.props.onChange({
name: this.props.name,
value: newValue
});
};
onInputBlur = () => {
this.setState({ suggestions: [] });
};
onSuggestionsFetchRequested = ({ value }) => {
const { values } = this.props;
const lowerCaseValue = jdu.replace(value).toLowerCase();
const filteredValues = values.filter((v) => {
return jdu.replace(v).toLowerCase().contains(lowerCaseValue);
});
this.setState({ suggestions: filteredValues });
};
onSuggestionsClearRequested = () => {
this.setState({ suggestions: [] });
};
//
// Render
render() {
const {
name,
value,
...otherProps
} = this.props;
const { suggestions } = this.state;
return (
<AutoSuggestInput
{...otherProps}
name={name}
value={value}
suggestions={suggestions}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
onInputBlur={this.onInputBlur}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/>
);
}
}
AutoCompleteInput.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired
};
AutoCompleteInput.defaultProps = {
value: ''
};
export default AutoCompleteInput;

View File

@@ -0,0 +1,81 @@
import jdu from 'jdu';
import React, { SyntheticEvent, useCallback, useState } from 'react';
import {
ChangeEvent,
SuggestionsFetchRequestedParams,
} from 'react-autosuggest';
import { InputChanged } from 'typings/inputs';
import AutoSuggestInput from './AutoSuggestInput';
interface AutoCompleteInputProps {
name: string;
value?: string;
values: string[];
onChange: (change: InputChanged<string>) => unknown;
}
function AutoCompleteInput({
name,
value = '',
values,
onChange,
...otherProps
}: AutoCompleteInputProps) {
const [suggestions, setSuggestions] = useState<string[]>([]);
const getSuggestionValue = useCallback((item: string) => {
return item;
}, []);
const renderSuggestion = useCallback((item: string) => {
return item;
}, []);
const handleInputChange = useCallback(
(_event: SyntheticEvent, { newValue }: ChangeEvent) => {
onChange({
name,
value: newValue,
});
},
[name, onChange]
);
const handleInputBlur = useCallback(() => {
setSuggestions([]);
}, [setSuggestions]);
const handleSuggestionsFetchRequested = useCallback(
({ value: newValue }: SuggestionsFetchRequestedParams) => {
const lowerCaseValue = jdu.replace(newValue).toLowerCase();
const filteredValues = values.filter((v) => {
return jdu.replace(v).toLowerCase().includes(lowerCaseValue);
});
setSuggestions(filteredValues);
},
[values, setSuggestions]
);
const handleSuggestionsClearRequested = useCallback(() => {
setSuggestions([]);
}, [setSuggestions]);
return (
<AutoSuggestInput
{...otherProps}
name={name}
value={value}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
onInputChange={handleInputChange}
onInputBlur={handleInputBlur}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
/>
);
}
export default AutoCompleteInput;

View File

@@ -1,257 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import styles from './AutoSuggestInput.css';
class AutoSuggestInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scheduleUpdate = null;
}
componentDidUpdate(prevProps) {
if (
this._scheduleUpdate &&
prevProps.suggestions !== this.props.suggestions
) {
this._scheduleUpdate();
}
}
//
// Control
renderInputComponent = (inputProps) => {
const { renderInputComponent } = this.props;
return (
<Reference>
{({ ref }) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, ref);
}
return (
<div ref={ref}>
<input
{...inputProps}
/>
</div>
);
}}
</Reference>
);
};
renderSuggestionsContainer = ({ containerProps, children }) => {
return (
<Portal>
<Popper
placement='bottom-start'
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
},
flip: {
padding: this.props.minHeight
}
}}
>
{({ ref: popperRef, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div
ref={popperRef}
style={style}
className={children ? styles.suggestionsContainerOpen : undefined}
>
<div
{...containerProps}
style={{
maxHeight: style.maxHeight
}}
>
{children}
</div>
</div>
);
}}
</Popper>
</Portal>
);
};
//
// Listeners
onComputeMaxHeight = (data) => {
const {
top,
bottom,
width
} = data.offsets.reference;
const windowHeight = window.innerHeight;
if ((/^botton/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
data.styles.width = width;
return data;
};
onInputChange = (event, { newValue }) => {
this.props.onChange({
name: this.props.name,
value: newValue
});
};
onInputKeyDown = (event) => {
const {
name,
value,
suggestions,
onChange
} = this.props;
if (
event.key === 'Tab' &&
suggestions.length &&
suggestions[0] !== this.props.value
) {
event.preventDefault();
if (value) {
onChange({
name,
value: suggestions[0]
});
}
}
};
//
// Render
render() {
const {
forwardedRef,
className,
inputContainerClassName,
name,
value,
placeholder,
suggestions,
hasError,
hasWarning,
getSuggestionValue,
renderSuggestion,
onInputChange,
onInputKeyDown,
onInputFocus,
onInputBlur,
onSuggestionsFetchRequested,
onSuggestionsClearRequested,
onSuggestionSelected,
...otherProps
} = this.props;
const inputProps = {
className: classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: onInputChange || this.onInputChange,
onKeyDown: onInputKeyDown || this.onInputKeyDown,
onFocus: onInputFocus,
onBlur: onInputBlur
};
const theme = {
container: inputContainerClassName,
containerOpen: styles.suggestionsContainerOpen,
suggestionsContainer: styles.suggestionsContainer,
suggestionsList: styles.suggestionsList,
suggestion: styles.suggestion,
suggestionHighlighted: styles.suggestionHighlighted
};
return (
<Manager>
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={this.renderInputComponent}
renderSuggestionsContainer={this.renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
</Manager>
);
}
}
AutoSuggestInput.propTypes = {
forwardedRef: PropTypes.func,
className: PropTypes.string.isRequired,
inputContainerClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
placeholder: PropTypes.string,
suggestions: PropTypes.array.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
enforceMaxHeight: PropTypes.bool.isRequired,
minHeight: PropTypes.number.isRequired,
maxHeight: PropTypes.number.isRequired,
getSuggestionValue: PropTypes.func.isRequired,
renderInputComponent: PropTypes.elementType,
renderSuggestion: PropTypes.func.isRequired,
onInputChange: PropTypes.func,
onInputKeyDown: PropTypes.func,
onInputFocus: PropTypes.func,
onInputBlur: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func,
onChange: PropTypes.func.isRequired
};
AutoSuggestInput.defaultProps = {
className: styles.input,
inputContainerClassName: styles.inputContainer,
enforceMaxHeight: true,
minHeight: 50,
maxHeight: 200
};
export default AutoSuggestInput;

View File

@@ -0,0 +1,259 @@
import classNames from 'classnames';
import React, {
FocusEvent,
FormEvent,
KeyboardEvent,
KeyboardEventHandler,
MutableRefObject,
ReactNode,
Ref,
SyntheticEvent,
useCallback,
useEffect,
useRef,
} from 'react';
import Autosuggest, {
AutosuggestPropsBase,
BlurEvent,
ChangeEvent,
RenderInputComponentProps,
RenderSuggestionsContainerParams,
} from 'react-autosuggest';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { InputChanged } from 'typings/inputs';
import styles from './AutoSuggestInput.css';
interface AutoSuggestInputProps<T>
extends Omit<AutosuggestPropsBase<T>, 'renderInputComponent' | 'inputProps'> {
forwardedRef?: MutableRefObject<Autosuggest<T> | null>;
className?: string;
inputContainerClassName?: string;
name: string;
value?: string;
placeholder?: string;
suggestions: T[];
hasError?: boolean;
hasWarning?: boolean;
enforceMaxHeight?: boolean;
minHeight?: number;
maxHeight?: number;
renderInputComponent?: (
inputProps: RenderInputComponentProps,
ref: Ref<HTMLDivElement>
) => ReactNode;
onInputChange: (
event: FormEvent<HTMLElement>,
params: ChangeEvent
) => unknown;
onInputKeyDown?: KeyboardEventHandler<HTMLElement>;
onInputFocus?: (event: SyntheticEvent) => unknown;
onInputBlur: (
event: FocusEvent<HTMLElement>,
params?: BlurEvent<T>
) => unknown;
onChange?: (change: InputChanged<T>) => unknown;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function AutoSuggestInput<T = any>(props: AutoSuggestInputProps<T>) {
const {
// TODO: forwaredRef should be replaces with React.forwardRef
forwardedRef,
className = styles.input,
inputContainerClassName = styles.inputContainer,
name,
value = '',
placeholder,
suggestions,
enforceMaxHeight = true,
hasError,
hasWarning,
minHeight = 50,
maxHeight = 200,
getSuggestionValue,
renderSuggestion,
renderInputComponent,
onInputChange,
onInputKeyDown,
onInputFocus,
onInputBlur,
onSuggestionsFetchRequested,
onSuggestionsClearRequested,
onSuggestionSelected,
onChange,
...otherProps
} = props;
const updater = useRef<(() => void) | null>(null);
const previousSuggestions = usePrevious(suggestions);
const handleComputeMaxHeight = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data: any) => {
const { top, bottom, width } = data.offsets.reference;
if (enforceMaxHeight) {
data.styles.maxHeight = maxHeight;
} else {
const windowHeight = window.innerHeight;
if (/^botton/.test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
}
data.styles.width = width;
return data;
},
[enforceMaxHeight, maxHeight]
);
const createRenderInputComponent = useCallback(
(inputProps: RenderInputComponentProps) => {
return (
<Reference>
{({ ref }) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, ref);
}
return (
<div ref={ref}>
<input {...inputProps} />
</div>
);
}}
</Reference>
);
},
[renderInputComponent]
);
const renderSuggestionsContainer = useCallback(
({ containerProps, children }: RenderSuggestionsContainerParams) => {
return (
<Portal>
<Popper
placement="bottom-start"
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: handleComputeMaxHeight,
},
flip: {
padding: minHeight,
},
}}
>
{({ ref: popperRef, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return (
<div
ref={popperRef}
style={style}
className={
children ? styles.suggestionsContainerOpen : undefined
}
>
<div
{...containerProps}
style={{
maxHeight: style.maxHeight,
}}
>
{children}
</div>
</div>
);
}}
</Popper>
</Portal>
);
},
[minHeight, handleComputeMaxHeight]
);
const handleInputKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (
event.key === 'Tab' &&
suggestions.length &&
suggestions[0] !== value
) {
event.preventDefault();
if (value) {
onSuggestionSelected?.(event, {
suggestion: suggestions[0],
suggestionValue: value,
suggestionIndex: 0,
sectionIndex: null,
method: 'enter',
});
}
}
},
[value, suggestions, onSuggestionSelected]
);
const inputProps = {
className: classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: onInputChange,
onKeyDown: onInputKeyDown || handleInputKeyDown,
onFocus: onInputFocus,
onBlur: onInputBlur,
};
const theme = {
container: inputContainerClassName,
containerOpen: styles.suggestionsContainerOpen,
suggestionsContainer: styles.suggestionsContainer,
suggestionsList: styles.suggestionsList,
suggestion: styles.suggestion,
suggestionHighlighted: styles.suggestionHighlighted,
};
useEffect(() => {
if (updater.current && suggestions !== previousSuggestions) {
updater.current();
}
}, [suggestions, previousSuggestions]);
return (
<Manager>
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={createRenderInputComponent}
renderSuggestionsContainer={renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
</Manager>
);
}
export default AutoSuggestInput;

View File

@@ -1,84 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import FormInputButton from './FormInputButton';
import TextInput from './TextInput';
import styles from './CaptchaInput.css';
function CaptchaInput(props) {
const {
className,
name,
value,
hasError,
hasWarning,
refreshing,
siteKey,
secretToken,
onChange,
onRefreshPress,
onCaptchaChange
} = props;
return (
<div>
<div className={styles.captchaInputWrapper}>
<TextInput
className={classNames(
className,
styles.hasButton,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
name={name}
value={value}
onChange={onChange}
/>
<FormInputButton
onPress={onRefreshPress}
>
<Icon
name={icons.REFRESH}
isSpinning={refreshing}
/>
</FormInputButton>
</div>
{
!!siteKey && !!secretToken &&
<div className={styles.recaptchaWrapper}>
<ReCAPTCHA
sitekey={siteKey}
stoken={secretToken}
onChange={onCaptchaChange}
/>
</div>
}
</div>
);
}
CaptchaInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
refreshing: PropTypes.bool.isRequired,
siteKey: PropTypes.string,
secretToken: PropTypes.string,
onChange: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onCaptchaChange: PropTypes.func.isRequired
};
CaptchaInput.defaultProps = {
className: styles.input,
value: ''
};
export default CaptchaInput;

View File

@@ -0,0 +1,118 @@
import classNames from 'classnames';
import React, { useCallback, useEffect } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Icon from 'Components/Icon';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons } from 'Helpers/Props';
import {
getCaptchaCookie,
refreshCaptcha,
resetCaptcha,
} from 'Store/Actions/captchaActions';
import { InputChanged } from 'typings/inputs';
import FormInputButton from './FormInputButton';
import TextInput from './TextInput';
import styles from './CaptchaInput.css';
interface CaptchaInputProps {
className?: string;
name: string;
value?: string;
provider: string;
providerData: object;
hasError?: boolean;
hasWarning?: boolean;
refreshing: boolean;
siteKey?: string;
secretToken?: string;
onChange: (change: InputChanged<string>) => unknown;
}
function CaptchaInput({
className = styles.input,
name,
value = '',
provider,
providerData,
hasError,
hasWarning,
refreshing,
siteKey,
secretToken,
onChange,
}: CaptchaInputProps) {
const { token } = useSelector((state: AppState) => state.captcha);
const dispatch = useDispatch();
const previousToken = usePrevious(token);
const handleCaptchaChange = useCallback(
(token: string | null) => {
// If the captcha has expired `captchaResponse` will be null.
// In the event it's null don't try to get the captchaCookie.
// TODO: Should we clear the cookie? or reset the captcha?
if (!token) {
return;
}
dispatch(
getCaptchaCookie({
provider,
providerData,
captchaResponse: token,
})
);
},
[provider, providerData, dispatch]
);
const handleRefreshPress = useCallback(() => {
dispatch(refreshCaptcha({ provider, providerData }));
}, [provider, providerData, dispatch]);
useEffect(() => {
if (token && token !== previousToken) {
onChange({ name, value: token });
}
}, [name, token, previousToken, onChange]);
useEffect(() => {
dispatch(resetCaptcha());
}, [dispatch]);
return (
<div>
<div className={styles.captchaInputWrapper}>
<TextInput
className={classNames(
className,
styles.hasButton,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
name={name}
value={value}
onChange={onChange}
/>
<FormInputButton onPress={handleRefreshPress}>
<Icon name={icons.REFRESH} isSpinning={refreshing} />
</FormInputButton>
</div>
{siteKey && secretToken ? (
<div className={styles.recaptchaWrapper}>
<ReCAPTCHA
sitekey={siteKey}
stoken={secretToken}
onChange={handleCaptchaChange}
/>
</div>
) : null}
</div>
);
}
export default CaptchaInput;

View File

@@ -1,98 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { getCaptchaCookie, refreshCaptcha, resetCaptcha } from 'Store/Actions/captchaActions';
import CaptchaInput from './CaptchaInput';
function createMapStateToProps() {
return createSelector(
(state) => state.captcha,
(captcha) => {
return captcha;
}
);
}
const mapDispatchToProps = {
refreshCaptcha,
getCaptchaCookie,
resetCaptcha
};
class CaptchaInputConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps) {
const {
name,
token,
onChange
} = this.props;
if (token && token !== prevProps.token) {
onChange({ name, value: token });
}
}
componentWillUnmount = () => {
this.props.resetCaptcha();
};
//
// Listeners
onRefreshPress = () => {
const {
provider,
providerData
} = this.props;
this.props.refreshCaptcha({ provider, providerData });
};
onCaptchaChange = (captchaResponse) => {
// If the captcha has expired `captchaResponse` will be null.
// In the event it's null don't try to get the captchaCookie.
// TODO: Should we clear the cookie? or reset the captcha?
if (!captchaResponse) {
return;
}
const {
provider,
providerData
} = this.props;
this.props.getCaptchaCookie({ provider, providerData, captchaResponse });
};
//
// Render
render() {
return (
<CaptchaInput
{...this.props}
onRefreshPress={this.onRefreshPress}
onCaptchaChange={this.onCaptchaChange}
/>
);
}
}
CaptchaInputConnector.propTypes = {
provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
token: PropTypes.string,
onChange: PropTypes.func.isRequired,
refreshCaptcha: PropTypes.func.isRequired,
getCaptchaCookie: PropTypes.func.isRequired,
resetCaptcha: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(CaptchaInputConnector);

View File

@@ -1,191 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import { icons, kinds } from 'Helpers/Props';
import FormInputHelpText from './FormInputHelpText';
import styles from './CheckInput.css';
class CheckInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._checkbox = null;
}
componentDidMount() {
this.setIndeterminate();
}
componentDidUpdate() {
this.setIndeterminate();
}
//
// Control
setIndeterminate() {
if (!this._checkbox) {
return;
}
const {
value,
uncheckedValue,
checkedValue
} = this.props;
this._checkbox.indeterminate = value !== uncheckedValue && value !== checkedValue;
}
toggleChecked = (checked, shiftKey) => {
const {
name,
value,
checkedValue,
uncheckedValue
} = this.props;
const newValue = checked ? checkedValue : uncheckedValue;
if (value !== newValue) {
this.props.onChange({
name,
value: newValue,
shiftKey
});
}
};
//
// Listeners
setRef = (ref) => {
this._checkbox = ref;
};
onClick = (event) => {
if (this.props.isDisabled) {
return;
}
const shiftKey = event.nativeEvent.shiftKey;
const checked = !this._checkbox.checked;
event.preventDefault();
this.toggleChecked(checked, shiftKey);
};
onChange = (event) => {
const checked = event.target.checked;
const shiftKey = event.nativeEvent.shiftKey;
this.toggleChecked(checked, shiftKey);
};
//
// Render
render() {
const {
className,
containerClassName,
name,
value,
checkedValue,
uncheckedValue,
helpText,
helpTextWarning,
isDisabled,
kind
} = this.props;
const isChecked = value === checkedValue;
const isUnchecked = value === uncheckedValue;
const isIndeterminate = !isChecked && !isUnchecked;
const isCheckClass = `${kind}IsChecked`;
return (
<div className={containerClassName}>
<label
className={styles.label}
onClick={this.onClick}
>
<input
ref={this.setRef}
className={styles.checkbox}
type="checkbox"
name={name}
checked={isChecked}
disabled={isDisabled}
onChange={this.onChange}
/>
<div
className={classNames(
className,
isChecked ? styles[isCheckClass] : styles.isNotChecked,
isIndeterminate && styles.isIndeterminate,
isDisabled && styles.isDisabled
)}
>
{
isChecked &&
<Icon name={icons.CHECK} />
}
{
isIndeterminate &&
<Icon name={icons.CHECK_INDETERMINATE} />
}
</div>
{
helpText &&
<FormInputHelpText
className={styles.helpText}
text={helpText}
/>
}
{
!helpText && helpTextWarning &&
<FormInputHelpText
className={styles.helpText}
text={helpTextWarning}
isWarning={true}
/>
}
</label>
</div>
);
}
}
CheckInput.propTypes = {
className: PropTypes.string.isRequired,
containerClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
checkedValue: PropTypes.bool,
uncheckedValue: PropTypes.bool,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
helpText: PropTypes.string,
helpTextWarning: PropTypes.string,
isDisabled: PropTypes.bool,
kind: PropTypes.oneOf(kinds.all).isRequired,
onChange: PropTypes.func.isRequired
};
CheckInput.defaultProps = {
className: styles.input,
containerClassName: styles.container,
checkedValue: true,
uncheckedValue: false,
kind: kinds.PRIMARY
};
export default CheckInput;

View File

@@ -0,0 +1,141 @@
import classNames from 'classnames';
import React, { SyntheticEvent, useCallback, useEffect, useRef } from 'react';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { CheckInputChanged } from 'typings/inputs';
import FormInputHelpText from './FormInputHelpText';
import styles from './CheckInput.css';
interface ChangeEvent<T = Element> extends SyntheticEvent<T, MouseEvent> {
target: EventTarget & T;
}
interface CheckInputProps {
className?: string;
containerClassName?: string;
name: string;
checkedValue?: boolean;
uncheckedValue?: boolean;
value?: string | boolean;
helpText?: string;
helpTextWarning?: string;
isDisabled?: boolean;
kind?: Extract<Kind, keyof typeof styles>;
onChange: (changes: CheckInputChanged) => void;
}
function CheckInput(props: CheckInputProps) {
const {
className = styles.input,
containerClassName = styles.container,
name,
value,
checkedValue = true,
uncheckedValue = false,
helpText,
helpTextWarning,
isDisabled,
kind = 'primary',
onChange,
} = props;
const inputRef = useRef<HTMLInputElement>(null);
const isChecked = value === checkedValue;
const isUnchecked = value === uncheckedValue;
const isIndeterminate = !isChecked && !isUnchecked;
const isCheckClass: keyof typeof styles = `${kind}IsChecked`;
const toggleChecked = useCallback(
(checked: boolean, shiftKey: boolean) => {
const newValue = checked ? checkedValue : uncheckedValue;
if (value !== newValue) {
onChange({
name,
value: newValue,
shiftKey,
});
}
},
[name, value, checkedValue, uncheckedValue, onChange]
);
const handleClick = useCallback(
(event: SyntheticEvent<HTMLElement, MouseEvent>) => {
if (isDisabled) {
return;
}
const shiftKey = event.nativeEvent.shiftKey;
const checked = !(inputRef.current?.checked ?? false);
event.preventDefault();
toggleChecked(checked, shiftKey);
},
[isDisabled, toggleChecked]
);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const checked = event.target.checked;
const shiftKey = event.nativeEvent.shiftKey;
toggleChecked(checked, shiftKey);
},
[toggleChecked]
);
useEffect(() => {
if (!inputRef.current) {
return;
}
inputRef.current.indeterminate =
value !== uncheckedValue && value !== checkedValue;
}, [value, uncheckedValue, checkedValue]);
return (
<div className={containerClassName}>
<label className={styles.label} onClick={handleClick}>
<input
ref={inputRef}
className={styles.checkbox}
type="checkbox"
name={name}
checked={isChecked}
disabled={isDisabled}
onChange={handleChange}
/>
<div
className={classNames(
className,
isChecked ? styles[isCheckClass] : styles.isNotChecked,
isIndeterminate && styles.isIndeterminate,
isDisabled && styles.isDisabled
)}
>
{isChecked ? <Icon name={icons.CHECK} /> : null}
{isIndeterminate ? <Icon name={icons.CHECK_INDETERMINATE} /> : null}
</div>
{helpText ? (
<FormInputHelpText className={styles.helpText} text={helpText} />
) : null}
{!helpText && helpTextWarning ? (
<FormInputHelpText
className={styles.helpText}
text={helpTextWarning}
isWarning={true}
/>
) : null}
</label>
</div>
);
}
export default CheckInput;

View File

@@ -1,106 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape';
import FormInputButton from './FormInputButton';
import TagInput from './TagInput';
import styles from './DeviceInput.css';
class DeviceInput extends Component {
onTagAdd = (device) => {
const {
name,
value,
onChange
} = this.props;
// New tags won't have an ID, only a name.
const deviceId = device.id || device.name;
onChange({
name,
value: [...value, deviceId]
});
};
onTagDelete = ({ index }) => {
const {
name,
value,
onChange
} = this.props;
const newValue = value.slice();
newValue.splice(index, 1);
onChange({
name,
value: newValue
});
};
//
// Render
render() {
const {
className,
name,
items,
selectedDevices,
hasError,
hasWarning,
isFetching,
onRefreshPress
} = this.props;
return (
<div className={className}>
<TagInput
inputContainerClassName={styles.input}
name={name}
tags={selectedDevices}
tagList={items}
allowNew={true}
minQueryLength={0}
hasError={hasError}
hasWarning={hasWarning}
onTagAdd={this.onTagAdd}
onTagDelete={this.onTagDelete}
/>
<FormInputButton
onPress={onRefreshPress}
>
<Icon
name={icons.REFRESH}
isSpinning={isFetching}
/>
</FormInputButton>
</div>
);
}
}
DeviceInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])).isRequired,
items: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
selectedDevices: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func.isRequired
};
DeviceInput.defaultProps = {
className: styles.deviceInputWrapper,
inputClassName: styles.input
};
export default DeviceInput;

View File

@@ -1,104 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearOptions, defaultState, fetchOptions } from 'Store/Actions/providerOptionActions';
import DeviceInput from './DeviceInput';
function createMapStateToProps() {
return createSelector(
(state, { value }) => value,
(state) => state.providerOptions.devices || defaultState,
(value, devices) => {
return {
...devices,
selectedDevices: value.map((valueDevice) => {
// Disable equality ESLint rule so we don't need to worry about
// a type mismatch between the value items and the device ID.
// eslint-disable-next-line eqeqeq
const device = devices.items.find((d) => d.id == valueDevice);
if (device) {
return {
id: device.id,
name: `${device.name} (${device.id})`
};
}
return {
id: valueDevice,
name: `Unknown (${valueDevice})`
};
})
};
}
);
}
const mapDispatchToProps = {
dispatchFetchOptions: fetchOptions,
dispatchClearOptions: clearOptions
};
class DeviceInputConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
this._populate();
};
componentWillUnmount = () => {
this.props.dispatchClearOptions({ section: 'devices' });
};
//
// Control
_populate() {
const {
provider,
providerData,
dispatchFetchOptions
} = this.props;
dispatchFetchOptions({
section: 'devices',
action: 'getDevices',
provider,
providerData
});
}
//
// Listeners
onRefreshPress = () => {
this._populate();
};
//
// Render
render() {
return (
<DeviceInput
{...this.props}
onRefreshPress={this.onRefreshPress}
/>
);
}
}
DeviceInputConnector.propTypes = {
provider: PropTypes.string.isRequired,
providerData: PropTypes.object.isRequired,
name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
dispatchFetchOptions: PropTypes.func.isRequired,
dispatchClearOptions: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(DeviceInputConnector);

View File

@@ -1,102 +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 { fetchDownloadClients } from 'Store/Actions/settingsActions';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.downloadClients,
(state, { includeAny }) => includeAny,
(state, { protocol }) => protocol,
(downloadClients, includeAny, protocolFilter) => {
const {
isFetching,
isPopulated,
error,
items
} = downloadClients;
const filteredItems = items.filter((item) => item.protocol === protocolFilter);
const values = _.map(filteredItems.sort(sortByProp('name')), (downloadClient) => {
return {
key: downloadClient.id,
value: downloadClient.name,
hint: `(${downloadClient.id})`
};
});
if (includeAny) {
values.unshift({
key: 0,
value: `(${translate('Any')})`
});
}
return {
isFetching,
isPopulated,
error,
values
};
}
);
}
const mapDispatchToProps = {
dispatchFetchDownloadClients: fetchDownloadClients
};
class DownloadClientSelectInputConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.isPopulated) {
this.props.dispatchFetchDownloadClients();
}
}
//
// Listeners
onChange = ({ name, value }) => {
this.props.onChange({ name, value: parseInt(value) });
};
//
// Render
render() {
return (
<EnhancedSelectInput
{...this.props}
onChange={this.onChange}
/>
);
}
}
DownloadClientSelectInputConnector.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
includeAny: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
dispatchFetchDownloadClients: PropTypes.func.isRequired
};
DownloadClientSelectInputConnector.defaultProps = {
includeAny: false,
protocol: 'torrent'
};
export default connect(createMapStateToProps, mapDispatchToProps)(DownloadClientSelectInputConnector);

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