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

Compare commits

...

107 Commits

Author SHA1 Message Date
Bogdan
cd29c0c9c8 Fix stable branch label in update 2024-10-20 04:41:20 +03:00
Servarr
9986d04d36 Automated API Docs update 2024-10-19 09:48:14 +03:00
Mark McDowall
f900d623dc New: Allow major version updates to be installed
(cherry picked from commit 0e95ba2021b23cc65bce0a0620dd48e355250dab)
2024-10-19 09:01:56 +03:00
Bogdan
84b507faf3 New: Romania and India added to list of Certification Countries 2024-10-19 08:59:29 +03:00
Bogdan
adb27123df Natural sorting for tags list in the UI
(cherry picked from commit 09d3ae969281715a26ffd374c148cafe17a8f438)
2024-10-18 23:04:31 +03:00
Bogdan
a06792b923 New: Sync updates to UI for providers (#10550) 2024-10-18 23:02:42 +03:00
Bogdan
d90ee3ae11 Fixed: Release Year mandatory to generate valid file formats 2024-10-18 12:48:51 +03:00
Bogdan
ff38afd198 Fixed: Add only movies with release dates from monitored collections 2024-10-18 12:09:37 +03:00
Weblate
db70c06b8b Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Buloni <ershov.artjom@yandex.ru>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: JoseFilipeFerreira <jose.filipe.matos.ferreira@gmail.com>
Co-authored-by: Kuzmich55 <kuzmich55@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sk/
Translation: Servarr/Radarr
2024-10-16 04:07:30 +03:00
costaht
fb7656be56 New: Parse pt-BR releases as Brazilian Portuguese (#10554)
* New: Match releases with pt-BR (BPC-47) with Portuguese Brazilian
2024-10-15 05:59:42 +03:00
Bogdan
3287e7cdec Bump dotnet to 6.0.35 2024-10-13 19:42:07 +03:00
Bogdan
0761e27cfa New: Parse ES as Spanish 2024-10-12 19:54:54 +03:00
Bogdan
4f47bb39ac Bump version to 5.13.0 2024-10-12 18:58:59 +03:00
Bogdan
889d071004 New: Display items tags on import lists index 2024-10-10 22:51:32 +03:00
Bogdan
0049922ab6 Include exception message in SkyHook failure message 2024-10-10 21:17:20 +03:00
Bogdan
3c995a0fff Bump babel packages 2024-10-10 19:01:53 +03:00
Bogdan
430719baac Remove unused gulp packages 2024-10-10 18:55:46 +03:00
Bogdan
9928d711a3 Trim multiple occurrences of ending separators in filename 2024-10-10 15:26:00 +03:00
Bogdan
f90b43b3e1 Simplify parsing IMDb and TMDb urls as search terms 2024-10-10 03:25:10 +03:00
Steel City Phantom
64122b4cfb Auto-detect building on macOS ARM (#10539) 2024-10-10 02:41:07 +03:00
Bogdan
7912a942f7 Bump frontend packages 2024-10-10 02:40:02 +03:00
Bogdan
0a7607bb62 Bump dotnet packages 2024-10-10 02:40:02 +03:00
Vincent Caron
beeb5204b8 New: Parse IMDB and TMDB URLs as search terms 2024-10-10 00:32:39 +01:00
Bogdan
ab13fb6e99 Fix index variable in fuse worker 2024-10-09 01:26:26 +03:00
Weblate
2a3d595a66 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Ardenet <1213193613@qq.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: angelsky11 <angelsky11@gmail.com>
Co-authored-by: anne <gagatebis@hotmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: jsain <josip.sain@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/is/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ja/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/th/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/vi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2024-10-08 13:26:52 +03:00
Jared Ledvina
958a863d8f Recompare file size after import file if necessary
(cherry picked from commit 6660db22ecf53d7747e3abc400529669ea779fa1)
2024-10-08 13:25:43 +03:00
Servarr
8b7884deb0 Automated API Docs update 2024-10-08 13:25:15 +03:00
Bogdan
9a22e1c791 Bump browserslist-db 2024-10-08 02:15:07 +03:00
Bogdan
f0f828491b Fixed: Copy to clipboard in non-secure contexts
(cherry picked from commit 3828e475cc8860e74cdfd8a70b4f886de7f9c5c3)

Closes #10525
2024-10-08 02:11:35 +03:00
Treycos
7f3d107eda Convert ClipboardButton to TypeScript
(cherry picked from commit 99fc52039f44264c83d939e5f096d8e16d2f3355)

Closes #10452
2024-10-08 02:09:10 +03:00
Bogdan
ce4477eeac Improve filename examples for movies naming 2024-10-08 01:54:46 +03:00
Bogdan
8b64f873f4 Convert Naming options to TypeScript 2024-10-08 01:54:46 +03:00
Bogdan
38bd060960 Convert FormInputButton to TypeScript 2024-10-08 01:54:46 +03:00
Bogdan
7c243cb6e8 Fixed: Error updating providers with ID missing from JSON
(cherry picked from commit c435fcd685cc97e98d14f747227eefd39e4d1164)
2024-10-08 01:52:26 +03:00
Bogdan
b29dee63f4 Use the first allowed quality for cutoff met rejection message with disabled upgrades 2024-10-07 22:26:55 +03:00
Mark McDowall
f6542bab0a New: Use 307 redirect for requests missing URL Base 2024-10-06 17:22:32 +03:00
Bogdan
da1b53b7e2 Bump macOS runner version to 13 2024-10-06 16:21:38 +03:00
Bogdan
0deae95782 Bump version to 5.12.2 2024-10-06 12:03:04 +03:00
Mark McDowall
75c7a3cfc6 Fixed: Ignore free space check before grabbing if directory is missing 2024-10-06 00:15:40 +03:00
Bogdan
cfdb7a15de Simplify defaults set when adding release profiles and list exclusions 2024-10-05 13:02:36 +03:00
Bogdan
63a7d33e7e Fixed: Cleaning the path for movie collections with top level folders 2024-10-05 12:01:13 +03:00
Bogdan
c9836f997c Fixed: Clean paths for top level root folders 2024-10-05 12:01:13 +03:00
Bogdan
d37e71415f Convert Release Profiles to TypeScript 2024-10-04 17:28:04 +03:00
Bogdan
9a5f4bef63 Check if root folder is not empty on files import 2024-10-04 12:04:09 +03:00
Bogdan
40551ba5a3 Fixed: Custom filters with release date filter
Fixes #10508
2024-10-02 22:32:33 +03:00
Bogdan
6e04dc894b Fixed: Validate path on movie update 2024-10-02 19:27:07 +03:00
Bogdan
ac767ed386 New: Add 'Movie CleanTitleThe' token
First attempt to fix movie folder validation by ignoring invalid tokens mixed from 'Original' and 'The'

Co-authored-by: Stevie Robinson <stevie.robinson@gmail.com>
2024-10-02 19:23:57 +03:00
Mark McDowall
42fbb79017 New: Parse 'BEN THE MAN' release group
(cherry picked from commit da610a1f409c9c03cbed1c27ccaedc32f42e636c)
2024-10-02 15:39:41 +03:00
Bogdan
c43bd77dae Display long date tooltips for release dates 2024-10-02 10:12:07 +03:00
Lorenzo Lewis
68dfa55b35 Fix typo README.md (#10502) 2024-10-01 12:04:50 -05:00
Bogdan
fa190c85a3 Add new category for FL 2024-09-30 17:14:18 +03:00
Bogdan
172dcf6f8d Bump version to 5.12.1 2024-09-29 08:16:01 +03:00
Weblate
0736fc955f Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Jhonata da Rocha <jhonata182@gmail.com>
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/radarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2024-09-28 04:22:34 +03:00
Bogdan
9d0b8d974d Fixed: Parsing of Hybrid-Remux 2024-09-28 04:21:08 +03:00
Bogdan
9a3e89f283 Fixed: Ignore '.DS_Store' and '.unmanic' when scanning for files 2024-09-28 04:20:22 +03:00
Mark McDowall
e33e45ec73 Fixed: Don't reject revision upgrades if profile doesn't allow upgrades
(cherry picked from commit 4f0e1c54c167f5123a33d19b76653450401adb6d)
2024-09-28 04:19:42 +03:00
Mark McDowall
5893d88058 Fixed: Ignore extra spaces in path when not running on Windows
(cherry picked from commit 6d0f10b877912edef21232c64339cc6548d9690e)
2024-09-28 04:18:47 +03:00
Servarr
a81d27acda Automated API Docs update 2024-09-26 11:43:29 +03:00
Mark McDowall
d2b279a6be Fixed: Replace illegal characters even when renaming is disabled
(cherry picked from commit 4d8a4436810828494e99f0854cf6de3269668fe4)
2024-09-26 10:22:40 +03:00
Bogdan
6686fa0600 New: Smart as default Colon Replacement
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2024-09-26 10:22:40 +03:00
Bogdan
1d286df85d Display naming example errors when all fields are empty 2024-09-26 10:22:40 +03:00
yammes08
be2e1e4fdb Fixed: SDR files being parsed as HLG
(cherry picked from commit 11e5c5a11b171138c235224c1aa9a258f0a4ec4d)
2024-09-25 09:41:21 +03:00
Bogdan
08868e5d01 Bump version to 5.12.0 2024-09-25 09:39:42 +03:00
Mark McDowall
7b43c2e345 Fixed: Loading movie images after placeholder in Safari
Closes #10474
2024-09-25 06:48:30 +03:00
Bogdan
dc599b6531 Sort allowed sorting keys 2024-09-25 06:47:37 +03:00
Weblate
1421179654 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: FloatStream <1213193613@qq.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: liuwqq <843384478@qq.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2024-09-25 02:35:29 +03:00
Bogdan
41dcf32e24 Fix translations for MovieMonitoredSelectInput 2024-09-25 02:33:02 +03:00
Bogdan
7a813a44b6 Use UTC to calculate movie status 2024-09-25 02:33:02 +03:00
Bogdan
54a5059080 Convert AvailabilitySelectInput to TypeScript 2024-09-25 02:33:02 +03:00
Bogdan
adaf7444d3 Add movie availability descriptions 2024-09-25 02:33:02 +03:00
Robin Dadswell
49d11e59b3 Fixed: Telegram Log Message 2024-09-24 16:59:30 +01:00
Servarr
a7eb4a4a04 Automated API Docs update 2024-09-24 12:10:31 +03:00
Bogdan
66a6a663ba Prevent line wraps on mobile for ratings 2024-09-24 03:31:43 +03:00
Bogdan
f735e31835 New: Trakt ratings 2024-09-24 03:31:43 +03:00
Bogdan
b8f1286abb Fixed: Sorting queue by columns 2024-09-22 07:30:46 +03:00
Mark McDowall
9df45199d0 Reprocessing manual import items unable to detect sample
(cherry picked from commit 27da0413882dc87e1617a5d091ac5111589e61a6)

Closes #10463
2024-09-22 05:21:38 +03:00
Servarr
a692c35b03 Automated API Docs update 2024-09-21 23:42:37 +03:00
momo
ddcad270c3 Fix description for API key as query parameter
(cherry picked from commit 30c36fdc3baa686102ff124833c7963fc786f251)
2024-09-21 21:18:15 +03:00
Bogdan
b06f1d7c12 Bump version to 5.11.0 2024-09-21 03:57:59 +03:00
Mark McDowall
480bb50b85 Fixed: Rejections for Custom Format score increment 2024-09-21 02:17:18 +03:00
Bogdan
dbc94dbe4e Simplify fallback to default for allowed sort keys
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2024-09-21 01:35:47 +03:00
Mark McDowall
b89271fc01 Fixed: Unable to login when instance name contained brackets 2024-09-21 01:23:25 +03:00
Bogdan
66fcde7325 Include current quality in rejection message for not an upgrade 2024-09-21 00:41:26 +03:00
Bogdan
463741da1f New: Fetch up to 1000 movies from Plex Watchlist 2024-09-18 03:49:49 +03:00
Bogdan
3388fae1a5 Fix translation key for Skip Free Space Check help text 2024-09-17 17:46:33 +03:00
bakerboy448
72b2cfe8be Fixed: Parse TELESYNCH as TELESYNC (#10445)
Fixes #10414
2024-09-17 02:34:12 +03:00
Servarr
d5dd5e08ca Automated API Docs update 2024-09-17 01:22:25 +03:00
Bogdan
fabd40cbae New: Allowed sort keys for paginated resources 2024-09-16 20:27:34 +03:00
Servarr
3ca327f611 Multiple Translations updated by Weblate (#10418)
ignore-downstream






Translate-URL: https://translate.servarr.com/projects/servarr/radarr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr

Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Kuzmich55 <kuzmich55@gmail.com>
Co-authored-by: genoher <genoher@gmail.com>
Co-authored-by: rookie7420 <yuanchong2001@qq.com>
Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>
2024-09-16 18:34:20 +03:00
Bogdan
c804140896 Fix use of min score increment in rejection message 2024-09-16 18:19:02 +03:00
somniumV
bb43d0c796 New: Minimum Upgrade Score for Custom Formats
(cherry picked from commit 8b20a9449c1ae5ffd1e8d12f1ca771727b8c52a5)
2024-09-16 18:19:02 +03:00
Mark McDowall
5757fa797f New: Use instance name in forms authentication cookie name
Closes #10416
2024-09-16 01:29:10 +03:00
Bogdan
2fc32189d8 Convert Movie Titles to TypeScript 2024-09-16 01:18:23 +03:00
Bogdan
5975be3690 Fixed: Removing import lists for cast and crew from movie details
Convert movie credits to TypeScript

Switching to metadata based order for crew
2024-09-16 01:18:23 +03:00
amdavie
6095819005 New: Scene and Nuked IndexerFlags for Newznab indexers
(cherry picked from commit 278c7891a3add639b4ff5bc1f4f5e8912dabc897)
2024-09-15 23:08:20 +03:00
Bogdan
7528882adf Gotify notification updates
New: Option to include links for Gotify notifications
New: Include images and links for Android

(cherry picked from commit 3c857135c59029635b0972f959f9a8255bcff21f)

Closes #10433
Fixes #10410
2024-09-15 22:42:59 +03:00
Mark McDowall
c1f1307345 New: Add exception to SSL Certificate validation message
(cherry picked from commit d84c4500949a530fac92d73f7f2f8e8462b37244)

Closes #10437
2024-09-15 22:42:59 +03:00
Mark McDowall
348060351a New: Check for available space before grabbing
(cherry picked from commit 4b5ff3927d3c123f9e3a2bc74328323fab1b0745)

Closes #10429
2024-09-15 22:42:59 +03:00
Mark McDowall
ca31cdd33a New: Add additional archive exentions
(cherry picked from commit 750a9353f82da4e016bee25e0c625cd6d8613b57)
2024-09-15 20:41:48 +03:00
Bogdan
36e278aa82 Bump version to 5.10.4 2024-09-15 15:52:54 +03:00
Bogdan
927e84654f Fixed: Filtering by IMDb decimal ratings 2024-09-13 01:25:37 +03:00
Bogdan
96e60906c5 Fixed: Empty or private MDBList lists shown as valid on save 2024-09-12 19:36:04 +03:00
Bogdan
7a55b563c0 New: Importing sup files as subtitles
Towards #10412
2024-09-11 20:44:38 +03:00
Servarr
b4bbb71a9b Automated API Docs update 2024-09-09 20:53:17 +03:00
ManiMatter
0361299a73 New: Last Searched column on Wanted screens (#10392)
* Adding lastSearchTime to API and "Last Searched" to Frontend (cutoff unmet & missing)
Picking lastSearchTime from movie instead of movieMetaData

---------

Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>
2024-09-08 20:56:11 +03:00
Bogdan
e11339fb83 Fix weblate widget 2024-09-08 11:26:02 +03:00
Bogdan
fbdd3129f5 Bump version to 5.10.3 2024-09-08 11:12:25 +03:00
289 changed files with 6922 additions and 5657 deletions

View File

@@ -1,7 +1,7 @@
# Radarr
[![Build Status](https://dev.azure.com/Radarr/Radarr/_apis/build/status/Radarr.Radarr?branchName=develop)](https://dev.azure.com/Radarr/Radarr/_build/latest?definitionId=1&branchName=develop)
[![Translated](https://translate.servarr.com/widgets/servarr/-/radarr/svg-badge.svg)](https://translate.servarr.com/engage/radarr/?utm_source=widget)
[![Translation status](https://translate.servarr.com/widget/servarr/radarr/svg-badge.svg)](https://translate.servarr.com/engage/servarr/?utm_source=widget)
[![Docker Pulls](https://img.shields.io/docker/pulls/linuxserver/radarr.svg)](https://wiki.servarr.com/radarr/installation/docker)
![Github Downloads](https://img.shields.io/github/downloads/Radarr/Radarr/total.svg)
[![Backers on Open Collective](https://opencollective.com/Radarr/backers/badge.svg)](#backers)
@@ -9,7 +9,7 @@
[![Mega Sponsors on Open Collective](https://opencollective.com/Radarr/megasponsors/badge.svg)](#mega-sponsors)
Radarr is a movie collection manager for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new movies and will interface with clients and indexers to grab, sort, and rename them. It can also be configured to automatically upgrade the quality of existing files in the library when a better quality format becomes available.
Note that only one type of a given movie is supported. If you want both an 4k version and 1080p version of a given movie you will need multiple instances.
Note that only one type of a given movie is supported. If you want both a 4k version and 1080p version of a given movie you will need multiple instances.
## Major Features Include

View File

@@ -9,18 +9,18 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '5.10.2'
majorVersion: '5.13.0'
minorVersion: $[counter('minorVersion', 2000)]
radarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
sentryOrg: 'servarr'
sentryUrl: 'https://sentry.servarr.com'
dotnetVersion: '6.0.424'
dotnetVersion: '6.0.427'
nodeVersion: '20.X'
innoVersion: '6.2.2'
windowsImage: 'windows-2022'
linuxImage: 'ubuntu-20.04'
macImage: 'macOS-12'
macImage: 'macOS-13'
trigger:
branches:

View File

@@ -131,7 +131,9 @@ class AddNewMovie extends Component {
<div className={styles.helpText}>
{translate('FailedLoadingSearchResults')}
</div>
<Alert kind={kinds.WARNING}>{getErrorMessage(error)}</Alert>
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
<div>
<Link to="https://wiki.servarr.com/radarr/troubleshooting#invalid-response-received-from-tmdb">
{translate('WhySearchesCouldBeFailing')}

View File

@@ -1,16 +1,19 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import MovieMinimumAvailabilityPopoverContent from 'AddMovie/MovieMinimumAvailabilityPopoverContent';
import CheckInput from 'Components/Form/CheckInput';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import SpinnerButton from 'Components/Link/SpinnerButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import MoviePoster from 'Movie/MoviePoster';
import translate from 'Utilities/String/translate';
import styles from './AddNewMovieModalContent.css';
@@ -115,13 +118,28 @@ class AddNewMovieModalContent extends Component {
</FormGroup>
<FormGroup>
<FormLabel>{translate('MinimumAvailability')}</FormLabel>
<FormLabel>
{translate('MinimumAvailability')}
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title={translate('MinimumAvailability')}
body={<MovieMinimumAvailabilityPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.AVAILABILITY_SELECT}
name="minimumAvailability"
onChange={onInputChange}
{...minimumAvailability}
helpLink="https://wiki.servarr.com/radarr/faq#what-is-minimum-availability"
/>
</FormGroup>

View File

@@ -1,8 +1,12 @@
import PropTypes from 'prop-types';
import React from 'react';
import MovieMinimumAvailabilityPopoverContent from 'AddMovie/MovieMinimumAvailabilityPopoverContent';
import Icon from 'Components/Icon';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
import Popover from 'Components/Tooltip/Popover';
import { icons, tooltipPositions } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ImportMovieHeader.css';
@@ -46,7 +50,19 @@ function ImportMovieHeader(props) {
className={styles.minimumAvailability}
name="minimumAvailability"
>
{translate('MinAvailability')}
{translate('MinimumAvailability')}
<Popover
anchor={
<Icon
className={styles.detailsIcon}
name={icons.INFO}
/>
}
title={translate('MinimumAvailability')}
body={<MovieMinimumAvailabilityPopoverContent />}
position={tooltipPositions.LEFT}
/>
</VirtualTableHeaderCell>
<VirtualTableHeaderCell

View File

@@ -0,0 +1,27 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
import translate from 'Utilities/String/translate';
function MovieMinimumAvailabilityPopoverContent() {
return (
<DescriptionList>
<DescriptionListItem
title={translate('Announced')}
data={translate('AnnouncedMovieAvailabilityDescription')}
/>
<DescriptionListItem
title={translate('InCinemas')}
data={translate('InCinemasMovieAvailabilityDescription')}
/>
<DescriptionListItem
title={translate('Released')}
data={translate('ReleasedMovieAvailabilityDescription')}
/>
</DescriptionList>
);
}
export default MovieMinimumAvailabilityPopoverContent;

View File

@@ -31,7 +31,7 @@ import LogsTableConnector from 'System/Events/LogsTableConnector';
import Logs from 'System/Logs/Logs';
import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import UpdatesConnector from 'System/Updates/UpdatesConnector';
import Updates from 'System/Updates/Updates';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
import MissingConnector from 'Wanted/Missing/MissingConnector';
@@ -228,7 +228,7 @@ function AppRoutes(props) {
<Route
path="/system/updates"
component={UpdatesConnector}
component={Updates}
/>
<Route

View File

@@ -1,9 +1,10 @@
import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState';
import HistoryAppState from './HistoryAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
import MovieCollectionAppState from './MovieCollectionAppState';
import MovieCreditAppState from './MovieCreditAppState';
import MovieFilesAppState from './MovieFilesAppState';
import MoviesAppState, { MovieIndexAppState } from './MoviesAppState';
import ParseAppState from './ParseAppState';
@@ -64,6 +65,7 @@ interface AppState {
history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
movieCollections: MovieCollectionAppState;
movieCredits: MovieCreditAppState;
movieFiles: MovieFilesAppState;
movieIndex: MovieIndexAppState;
movies: MoviesAppState;

View File

@@ -0,0 +1,6 @@
import AppSectionState from 'App/State/AppSectionState';
import MovieCredit from 'typings/MovieCredit';
interface MovieCreditAppState extends AppSectionState<MovieCredit> {}
export default MovieCreditAppState;

View File

@@ -27,6 +27,7 @@ export interface MovieIndexAppState {
showTmdbRating: boolean;
showImdbRating: boolean;
showRottenTomatoesRating: boolean;
showTraktRating: boolean;
showTags: boolean;
showSearchAction: boolean;
};

View File

@@ -16,6 +16,9 @@ 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';
export interface DownloadClientAppState
@@ -29,6 +32,13 @@ export interface GeneralAppState
extends AppSectionItemState<General>,
AppSectionSaveState {}
export interface NamingAppState
extends AppSectionItemState<NamingConfig>,
AppSectionSaveState {}
export interface NamingExamplesAppState
extends AppSectionItemState<NamingExample> {}
export interface ImportListAppState
extends AppSectionState<ImportList>,
AppSectionDeleteState,
@@ -49,6 +59,12 @@ export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>,
AppSectionSchemaState<QualityProfile> {}
export interface ReleaseProfilesAppState
extends AppSectionState<ReleaseProfile>,
AppSectionSaveState {
pendingChanges: Partial<ReleaseProfile>;
}
export interface CustomFormatAppState
extends AppSectionState<CustomFormat>,
AppSectionDeleteState,
@@ -81,8 +97,11 @@ interface SettingsAppState {
indexerFlags: IndexerFlagSettingsAppState;
indexers: IndexerAppState;
languages: LanguageSettingsAppState;
naming: NamingAppState;
namingExamples: NamingExamplesAppState;
notifications: NotificationAppState;
qualityProfiles: QualityProfilesAppState;
releaseProfiles: ReleaseProfilesAppState;
ui: UiSettingsAppState;
}

View File

@@ -2,18 +2,21 @@ import DiskSpace from 'typings/DiskSpace';
import Health from 'typings/Health';
import SystemStatus from 'typings/SystemStatus';
import Task from 'typings/Task';
import Update from 'typings/Update';
import AppSectionState, { AppSectionItemState } from './AppSectionState';
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
export type HealthAppState = AppSectionState<Health>;
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type TaskAppState = AppSectionState<Task>;
export type UpdateAppState = AppSectionState<Update>;
interface SystemAppState {
diskSpace: DiskSpaceAppState;
health: HealthAppState;
status: SystemStatusAppState;
tasks: TaskAppState;
updates: UpdateAppState;
}
export default SystemAppState;

View File

@@ -56,7 +56,9 @@ function getValue(input, selectedFilterBuilderProp) {
}
if (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER) {
return parseInt(input);
const { numberFractionDigits = 0 } = selectedFilterBuilderProp;
return Number(input).toFixed(numberFractionDigits);
}
return input;

View File

@@ -1,69 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput';
const availabilityOptions = [
{
key: 'announced',
get value() {
return translate('Announced');
}
},
{
key: 'inCinemas',
get value() {
return translate('InCinemas');
}
},
{
key: 'released',
get value() {
return translate('Released');
}
}
];
function AvailabilitySelectInput(props) {
const values = [...availabilityOptions];
const {
includeNoChange,
includeMixed
} = props;
if (includeNoChange) {
values.unshift({
key: 'noChange',
value: translate('NoChange'),
isDisabled: true
});
}
if (includeMixed) {
values.unshift({
key: 'mixed',
value: '(Mixed)',
isDisabled: true
});
}
return (
<EnhancedSelectInput
{...props}
values={values}
/>
);
}
AvailabilitySelectInput.propTypes = {
includeNoChange: PropTypes.bool.isRequired,
includeMixed: PropTypes.bool.isRequired
};
AvailabilitySelectInput.defaultProps = {
includeNoChange: false,
includeMixed: false
};
export default AvailabilitySelectInput;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput';
interface AvailabilitySelectInputProps {
includeNoChange: boolean;
includeNoChangeDisabled?: boolean;
includeMixed?: boolean;
}
interface IMovieAvailabilityOption {
key: string;
value: string;
format?: string;
isDisabled?: boolean;
}
const movieAvailabilityOptions: IMovieAvailabilityOption[] = [
{
key: 'announced',
get value() {
return translate('Announced');
},
},
{
key: 'inCinemas',
get value() {
return translate('InCinemas');
},
},
{
key: 'released',
get value() {
return translate('Released');
},
},
];
function AvailabilitySelectInput(props: AvailabilitySelectInputProps) {
const values = [...movieAvailabilityOptions];
const {
includeNoChange = false,
includeNoChangeDisabled = true,
includeMixed = false,
} = props;
if (includeNoChange) {
values.unshift({
key: 'noChange',
value: translate('NoChange'),
isDisabled: includeNoChangeDisabled,
});
}
if (includeMixed) {
values.unshift({
key: 'mixed',
value: `(${translate('Mixed')})`,
isDisabled: true,
});
}
return <EnhancedSelectInput {...props} values={values} />;
}
export default AvailabilitySelectInput;

View File

@@ -1,54 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import { kinds } from 'Helpers/Props';
import styles from './FormInputButton.css';
function FormInputButton(props) {
const {
className,
canSpin,
isLastButton,
...otherProps
} = props;
if (canSpin) {
return (
<SpinnerButton
className={classNames(
className,
!isLastButton && styles.middleButton
)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
return (
<Button
className={classNames(
className,
!isLastButton && styles.middleButton
)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
FormInputButton.propTypes = {
className: PropTypes.string.isRequired,
isLastButton: PropTypes.bool.isRequired,
canSpin: PropTypes.bool.isRequired
};
FormInputButton.defaultProps = {
className: styles.button,
isLastButton: true,
canSpin: false
};
export default FormInputButton;

View File

@@ -0,0 +1,38 @@
import classNames from 'classnames';
import React from 'react';
import Button, { ButtonProps } from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import { kinds } from 'Helpers/Props';
import styles from './FormInputButton.css';
export interface FormInputButtonProps extends ButtonProps {
canSpin?: boolean;
isLastButton?: boolean;
}
function FormInputButton({
className = styles.button,
canSpin = false,
isLastButton = true,
...otherProps
}: FormInputButtonProps) {
if (canSpin) {
return (
<SpinnerButton
className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
return (
<Button
className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
export default FormInputButton;

View File

@@ -272,6 +272,8 @@ FormInputGroup.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.any,
values: PropTypes.arrayOf(PropTypes.any),
placeholder: PropTypes.string,
delimiters: PropTypes.arrayOf(PropTypes.string),
isDisabled: PropTypes.bool,
type: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all),
@@ -284,8 +286,10 @@ FormInputGroup.propTypes = {
helpTextWarning: PropTypes.string,
helpLink: PropTypes.string,
autoFocus: PropTypes.bool,
canEdit: PropTypes.bool,
includeNoChange: PropTypes.bool,
includeNoChangeDisabled: PropTypes.bool,
includeAny: PropTypes.bool,
selectedValueOptions: PropTypes.object,
indexerFlags: PropTypes.number,
pending: PropTypes.bool,

View File

@@ -5,17 +5,20 @@ import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput';
function MovieMonitoredSelectInput(props) {
const values = [...monitorOptions];
const {
includeNoChange,
includeMixed
includeMixed,
...otherProps
} = props;
const values = [...monitorOptions];
if (includeNoChange) {
values.unshift({
key: 'noChange',
value: translate('NoChange'),
get value() {
return translate('NoChange');
},
isDisabled: true
});
}
@@ -23,14 +26,16 @@ function MovieMonitoredSelectInput(props) {
if (includeMixed) {
values.unshift({
key: 'mixed',
value: '(Mixed)',
get value() {
return `(${translate('Mixed')})`;
},
isDisabled: true
});
}
return (
<EnhancedSelectInput
{...props}
{...otherProps}
values={values}
/>
);

View File

@@ -1,3 +1,7 @@
.wrapper {
display: inline-block;
}
.image {
align-content: center;
margin-right: 5px;

View File

@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'image': string;
'wrapper': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -23,7 +23,7 @@ function ImdbRating(props: ImdbRatingProps) {
return (
<Tooltip
anchor={
<span>
<span className={styles.wrapper}>
{!hideIcon && (
<img
className={styles.image}

View File

@@ -1,139 +0,0 @@
import Clipboard from 'clipboard';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormInputButton from 'Components/Form/FormInputButton';
import Icon from 'Components/Icon';
import { icons, kinds } from 'Helpers/Props';
import getUniqueElememtId from 'Utilities/getUniqueElementId';
import styles from './ClipboardButton.css';
class ClipboardButton extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._id = getUniqueElememtId();
this._successTimeout = null;
this._testResultTimeout = null;
this.state = {
showSuccess: false,
showError: false
};
}
componentDidMount() {
this._clipboard = new Clipboard(`#${this._id}`, {
text: () => this.props.value,
container: document.getElementById(this._id)
});
this._clipboard.on('success', this.onSuccess);
}
componentDidUpdate() {
const {
showSuccess,
showError
} = this.state;
if (showSuccess || showError) {
this._testResultTimeout = setTimeout(this.resetState, 3000);
}
}
componentWillUnmount() {
if (this._clipboard) {
this._clipboard.destroy();
}
if (this._testResultTimeout) {
clearTimeout(this._testResultTimeout);
}
}
//
// Control
resetState = () => {
this.setState({
showSuccess: false,
showError: false
});
};
//
// Listeners
onSuccess = () => {
this.setState({
showSuccess: true
});
};
onError = () => {
this.setState({
showError: true
});
};
//
// Render
render() {
const {
value,
className,
...otherProps
} = this.props;
const {
showSuccess,
showError
} = this.state;
const showStateIcon = showSuccess || showError;
const iconName = showError ? icons.DANGER : icons.CHECK;
const iconKind = showError ? kinds.DANGER : kinds.SUCCESS;
return (
<FormInputButton
id={this._id}
className={className}
{...otherProps}
>
<span className={showStateIcon ? styles.showStateIcon : undefined}>
{
showSuccess &&
<span className={styles.stateIconContainer}>
<Icon
name={iconName}
kind={iconKind}
/>
</span>
}
{
<span className={styles.clipboardIconContainer}>
<Icon name={icons.CLIPBOARD} />
</span>
}
</span>
</FormInputButton>
);
}
}
ClipboardButton.propTypes = {
className: PropTypes.string.isRequired,
value: PropTypes.string.isRequired
};
ClipboardButton.defaultProps = {
className: styles.button
};
export default ClipboardButton;

View File

@@ -0,0 +1,76 @@
import copy from 'copy-to-clipboard';
import React, { useCallback, useEffect, useState } from 'react';
import FormInputButton from 'Components/Form/FormInputButton';
import Icon from 'Components/Icon';
import { icons, kinds } from 'Helpers/Props';
import { ButtonProps } from './Button';
import styles from './ClipboardButton.css';
export interface ClipboardButtonProps extends Omit<ButtonProps, 'children'> {
value: string;
}
export type ClipboardState = 'success' | 'error' | null;
export default function ClipboardButton({
id,
value,
className = styles.button,
...otherProps
}: ClipboardButtonProps) {
const [state, setState] = useState<ClipboardState>(null);
useEffect(() => {
if (!state) {
return;
}
const timeoutId = setTimeout(() => {
setState(null);
}, 3000);
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [state]);
const handleClick = useCallback(async () => {
try {
if ('clipboard' in navigator) {
await navigator.clipboard.writeText(value);
} else {
copy(value);
}
setState('success');
} catch (e) {
setState('error');
console.error(`Failed to copy to clipboard`, e);
}
}, [value]);
return (
<FormInputButton
className={className}
onClick={handleClick}
{...otherProps}
>
<span className={state ? styles.showStateIcon : undefined}>
{state ? (
<span className={styles.stateIconContainer}>
<Icon
name={state === 'error' ? icons.DANGER : icons.CHECK}
kind={state === 'error' ? kinds.DANGER : kinds.SUCCESS}
/>
</span>
) : null}
<span className={styles.clipboardIconContainer}>
<Icon name={icons.CLIPBOARD} />
</span>
</span>
</FormInputButton>
);
}

View File

@@ -34,7 +34,7 @@ function getSuggestions(movies, value) {
key: 'title'
}
],
arrayIndex: 0
refIndex: 0
});
if (suggestions.length > limit) {
break;

View File

@@ -1,3 +1,7 @@
.wrapper {
display: inline-block;
}
.image {
align-content: center;
margin-right: 5px;

View File

@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'image': string;
'wrapper': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -24,7 +24,7 @@ function RottenTomatoRating(props: RottenTomatoRatingProps) {
const ratingImage = value > 50 ? rtFresh : rtRotten;
return (
<span>
<span className={styles.wrapper}>
{!hideIcon && (
<img
className={styles.image}

View File

@@ -168,7 +168,7 @@ class SignalRConnector extends Component {
const status = resource.status;
// Both successful and failed commands need to be
// completed, otherwise they spin until they timeout.
// completed, otherwise they spin until they time out.
if (status === 'completed' || status === 'failed') {
this.props.dispatchFinishCommand(resource);
@@ -192,10 +192,50 @@ class SignalRConnector extends Component {
}
};
handleDownloadclient = ({ action, resource }) => {
const section = 'settings.downloadClients';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleHealth = () => {
this.props.dispatchFetchHealth();
};
handleImportlist = ({ action, resource }) => {
const section = 'settings.importLists';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleIndexer = ({ action, resource }) => {
const section = 'settings.indexers';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleNotification = ({ action, resource }) => {
const section = 'settings.notifications';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleMovie = (body) => {
const action = body.action;
const section = 'movies';

View File

@@ -1,3 +1,7 @@
.wrapper {
display: inline-block;
}
.image {
align-content: center;
margin-right: 5px;

View File

@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'image': string;
'wrapper': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -22,7 +22,7 @@ function TmdbRating(props: TmdbRatingProps) {
return (
<Tooltip
anchor={
<span>
<span className={styles.wrapper}>
{!hideIcon && (
<img
className={styles.image}

View File

@@ -0,0 +1,9 @@
.wrapper {
display: inline-block;
}
.image {
align-content: center;
margin-right: 5px;
vertical-align: -0.125em;
}

View File

@@ -1,7 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'blankpad': string;
'image': string;
'wrapper': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,46 @@
import React from 'react';
import Tooltip from 'Components/Tooltip/Tooltip';
import { kinds, tooltipPositions } from 'Helpers/Props';
import { Ratings } from 'Movie/Movie';
import translate from 'Utilities/String/translate';
import styles from './TraktRating.css';
interface TraktRatingProps {
ratings: Ratings;
iconSize?: number;
hideIcon?: boolean;
}
function TraktRating(props: TraktRatingProps) {
const { ratings, iconSize = 14, hideIcon = false } = props;
const traktImage =
'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IS0tIEdlbmVyYXRvcjogQWRvYmUgSWxsdXN0cmF0b3IgMTguMC4wLCBTVkcgRXhwb3J0IFBsdWctSW4gLiBTVkcgVmVyc2lvbjogNi4wMCBCdWlsZCAwKSAgLS0+PCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj48c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IiAgICAgdmlld0JveD0iMCAwIDE0NC44IDE0NC44IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAxNDQuOCAxNDQuOCIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+PGc+ICAgIDxwYXRoIGZpbGw9IiNFRDIyMjQiIGQ9Ik0yOS41LDExMS44YzEwLjYsMTEuNiwyNS45LDE4LjgsNDIuOSwxOC44YzguNywwLDE2LjktMS45LDI0LjMtNS4zTDU2LjMsODVMMjkuNSwxMTEuOHoiLz4gICAgPHBhdGggZmlsbD0iI0VEMjIyNCIgZD0iTTU2LjEsNjAuNkwyNS41LDkxLjFMMjEuNCw4N2wzMi4yLTMyLjJoMGwzNy42LTM3LjZjLTUuOS0yLTEyLjItMy4xLTE4LjgtMy4xYy0zMi4yLDAtNTguMywyNi4xLTU4LjMsNTguMyAgICAgICBjMCwxMy4xLDQuMywyNS4yLDExLjcsMzVsMzAuNS0zMC41bDIuMSwybDQzLjcsNDMuN2MwLjktMC41LDEuNy0xLDIuNS0xLjZMNTYuMyw3Mi43TDI3LDEwMmwtNC4xLTQuMWwzMy40LTMzLjRsMi4xLDJsNTEsNTAuOSAgICAgICBjMC44LTAuNiwxLjUtMS4zLDIuMi0xLjlsLTU1LTU1TDU2LjEsNjAuNnoiLz4gICAgPHBhdGggZmlsbD0iI0VEMUMyNCIgZD0iTTExNS43LDExMS40YzkuMy0xMC4zLDE1LTI0LDE1LTM5YzAtMjMuNC0xMy44LTQzLjUtMzMuNi01Mi44TDYwLjQsNTYuMkwxMTUuNywxMTEuNHogTTc0LjUsNjYuOGwtNC4xLTQuMSAgICAgICBsMjguOS0yOC45bDQuMSw0LjFMNzQuNSw2Ni44eiBNMTAxLjksMjcuMUw2OC42LDYwLjRsLTQuMS00LjFMOTcuOCwyM0wxMDEuOSwyNy4xeiIvPiAgICA8Zz4gICAgICAgPGc+ICAgICAgICAgIDxwYXRoIGZpbGw9IiNFRDIyMjQiIGQ9Ik03Mi40LDE0NC44QzMyLjUsMTQ0LjgsMCwxMTIuMywwLDcyLjRDMCwzMi41LDMyLjUsMCw3Mi40LDBzNzIuNCwzMi41LDcyLjQsNzIuNCAgICAgICAgICAgICBDMTQ0LjgsMTEyLjMsMTEyLjMsMTQ0LjgsNzIuNCwxNDQuOHogTTcyLjQsNy4zQzM2LjUsNy4zLDcuMywzNi41LDcuMyw3Mi40czI5LjIsNjUuMSw2NS4xLDY1LjFzNjUuMS0yOS4yLDY1LjEtNjUuMSAgICAgICAgICAgICBTMTA4LjMsNy4zLDcyLjQsNy4zeiIvPiAgICAgICA8L2c+ICAgIDwvZz48L2c+PC9zdmc+';
const { value = 0, votes = 0 } = ratings.trakt;
return (
<Tooltip
anchor={
<span className={styles.wrapper}>
{!hideIcon && (
<img
className={styles.image}
alt={translate('TraktRating')}
src={traktImage}
style={{
height: `${iconSize}px`,
}}
/>
)}
{(value * 10).toFixed()}%
</span>
}
tooltip={translate('CountVotes', { votes })}
kind={kinds.INVERSE}
position={tooltipPositions.TOP}
/>
);
}
export default TraktRating;

View File

@@ -110,6 +110,15 @@ function DiscoverMovieSortMenu(props) {
{translate('RottenTomatoesRating')}
</SortMenuItem>
<SortMenuItem
name="traktRating"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('TraktRating')}
</SortMenuItem>
<SortMenuItem
name="certification"
sortKey={sortKey}

View File

@@ -9,6 +9,7 @@ import Link from 'Components/Link/Link';
import RottenTomatoRating from 'Components/RottenTomatoRating';
import TmdbRating from 'Components/TmdbRating';
import Popover from 'Components/Tooltip/Popover';
import TraktRating from 'Components/TraktRating';
import AddNewDiscoverMovieModal from 'DiscoverMovie/AddNewDiscoverMovieModal';
import ExcludeMovieModal from 'DiscoverMovie/Exclusion/ExcludeMovieModal';
import { icons } from 'Helpers/Props';
@@ -92,6 +93,7 @@ class DiscoverMoviePoster extends Component {
showTmdbRating,
showImdbRating,
showRottenTomatoesRating,
showTraktRating,
ratings,
isExisting,
isExcluded,
@@ -223,6 +225,12 @@ class DiscoverMoviePoster extends Component {
</div>
) : null}
{showTraktRating && !!ratings.trakt ? (
<div className={styles.title}>
<TraktRating ratings={ratings} iconSize={12} />
</div>
) : null}
<DiscoverMoviePosterInfo
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
@@ -232,6 +240,7 @@ class DiscoverMoviePoster extends Component {
showTmdbRating={showTmdbRating}
showImdbRating={showImdbRating}
showRottenTomatoesRating={showRottenTomatoesRating}
showTraktRating={showTraktRating}
{...otherProps}
/>
@@ -274,6 +283,7 @@ DiscoverMoviePoster.propTypes = {
showTmdbRating: PropTypes.bool.isRequired,
showImdbRating: PropTypes.bool.isRequired,
showRottenTomatoesRating: PropTypes.bool.isRequired,
showTraktRating: PropTypes.bool.isRequired,
ratings: PropTypes.object.isRequired,
showRelativeDates: PropTypes.bool.isRequired,
shortDateFormat: PropTypes.string.isRequired,

View File

@@ -4,6 +4,7 @@ import Icon from 'Components/Icon';
import ImdbRating from 'Components/ImdbRating';
import RottenTomatoRating from 'Components/RottenTomatoRating';
import TmdbRating from 'Components/TmdbRating';
import TraktRating from 'Components/TraktRating';
import { icons } from 'Helpers/Props';
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
import formatRuntime from 'Utilities/Date/formatRuntime';
@@ -28,7 +29,8 @@ function DiscoverMoviePosterInfo(props) {
movieRuntimeFormat,
showTmdbRating,
showImdbRating,
showRottenTomatoesRating
showRottenTomatoesRating,
showTraktRating
} = props;
if (sortKey === 'status' && status) {
@@ -141,6 +143,14 @@ function DiscoverMoviePosterInfo(props) {
);
}
if (!showTraktRating && sortKey === 'traktRating' && !!ratings.trakt) {
return (
<div className={styles.info}>
<TraktRating ratings={ratings} iconSize={12} />
</div>
);
}
return null;
}
@@ -160,7 +170,8 @@ DiscoverMoviePosterInfo.propTypes = {
movieRuntimeFormat: PropTypes.string.isRequired,
showTmdbRating: PropTypes.bool.isRequired,
showImdbRating: PropTypes.bool.isRequired,
showRottenTomatoesRating: PropTypes.bool.isRequired
showRottenTomatoesRating: PropTypes.bool.isRequired,
showTraktRating: PropTypes.bool.isRequired
};
export default DiscoverMoviePosterInfo;

View File

@@ -39,7 +39,8 @@ function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions)
showTitle,
showTmdbRating,
showImdbRating,
showRottenTomatoesRating
showRottenTomatoesRating,
showTraktRating
} = posterOptions;
const heights = [
@@ -64,6 +65,10 @@ function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions)
heights.push(19);
}
if (showTraktRating) {
heights.push(19);
}
switch (sortKey) {
case 'studio':
case 'inCinemas':
@@ -88,6 +93,11 @@ function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions)
heights.push(19);
}
break;
case 'traktRating':
if (!showTraktRating) {
heights.push(19);
}
break;
default:
// No need to add a height of 0
}
@@ -219,7 +229,8 @@ class DiscoverMoviePosters extends Component {
showTitle,
showTmdbRating,
showImdbRating,
showRottenTomatoesRating
showRottenTomatoesRating,
showTraktRating
} = posterOptions;
const movieIdx = rowIndex * columnCount + columnIndex;
@@ -248,6 +259,7 @@ class DiscoverMoviePosters extends Component {
showTmdbRating={showTmdbRating}
showImdbRating={showImdbRating}
showRottenTomatoesRating={showRottenTomatoesRating}
showTraktRating={showTraktRating}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}

View File

@@ -48,6 +48,7 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
showTmdbRating: props.showTmdbRating,
showImdbRating: props.showImdbRating,
showRottenTomatoesRating: props.showRottenTomatoesRating,
showTraktRating: props.showTraktRating,
includeRecommendations: props.includeRecommendations,
includeTrending: props.includeTrending,
includePopular: props.includePopular
@@ -61,6 +62,7 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
showTmdbRating,
showImdbRating,
showRottenTomatoesRating,
showTraktRating,
includeRecommendations,
includeTrending,
includePopular
@@ -88,6 +90,10 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
state.showRottenTomatoesRating = showRottenTomatoesRating;
}
if (showTraktRating !== prevProps.showTraktRating) {
state.showTraktRating = showTraktRating;
}
if (includeRecommendations !== prevProps.includeRecommendations) {
state.includeRecommendations = includeRecommendations;
}
@@ -140,6 +146,7 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
showTmdbRating,
showImdbRating,
showRottenTomatoesRating,
showTraktRating,
includeRecommendations,
includeTrending,
includePopular
@@ -248,6 +255,18 @@ class DiscoverMoviePosterOptionsModalContent extends Component {
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowTraktRating')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showTraktRating"
value={showTraktRating}
helpText={translate('ShowTraktRatingPosterHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
</Form>
</ModalBody>
@@ -269,6 +288,7 @@ DiscoverMoviePosterOptionsModalContent.propTypes = {
showTmdbRating: PropTypes.bool.isRequired,
showImdbRating: PropTypes.bool.isRequired,
showRottenTomatoesRating: PropTypes.bool.isRequired,
showTraktRating: PropTypes.bool.isRequired,
includeRecommendations: PropTypes.bool.isRequired,
includeTrending: PropTypes.bool.isRequired,
includePopular: PropTypes.bool.isRequired,

View File

@@ -35,6 +35,7 @@
.tmdbRating,
.imdbRating,
.rottenTomatoesRating,
.traktRating,
.runtime {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';

View File

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

View File

@@ -60,6 +60,7 @@
.tmdbRating,
.imdbRating,
.rottenTomatoesRating,
.traktRating,
.runtime {
composes: cell;

View File

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

View File

@@ -11,6 +11,7 @@ import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import TmdbRating from 'Components/TmdbRating';
import Popover from 'Components/Tooltip/Popover';
import TraktRating from 'Components/TraktRating';
import AddNewDiscoverMovieModal from 'DiscoverMovie/AddNewDiscoverMovieModal';
import ExcludeMovieModal from 'DiscoverMovie/Exclusion/ExcludeMovieModal';
import { icons } from 'Helpers/Props';
@@ -291,6 +292,17 @@ class DiscoverMovieRow extends Component {
);
}
if (name === 'traktRating') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{ratings.trakt ? <TraktRating ratings={ratings} /> : null}
</VirtualTableRowCell>
);
}
if (name === 'popularity') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>

View File

@@ -1,175 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds, sizes } from 'Helpers/Props';
import MovieHeadshot from 'Movie/MovieHeadshot';
import EditImportListModalConnector from 'Settings/ImportLists/ImportLists/EditImportListModalConnector';
import translate from 'Utilities/String/translate';
import styles from '../MovieCreditPoster.css';
class MovieCastPoster extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
hasPosterError: false,
isEditImportListModalOpen: false
};
}
//
// Listeners
onEditImportListPress = () => {
this.setState({ isEditImportListModalOpen: true });
};
onAddImportListPress = () => {
this.props.onImportListSelect();
this.setState({ isEditImportListModalOpen: true });
};
onEditImportListModalClose = () => {
this.setState({ isEditImportListModalOpen: false });
};
onPosterLoad = () => {
if (this.state.hasPosterError) {
this.setState({ hasPosterError: false });
}
};
onPosterLoadError = () => {
if (!this.state.hasPosterError) {
this.setState({ hasPosterError: true });
}
};
//
// Render
render() {
const {
tmdbId,
personName,
character,
images,
posterWidth,
posterHeight,
importList
} = this.props;
const {
hasPosterError
} = this.state;
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`,
borderRadius: '5px'
};
const contentStyle = {
width: `${posterWidth}px`
};
const monitored = importList !== undefined && importList.enabled && importList.enableAuto;
const importListId = importList ? importList.id : 0;
return (
<div
className={styles.content}
style={contentStyle}
>
<div className={styles.posterContainer}>
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
size={20}
onPress={importListId > 0 ? this.onEditImportListPress : this.onAddImportListPress}
/>
</div>
<Label className={styles.controls}>
<span className={styles.externalLinks}>
<Popover
anchor={<Icon name={icons.EXTERNAL_LINK} size={12} />}
title={translate('Links')}
body={
<Link to={`https://www.themoviedb.org/person/${tmdbId}`}>
<Label
className={styles.externalLinkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
{translate('TMDb')}
</Label>
</Link>
}
/>
</span>
</Label>
<div
style={elementStyle}
>
<MovieHeadshot
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
onError={this.onPosterLoadError}
onLoad={this.onPosterLoad}
/>
{
hasPosterError &&
<div className={styles.overlayTitle}>
{personName}
</div>
}
</div>
</div>
<div className={classNames(styles.title, 'swiper-no-swiping')}>
{personName}
</div>
<div className={classNames(styles.title, 'swiper-no-swiping')}>
{character}
</div>
<EditImportListModalConnector
id={importListId}
isOpen={this.state.isEditImportListModalOpen}
onModalClose={this.onEditImportListModalClose}
onDeleteImportListPress={this.onDeleteImportListPress}
/>
</div>
);
}
}
MovieCastPoster.propTypes = {
tmdbId: PropTypes.number.isRequired,
personName: PropTypes.string.isRequired,
character: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
posterWidth: PropTypes.number.isRequired,
posterHeight: PropTypes.number.isRequired,
importList: PropTypes.object,
onImportListSelect: PropTypes.func.isRequired
};
export default MovieCastPoster;

View File

@@ -0,0 +1,179 @@
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import Popover from 'Components/Tooltip/Popover';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons, kinds, sizes } from 'Helpers/Props';
import MovieHeadshot from 'Movie/MovieHeadshot';
import EditImportListModalConnector from 'Settings/ImportLists/ImportLists/EditImportListModalConnector';
import { deleteImportList } from 'Store/Actions/Settings/importLists';
import ImportList from 'typings/ImportList';
import MovieCredit from 'typings/MovieCredit';
import translate from 'Utilities/String/translate';
import styles from '../MovieCreditPoster.css';
export interface MovieCastPosterProps
extends Pick<MovieCredit, 'personName' | 'images' | 'character'> {
tmdbId: number;
posterWidth: number;
posterHeight: number;
importList?: ImportList;
onImportListSelect(): void;
}
function MovieCastPoster(props: MovieCastPosterProps) {
const {
tmdbId,
personName,
character,
images = [],
posterWidth,
posterHeight,
importList,
onImportListSelect,
} = props;
const importListId = importList?.id ?? 0;
const dispatch = useDispatch();
const [hasPosterError, setHasPosterError] = useState(false);
const [
isEditImportListModalOpen,
setEditImportListModalOpen,
setEditImportListModalClosed,
] = useModalOpenState(false);
const [
isDeleteImportListModalOpen,
setDeleteImportListModalOpen,
setDeleteImportListModalClosed,
] = useModalOpenState(false);
const handlePosterLoadError = useCallback(() => {
setHasPosterError(true);
}, [setHasPosterError]);
const handlePosterLoad = useCallback(() => {
setHasPosterError(false);
}, [setHasPosterError]);
const handleManageImportListPress = useCallback(() => {
if (importListId === 0) {
onImportListSelect();
}
setEditImportListModalOpen();
}, [importListId, onImportListSelect, setEditImportListModalOpen]);
const handleDeleteImportListConfirmed = useCallback(() => {
dispatch(deleteImportList({ id: importListId }));
setEditImportListModalClosed();
setDeleteImportListModalClosed();
}, [
importListId,
setEditImportListModalClosed,
setDeleteImportListModalClosed,
dispatch,
]);
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`,
borderRadius: '5px',
};
const contentStyle = {
width: `${posterWidth}px`,
};
const monitored =
importList?.enabled === true && importList?.enableAuto === true;
return (
<div className={styles.content} style={contentStyle}>
<div className={styles.posterContainer}>
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
size={20}
onPress={handleManageImportListPress}
/>
</div>
<Label className={styles.controls}>
<span className={styles.externalLinks}>
<Popover
anchor={<Icon name={icons.EXTERNAL_LINK} size={12} />}
title={translate('Links')}
body={
<Link to={`https://www.themoviedb.org/person/${tmdbId}`}>
<Label
className={styles.externalLinkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
{translate('TMDb')}
</Label>
</Link>
}
/>
</span>
</Label>
<div style={elementStyle}>
<MovieHeadshot
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
onError={handlePosterLoadError}
onLoad={handlePosterLoad}
/>
{hasPosterError && (
<div className={styles.overlayTitle}>{personName}</div>
)}
</div>
</div>
<div className={classNames(styles.title, 'swiper-no-swiping')}>
{personName}
</div>
<div className={classNames(styles.title, 'swiper-no-swiping')}>
{character}
</div>
<EditImportListModalConnector
id={importListId}
isOpen={isEditImportListModalOpen}
onModalClose={setEditImportListModalClosed}
onDeleteImportListPress={setDeleteImportListModalOpen}
/>
<ConfirmModal
isOpen={isDeleteImportListModalOpen}
kind={kinds.DANGER}
title={translate('DeleteImportList')}
message={translate('DeleteImportListMessageText', {
name: importList?.name ?? personName,
})}
confirmLabel={translate('Delete')}
onConfirm={handleDeleteImportListConfirmed}
onCancel={setDeleteImportListModalClosed}
/>
</div>
);
}
export default MovieCastPoster;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { useSelector } from 'react-redux';
import createMovieCreditsSelector from 'Store/Selectors/createMovieCreditsSelector';
import MovieCreditPosters from '../MovieCreditPosters';
import MovieCastPoster from './MovieCastPoster';
interface MovieCastPostersProps {
isSmallScreen: boolean;
}
function MovieCastPosters({ isSmallScreen }: MovieCastPostersProps) {
const { items: castCredits } = useSelector(
createMovieCreditsSelector('cast')
);
return (
<MovieCreditPosters
items={castCredits}
itemComponent={MovieCastPoster}
isSmallScreen={isSmallScreen}
/>
);
}
export default MovieCastPosters;

View File

@@ -1,43 +0,0 @@
import _ from 'lodash';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import MovieCreditPosters from '../MovieCreditPosters';
import MovieCastPoster from './MovieCastPoster';
function createMapStateToProps() {
return createSelector(
(state) => state.movieCredits.items,
(credits) => {
const cast = _.reduce(credits, (acc, credit) => {
if (credit.type === 'cast') {
acc.push(credit);
}
return acc;
}, []);
return {
items: cast
};
}
);
}
class MovieCastPostersConnector extends Component {
//
// Render
render() {
return (
<MovieCreditPosters
{...this.props}
itemComponent={MovieCastPoster}
/>
);
}
}
export default connect(createMapStateToProps)(MovieCastPostersConnector);

View File

@@ -1,175 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds, sizes } from 'Helpers/Props';
import MovieHeadshot from 'Movie/MovieHeadshot';
import EditImportListModalConnector from 'Settings/ImportLists/ImportLists/EditImportListModalConnector';
import translate from 'Utilities/String/translate';
import styles from '../MovieCreditPoster.css';
class MovieCrewPoster extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
hasPosterError: false,
isEditImportListModalOpen: false
};
}
//
// Listeners
onEditImportListPress = () => {
this.setState({ isEditImportListModalOpen: true });
};
onAddImportListPress = () => {
this.props.onImportListSelect();
this.setState({ isEditImportListModalOpen: true });
};
onEditImportListModalClose = () => {
this.setState({ isEditImportListModalOpen: false });
};
onPosterLoad = () => {
if (this.state.hasPosterError) {
this.setState({ hasPosterError: false });
}
};
onPosterLoadError = () => {
if (!this.state.hasPosterError) {
this.setState({ hasPosterError: true });
}
};
//
// Render
render() {
const {
tmdbId,
personName,
job,
images,
posterWidth,
posterHeight,
importList
} = this.props;
const {
hasPosterError
} = this.state;
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`,
borderRadius: '5px'
};
const contentStyle = {
width: `${posterWidth}px`
};
const monitored = importList !== undefined && importList.enabled && importList.enableAuto;
const importListId = importList ? importList.id : 0;
return (
<div
className={styles.content}
style={contentStyle}
>
<div className={styles.posterContainer}>
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
size={20}
onPress={importListId > 0 ? this.onEditImportListPress : this.onAddImportListPress}
/>
</div>
<Label className={styles.controls}>
<span className={styles.externalLinks}>
<Popover
anchor={<Icon name={icons.EXTERNAL_LINK} size={12} />}
title={translate('Links')}
body={
<Link to={`https://www.themoviedb.org/person/${tmdbId}`}>
<Label
className={styles.externalLinkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
{translate('TMDb')}
</Label>
</Link>
}
/>
</span>
</Label>
<div
style={elementStyle}
>
<MovieHeadshot
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
onError={this.onPosterLoadError}
onLoad={this.onPosterLoad}
/>
{
hasPosterError &&
<div className={styles.overlayTitle}>
{personName}
</div>
}
</div>
</div>
<div className={classNames(styles.title, 'swiper-no-swiping')}>
{personName}
</div>
<div className={classNames(styles.title, 'swiper-no-swiping')}>
{job}
</div>
<EditImportListModalConnector
id={importListId}
isOpen={this.state.isEditImportListModalOpen}
onModalClose={this.onEditImportListModalClose}
onDeleteImportListPress={this.onDeleteImportListPress}
/>
</div>
);
}
}
MovieCrewPoster.propTypes = {
tmdbId: PropTypes.number.isRequired,
personName: PropTypes.string.isRequired,
job: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
posterWidth: PropTypes.number.isRequired,
posterHeight: PropTypes.number.isRequired,
importList: PropTypes.object,
onImportListSelect: PropTypes.func.isRequired
};
export default MovieCrewPoster;

View File

@@ -0,0 +1,177 @@
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import Popover from 'Components/Tooltip/Popover';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons, kinds, sizes } from 'Helpers/Props';
import MovieHeadshot from 'Movie/MovieHeadshot';
import EditImportListModalConnector from 'Settings/ImportLists/ImportLists/EditImportListModalConnector';
import { deleteImportList } from 'Store/Actions/Settings/importLists';
import ImportList from 'typings/ImportList';
import MovieCredit from 'typings/MovieCredit';
import translate from 'Utilities/String/translate';
import styles from '../MovieCreditPoster.css';
export interface MovieCrewPosterProps
extends Pick<MovieCredit, 'personName' | 'images' | 'job'> {
tmdbId: number;
posterWidth: number;
posterHeight: number;
importList?: ImportList;
onImportListSelect(): void;
}
function MovieCrewPoster(props: MovieCrewPosterProps) {
const {
tmdbId,
personName,
job,
images = [],
posterWidth,
posterHeight,
importList,
onImportListSelect,
} = props;
const importListId = importList?.id ?? 0;
const dispatch = useDispatch();
const [hasPosterError, setHasPosterError] = useState(false);
const [
isEditImportListModalOpen,
setEditImportListModalOpen,
setEditImportListModalClosed,
] = useModalOpenState(false);
const [
isDeleteImportListModalOpen,
setDeleteImportListModalOpen,
setDeleteImportListModalClosed,
] = useModalOpenState(false);
const handlePosterLoadError = useCallback(() => {
setHasPosterError(true);
}, [setHasPosterError]);
const handlePosterLoad = useCallback(() => {
setHasPosterError(false);
}, [setHasPosterError]);
const handleManageImportListPress = useCallback(() => {
if (importListId === 0) {
onImportListSelect();
}
setEditImportListModalOpen();
}, [importListId, onImportListSelect, setEditImportListModalOpen]);
const handleDeleteImportListConfirmed = useCallback(() => {
dispatch(deleteImportList({ id: importListId }));
setEditImportListModalClosed();
setDeleteImportListModalClosed();
}, [
importListId,
setEditImportListModalClosed,
setDeleteImportListModalClosed,
dispatch,
]);
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`,
borderRadius: '5px',
};
const contentStyle = {
width: `${posterWidth}px`,
};
const monitored =
importList?.enabled === true && importList?.enableAuto === true;
return (
<div className={styles.content} style={contentStyle}>
<div className={styles.posterContainer}>
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
size={20}
onPress={handleManageImportListPress}
/>
</div>
<Label className={styles.controls}>
<span className={styles.externalLinks}>
<Popover
anchor={<Icon name={icons.EXTERNAL_LINK} size={12} />}
title={translate('Links')}
body={
<Link to={`https://www.themoviedb.org/person/${tmdbId}`}>
<Label
className={styles.externalLinkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
{translate('TMDb')}
</Label>
</Link>
}
/>
</span>
</Label>
<div style={elementStyle}>
<MovieHeadshot
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
onError={handlePosterLoadError}
onLoad={handlePosterLoad}
/>
{hasPosterError && (
<div className={styles.overlayTitle}>{personName}</div>
)}
</div>
</div>
<div className={classNames(styles.title, 'swiper-no-swiping')}>
{personName}
</div>
<div className={classNames(styles.title, 'swiper-no-swiping')}>{job}</div>
<EditImportListModalConnector
id={importListId}
isOpen={isEditImportListModalOpen}
onModalClose={setEditImportListModalClosed}
onDeleteImportListPress={setDeleteImportListModalOpen}
/>
<ConfirmModal
isOpen={isDeleteImportListModalOpen}
kind={kinds.DANGER}
title={translate('DeleteImportList')}
message={translate('DeleteImportListMessageText', {
name: importList?.name ?? personName,
})}
confirmLabel={translate('Delete')}
onConfirm={handleDeleteImportListConfirmed}
onCancel={setDeleteImportListModalClosed}
/>
</div>
);
}
export default MovieCrewPoster;

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { useSelector } from 'react-redux';
import createMovieCreditsSelector from 'Store/Selectors/createMovieCreditsSelector';
import MovieCreditPosters from '../MovieCreditPosters';
import MovieCrewPoster from './MovieCrewPoster';
interface MovieCrewPostersProps {
isSmallScreen: boolean;
}
function MovieCrewPosters({ isSmallScreen }: MovieCrewPostersProps) {
const { items: crewCredits } = useSelector(
createMovieCreditsSelector('crew')
);
return (
<MovieCreditPosters
items={crewCredits}
itemComponent={MovieCrewPoster}
isSmallScreen={isSmallScreen}
/>
);
}
export default MovieCrewPosters;

View File

@@ -1,68 +0,0 @@
import _ from 'lodash';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import MovieCreditPosters from '../MovieCreditPosters';
import MovieCrewPoster from './MovieCrewPoster';
function crewSort(a, b) {
const jobOrder = ['Director', 'Writer', 'Producer', 'Executive Producer', 'Director of Photography'];
const indexA = jobOrder.indexOf(a.job);
const indexB = jobOrder.indexOf(b.job);
if (indexA === -1 && indexB === -1) {
return 0;
} else if (indexA === -1) {
return 1;
} else if (indexB === -1) {
return -1;
}
if (indexA < indexB) {
return -1;
} else if (indexA > indexB) {
return 1;
}
return 0;
}
function createMapStateToProps() {
return createSelector(
(state) => state.movieCredits.items,
(credits) => {
const crew = _.reduce(credits, (acc, credit) => {
if (credit.type === 'crew') {
acc.push(credit);
}
return acc;
}, []);
const sortedCrew = crew.sort(crewSort);
return {
items: _.uniqBy(sortedCrew, 'personName')
};
}
);
}
class MovieCrewPostersConnector extends Component {
//
// Render
render() {
return (
<MovieCreditPosters
{...this.props}
itemComponent={MovieCrewPoster}
/>
);
}
}
export default connect(createMapStateToProps)(MovieCrewPostersConnector);

View File

@@ -0,0 +1,60 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
selectImportListSchema,
setImportListFieldValue,
setImportListValue,
} from 'Store/Actions/settingsActions';
import createMovieCreditImportListSelector from 'Store/Selectors/createMovieCreditImportListSelector';
import { MovieCastPosterProps } from './Cast/MovieCastPoster';
import { MovieCrewPosterProps } from './Crew/MovieCrewPoster';
type MovieCreditPosterProps = {
component: React.ElementType;
} & (
| Omit<MovieCrewPosterProps, 'onImportListSelect'>
| Omit<MovieCastPosterProps, 'onImportListSelect'>
);
function MovieCreditPoster({
component: ItemComponent,
tmdbId,
personName,
...otherProps
}: MovieCreditPosterProps) {
const importList = useSelector(createMovieCreditImportListSelector(tmdbId));
const dispatch = useDispatch();
const handleImportListSelect = useCallback(() => {
dispatch(
selectImportListSchema({
implementation: 'TMDbPersonImport',
implementationName: 'TMDb Person',
presetName: undefined,
})
);
dispatch(
// @ts-expect-error 'setImportListFieldValue' isn't typed yet
setImportListFieldValue({ name: 'personId', value: tmdbId.toString() })
);
dispatch(
// @ts-expect-error 'setImportListValue' isn't typed yet
setImportListValue({ name: 'name', value: `${personName} - ${tmdbId}` })
);
}, [dispatch, tmdbId, personName]);
return (
<ItemComponent
{...otherProps}
tmdbId={tmdbId}
personName={personName}
importList={importList}
onImportListSelect={handleImportListSelect}
/>
);
}
export default MovieCreditPoster;

View File

@@ -1,66 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { selectImportListSchema, setImportListFieldValue, setImportListValue } from 'Store/Actions/settingsActions';
import createMovieCreditListSelector from 'Store/Selectors/createMovieCreditListSelector';
function createMapStateToProps() {
return createSelector(
createMovieCreditListSelector(),
(importList) => {
return {
importList
};
}
);
}
const mapDispatchToProps = {
selectImportListSchema,
setImportListFieldValue,
setImportListValue
};
class MovieCreditPosterConnector extends Component {
//
// Listeners
onImportListSelect = () => {
this.props.selectImportListSchema({ implementation: 'TMDbPersonImport', implementationName: 'TMDb Person', presetName: undefined });
this.props.setImportListFieldValue({ name: 'personId', value: this.props.tmdbId.toString() });
this.props.setImportListValue({ name: 'name', value: `${this.props.personName} - ${this.props.tmdbId}` });
};
//
// Render
render() {
const {
tmdbId,
component: ItemComponent,
personName
} = this.props;
return (
<ItemComponent
{...this.props}
tmdbId={tmdbId}
personName={personName}
onImportListSelect={this.onImportListSelect}
/>
);
}
}
MovieCreditPosterConnector.propTypes = {
tmdbId: PropTypes.number.isRequired,
personName: PropTypes.string.isRequired,
component: PropTypes.elementType.isRequired,
selectImportListSchema: PropTypes.func.isRequired,
setImportListFieldValue: PropTypes.func.isRequired,
setImportListValue: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MovieCreditPosterConnector);

View File

@@ -1,109 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Navigation } from 'swiper';
import { Swiper, SwiperSlide } from 'swiper/react';
import dimensions from 'Styles/Variables/dimensions';
import MovieCreditPosterConnector from './MovieCreditPosterConnector';
import styles from './MovieCreditPosters.css';
// Import Swiper styles
import 'swiper/css';
import 'swiper/css/navigation';
// Poster container dimensions
const columnPadding = parseInt(dimensions.movieIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen);
function calculateRowHeight(posterHeight, isSmallScreen) {
const titleHeight = 19;
const characterHeight = 19;
const heights = [
posterHeight,
titleHeight,
characterHeight,
isSmallScreen ? columnPaddingSmallScreen : columnPadding
];
return heights.reduce((acc, height) => acc + height, 0);
}
class MovieCreditPosters extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
width: 0,
columnWidth: 182,
columnCount: 1,
posterWidth: 162,
posterHeight: 238,
rowHeight: calculateRowHeight(238, props.isSmallScreen)
};
}
//
// Render
render() {
const {
items,
itemComponent,
isSmallScreen
} = this.props;
const {
posterWidth,
posterHeight,
rowHeight
} = this.state;
return (
<div className={styles.sliderContainer}>
<Swiper
slidesPerView='auto'
spaceBetween={10}
slidesPerGroup={isSmallScreen ? 1 : 3}
navigation={true}
loop={false}
loopFillGroupWithBlank={true}
className="mySwiper"
modules={[Navigation]}
onInit={(swiper) => {
swiper.navigation.init();
swiper.navigation.update();
}}
>
{items.map((credit) => (
<SwiperSlide key={credit.id} style={{ width: posterWidth, height: rowHeight }}>
<MovieCreditPosterConnector
key={credit.id}
component={itemComponent}
posterWidth={posterWidth}
posterHeight={posterHeight}
tmdbId={credit.personTmdbId}
personName={credit.personName}
job={credit.job}
character={credit.character}
images={credit.images}
/>
</SwiperSlide>
))}
</Swiper>
</div>
);
}
}
MovieCreditPosters.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
itemComponent: PropTypes.elementType.isRequired,
isSmallScreen: PropTypes.bool.isRequired
};
export default MovieCreditPosters;

View File

@@ -0,0 +1,87 @@
import React, { useCallback, useMemo } from 'react';
import { Navigation } from 'swiper';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Swiper as SwiperClass } from 'swiper/types';
import dimensions from 'Styles/Variables/dimensions';
import MovieCredit from 'typings/MovieCredit';
import MovieCreditPoster from './MovieCreditPoster';
import styles from './MovieCreditPosters.css';
// Import Swiper styles
import 'swiper/css';
import 'swiper/css/navigation';
// Poster container dimensions
const columnPadding = parseInt(dimensions.movieIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(
dimensions.movieIndexColumnPaddingSmallScreen
);
interface MovieCreditPostersProps {
items: MovieCredit[];
itemComponent: React.ElementType;
isSmallScreen: boolean;
}
function MovieCreditPosters(props: MovieCreditPostersProps) {
const { items, itemComponent, isSmallScreen } = props;
const posterWidth = 162;
const posterHeight = 238;
const rowHeight = useMemo(() => {
const titleHeight = 19;
const characterHeight = 19;
const heights = [
posterHeight,
titleHeight,
characterHeight,
isSmallScreen ? columnPaddingSmallScreen : columnPadding,
];
return heights.reduce((acc, height) => acc + height, 0);
}, [posterHeight, isSmallScreen]);
const handleSwiperInit = useCallback((swiper: SwiperClass) => {
swiper.navigation.init();
swiper.navigation.update();
}, []);
return (
<div className={styles.sliderContainer}>
<Swiper
slidesPerView="auto"
spaceBetween={10}
slidesPerGroup={isSmallScreen ? 1 : 3}
navigation={true}
loop={false}
loopFillGroupWithBlank={true}
className="mySwiper"
modules={[Navigation]}
onInit={handleSwiperInit}
>
{items.map((credit) => (
<SwiperSlide
key={credit.id}
style={{ width: posterWidth, height: rowHeight }}
>
<MovieCreditPoster
key={credit.id}
component={itemComponent}
posterWidth={posterWidth}
posterHeight={posterHeight}
tmdbId={credit.personTmdbId}
personName={credit.personName}
images={credit.images}
job={credit.job}
character={credit.character}
/>
</SwiperSlide>
))}
</Swiper>
</div>
);
}
export default MovieCreditPosters;

View File

@@ -20,12 +20,14 @@ import RottenTomatoRating from 'Components/RottenTomatoRating';
import TmdbRating from 'Components/TmdbRating';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import TraktRating from 'Components/TraktRating';
import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
import MovieHistoryModal from 'Movie/History/MovieHistoryModal';
import MovieCollectionLabelConnector from 'Movie/MovieCollectionLabelConnector';
import MoviePoster from 'Movie/MoviePoster';
import MovieInteractiveSearchModal from 'Movie/Search/MovieInteractiveSearchModal';
import MovieFileEditorTable from 'MovieFile/Editor/MovieFileEditorTable';
@@ -37,9 +39,8 @@ import * as keyCodes from 'Utilities/Constants/keyCodes';
import formatRuntime from 'Utilities/Date/formatRuntime';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import MovieCollectionLabelConnector from './../MovieCollectionLabelConnector';
import MovieCastPostersConnector from './Credits/Cast/MovieCastPostersConnector';
import MovieCrewPostersConnector from './Credits/Crew/MovieCrewPostersConnector';
import MovieCastPosters from './Credits/Cast/MovieCastPosters';
import MovieCrewPosters from './Credits/Crew/MovieCrewPosters';
import MovieDetailsLinks from './MovieDetailsLinks';
import MovieReleaseDates from './MovieReleaseDates';
import MovieStatusLabel from './MovieStatusLabel';
@@ -421,14 +422,15 @@ class MovieDetails extends Component {
<div className={styles.details}>
<div>
{
!!certification &&
certification ?
<span className={styles.certification}>
{certification}
</span>
</span> :
null
}
{
year > 0 &&
year > 0 ?
<span className={styles.year}>
<Popover
anchor={
@@ -444,14 +446,16 @@ class MovieDetails extends Component {
}
position={tooltipPositions.BOTTOM}
/>
</span>
</span> :
null
}
{
!!runtime &&
runtime ?
<span className={styles.runtime}>
{formatRuntime(runtime, movieRuntimeFormat)}
</span>
</span> :
null
}
{
@@ -497,31 +501,44 @@ class MovieDetails extends Component {
<div className={styles.details}>
{
!!ratings.tmdb &&
ratings.tmdb ?
<span className={styles.rating}>
<TmdbRating
ratings={ratings}
iconSize={20}
/>
</span>
</span> :
null
}
{
!!ratings.imdb &&
ratings.imdb ?
<span className={styles.rating}>
<ImdbRating
ratings={ratings}
iconSize={20}
/>
</span>
</span> :
null
}
{
!!ratings.rottenTomatoes &&
ratings.rottenTomatoes ?
<span className={styles.rating}>
<RottenTomatoRating
ratings={ratings}
iconSize={20}
/>
</span>
</span> :
null
}
{
ratings.trakt ?
<span className={styles.rating}>
<TraktRating
ratings={ratings}
iconSize={20}
/>
</span> :
null
}
</div>
@@ -685,13 +702,13 @@ class MovieDetails extends Component {
</FieldSet>
<FieldSet legend={translate('Cast')}>
<MovieCastPostersConnector
<MovieCastPosters
isSmallScreen={isSmallScreen}
/>
</FieldSet>
<FieldSet legend={translate('Crew')}>
<MovieCrewPostersConnector
<MovieCrewPosters
isSmallScreen={isSmallScreen}
/>
</FieldSet>

View File

@@ -2,23 +2,25 @@ import React from 'react';
import { useSelector } from 'react-redux';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import Movie from 'Movie/Movie';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatDate from 'Utilities/Date/formatDate';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import translate from 'Utilities/String/translate';
import styles from './MovieReleaseDates.css';
interface MovieReleaseDatesProps {
inCinemas?: string;
digitalRelease?: string;
physicalRelease?: string;
}
type MovieReleaseDatesProps = Pick<
Movie,
'inCinemas' | 'digitalRelease' | 'physicalRelease'
>;
function MovieReleaseDates(props: MovieReleaseDatesProps) {
const { inCinemas, digitalRelease, physicalRelease } = props;
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
createUISettingsSelector()
);
function MovieReleaseDates({
inCinemas,
digitalRelease,
physicalRelease,
}: MovieReleaseDatesProps) {
const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } =
useSelector(createUISettingsSelector());
if (!inCinemas && !physicalRelease && !digitalRelease) {
return (
@@ -34,10 +36,16 @@ function MovieReleaseDates(props: MovieReleaseDatesProps) {
return (
<>
{inCinemas ? (
<div title={translate('InCinemas')}>
<div
title={`${translate('InCinemas')}: ${formatDate(
inCinemas,
longDateFormat
)}`}
>
<div className={styles.dateIcon}>
<Icon name={icons.IN_CINEMAS} />
</div>
{getRelativeDate({
date: inCinemas,
shortDateFormat,
@@ -49,10 +57,16 @@ function MovieReleaseDates(props: MovieReleaseDatesProps) {
) : null}
{digitalRelease ? (
<div title={translate('DigitalRelease')}>
<div
title={`${translate('DigitalRelease')}: ${formatDate(
digitalRelease,
longDateFormat
)}`}
>
<div className={styles.dateIcon}>
<Icon name={icons.MOVIE_FILE} />
</div>
{getRelativeDate({
date: digitalRelease,
shortDateFormat,
@@ -64,10 +78,16 @@ function MovieReleaseDates(props: MovieReleaseDatesProps) {
) : null}
{physicalRelease ? (
<div title={translate('PhysicalRelease')}>
<div
title={`${translate('PhysicalRelease')}: ${formatDate(
physicalRelease,
longDateFormat
)}`}
>
<div className={styles.dateIcon}>
<Icon name={icons.DISC} />
</div>
{getRelativeDate({
date: physicalRelease,
shortDateFormat,

View File

@@ -1,40 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import titleCase from 'Utilities/String/titleCase';
class MovieTitlesRow extends Component {
//
// Render
render() {
const {
title,
sourceType
} = this.props;
return (
<TableRow>
<TableRowCell>
{title}
</TableRowCell>
<TableRowCell>
{titleCase(sourceType)}
</TableRowCell>
</TableRow>
);
}
}
MovieTitlesRow.propTypes = {
id: PropTypes.number.isRequired,
title: PropTypes.string.isRequired,
sourceType: PropTypes.string.isRequired
};
export default MovieTitlesRow;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import titleCase from 'Utilities/String/titleCase';
interface MovieTitlesRowProps {
title: string;
sourceType: string;
}
function MovieTitlesRow({ title, sourceType }: MovieTitlesRowProps) {
return (
<TableRow>
<TableRowCell>{title}</TableRowCell>
<TableRowCell>{titleCase(sourceType)}</TableRowCell>
</TableRow>
);
}
export default MovieTitlesRow;

View File

@@ -1,3 +1,9 @@
.blankpad {
padding-top: 10px;
padding-bottom: 10px;
padding-left: 2em;
}
.container {
border: 1px solid var(--borderColor);
border-radius: 4px;

View File

@@ -1,6 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'blankpad': string;
'container': string;
}
export const cssExports: CssExports;

View File

@@ -1,22 +0,0 @@
import React from 'react';
import MovieTitlesTableContentConnector from './MovieTitlesTableContentConnector';
import styles from './MovieTitlesTable.css';
function MovieTitlesTable(props) {
const {
...otherProps
} = props;
return (
<div className={styles.container}>
<MovieTitlesTableContentConnector
{...otherProps}
/>
</div>
);
}
MovieTitlesTable.propTypes = {
};
export default MovieTitlesTable;

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { kinds } from 'Helpers/Props';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import MovieTitlesRow from './MovieTitlesRow';
import styles from './MovieTitlesTable.css';
const columns: Column[] = [
{
name: 'alternativeTitle',
label: () => translate('AlternativeTitle'),
isVisible: true,
},
{
name: 'sourceType',
label: () => translate('Type'),
isVisible: true,
},
];
function movieAlternativeTitlesSelector(movieId: number) {
return createSelector(
(state: AppState) => state.movies,
(movies) => {
const { isFetching, isPopulated, error, items } = movies;
const alternateTitles =
items.find((m) => m.id === movieId)?.alternateTitles ?? [];
return {
isFetching,
isPopulated,
error,
items: alternateTitles,
};
}
);
}
interface MovieTitlesProps {
movieId: number;
}
function MovieTitlesTable({ movieId }: MovieTitlesProps) {
const { isFetching, isPopulated, error, items } = useSelector(
movieAlternativeTitlesSelector(movieId)
);
const sortedItems = items.sort(sortByProp('title'));
if (!isFetching && !!error) {
return (
<Alert kind={kinds.DANGER}>
{translate('AlternativeTitlesLoadError')}
</Alert>
);
}
return (
<div className={styles.container}>
{isFetching && <LoadingIndicator />}
{isPopulated && !items.length && !error ? (
<div className={styles.blankpad}>
{translate('NoAlternativeTitles')}
</div>
) : null}
{isPopulated && !!items.length && !error ? (
<Table columns={columns}>
<TableBody>
{sortedItems.map((item) => (
<MovieTitlesRow
key={item.id}
title={item.title}
sourceType={item.sourceType}
/>
))}
</TableBody>
</Table>
) : null}
</div>
);
}
export default MovieTitlesTable;

View File

@@ -1,5 +0,0 @@
.blankpad {
padding-top: 10px;
padding-bottom: 10px;
padding-left: 2em;
}

View File

@@ -1,87 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import translate from 'Utilities/String/translate';
import MovieTitlesRow from './MovieTitlesRow';
import styles from './MovieTitlesTableContent.css';
const columns = [
{
name: 'altTitle',
label: () => translate('AlternativeTitle'),
isVisible: true
},
{
name: 'sourceType',
label: () => translate('Type'),
isVisible: true
}
];
class MovieTitlesTableContent extends Component {
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items
} = this.props;
const hasItems = !!items.length;
return (
<div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div className={styles.blankpad}>
{translate('UnableToLoadAltTitle')}
</div>
}
{
isPopulated && !hasItems && !error &&
<div className={styles.blankpad}>
{translate('NoAltTitle')}
</div>
}
{
isPopulated && hasItems && !error &&
<Table columns={columns}>
<TableBody>
{
items.reverse().map((item) => {
return (
<MovieTitlesRow
key={item.id}
{...item}
/>
);
})
}
</TableBody>
</Table>
}
</div>
);
}
}
MovieTitlesTableContent.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default MovieTitlesTableContent;

View File

@@ -1,60 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import MovieTitlesTableContent from './MovieTitlesTableContent';
function createMapStateToProps() {
return createSelector(
(state, { movieId }) => movieId,
(state) => state.movies,
(movieId, movies) => {
const {
isFetching,
isPopulated,
error,
items
} = movies;
const alternateTitles = items.find((m) => m.id === movieId)?.alternateTitles;
return {
isFetching,
isPopulated,
error,
alternateTitles
};
}
);
}
class MovieTitlesTableContentConnector extends Component {
//
// Render
render() {
const {
alternateTitles,
...otherProps
} = this.props;
return (
<MovieTitlesTableContent
{...otherProps}
items={alternateTitles}
/>
);
}
}
MovieTitlesTableContentConnector.propTypes = {
movieId: PropTypes.number.isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired
};
MovieTitlesTableContentConnector.defaultProps = {
alternateTitles: []
};
export default connect(createMapStateToProps)(MovieTitlesTableContentConnector);

View File

@@ -4,8 +4,6 @@
margin-right: auto;
}
.tagInternalInput {
composes: internalInput from '~Components/Form/TagInput.css';
flex: 0 0 100%;
.labelIcon {
margin-left: 8px;
}

View File

@@ -2,7 +2,7 @@
// Please do not change this file!
interface CssExports {
'deleteButton': string;
'tagInternalInput': string;
'labelIcon': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -1,16 +1,19 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import MovieMinimumAvailabilityPopoverContent from 'AddMovie/MovieMinimumAvailabilityPopoverContent';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import MoveMovieModal from 'Movie/MoveMovie/MoveMovieModal';
import translate from 'Utilities/String/translate';
import styles from './EditMovieModalContent.css';
@@ -103,7 +106,21 @@ class EditMovieModalContent extends Component {
</FormGroup>
<FormGroup>
<FormLabel>{translate('MinimumAvailability')}</FormLabel>
<FormLabel>
{translate('MinimumAvailability')}
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title={translate('MinimumAvailability')}
body={<MovieMinimumAvailabilityPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.AVAILABILITY_SELECT}

View File

@@ -136,6 +136,15 @@ function MovieIndexSortMenu(props: MovieIndexSortMenuProps) {
{translate('RottenTomatoesRating')}
</SortMenuItem>
<SortMenuItem
name="traktRating"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('TraktRating')}
</SortMenuItem>
<SortMenuItem
name="popularity"
sortKey={sortKey}

View File

@@ -11,6 +11,7 @@ import RottenTomatoRating from 'Components/RottenTomatoRating';
import TagListConnector from 'Components/TagListConnector';
import TmdbRating from 'Components/TmdbRating';
import Popover from 'Components/Tooltip/Popover';
import TraktRating from 'Components/TraktRating';
import { icons } from 'Helpers/Props';
import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks';
@@ -21,6 +22,7 @@ import { Statistics } from 'Movie/Movie';
import MoviePoster from 'Movie/MoviePoster';
import { executeCommand } from 'Store/Actions/commandActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import formatDate from 'Utilities/Date/formatDate';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import translate from 'Utilities/String/translate';
import createMovieIndexItemSelector from '../createMovieIndexItemSelector';
@@ -54,6 +56,7 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
showTmdbRating,
showImdbRating,
showRottenTomatoesRating,
showTraktRating,
showTags,
showSearchAction,
} = useSelector(selectPosterOptions);
@@ -241,7 +244,13 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
) : null}
{showCinemaRelease && inCinemas ? (
<div className={styles.title} title={translate('InCinemas')}>
<div
className={styles.title}
title={`${translate('InCinemas')}: ${formatDate(
inCinemas,
longDateFormat
)}`}
>
<Icon name={icons.IN_CINEMAS} />{' '}
{getRelativeDate({
date: inCinemas,
@@ -254,7 +263,13 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
) : null}
{showDigitalRelease && digitalRelease ? (
<div className={styles.title} title={translate('DigitalRelease')}>
<div
className={styles.title}
title={`${translate('DigitalRelease')}: ${formatDate(
digitalRelease,
longDateFormat
)}`}
>
<Icon name={icons.MOVIE_FILE} />{' '}
{getRelativeDate({
date: digitalRelease,
@@ -267,7 +282,13 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
) : null}
{showPhysicalRelease && physicalRelease ? (
<div className={styles.title} title={translate('PhysicalRelease')}>
<div
className={styles.title}
title={`${translate('PhysicalRelease')}: ${formatDate(
physicalRelease,
longDateFormat
)}`}
>
<Icon name={icons.DISC} />{' '}
{getRelativeDate({
date: physicalRelease,
@@ -280,7 +301,13 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
) : null}
{showReleaseDate && releaseDate ? (
<div className={styles.title} title={translate('ReleaseDate')}>
<div
className={styles.title}
title={`${translate('ReleaseDate')}: ${formatDate(
releaseDate,
longDateFormat
)}`}
>
<Icon name={icons.CALENDAR} />{' '}
{getRelativeDate({
date: releaseDate,
@@ -310,6 +337,12 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
</div>
) : null}
{showTraktRating && !!ratings.trakt ? (
<div className={styles.title}>
<TraktRating ratings={ratings} iconSize={12} />
</div>
) : null}
{showTags && tags.length ? (
<div className={styles.tags}>
<div className={styles.tagsList}>
@@ -347,6 +380,7 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
showTmdbRating={showTmdbRating}
showImdbRating={showImdbRating}
showRottenTomatoesRating={showRottenTomatoesRating}
showTraktRating={showTraktRating}
showTags={showTags}
/>

View File

@@ -4,10 +4,12 @@ import ImdbRating from 'Components/ImdbRating';
import RottenTomatoRating from 'Components/RottenTomatoRating';
import TagListConnector from 'Components/TagListConnector';
import TmdbRating from 'Components/TmdbRating';
import TraktRating from 'Components/TraktRating';
import { icons } from 'Helpers/Props';
import Language from 'Language/Language';
import { Ratings } from 'Movie/Movie';
import QualityProfile from 'typings/QualityProfile';
import formatDate from 'Utilities/Date/formatDate';
import formatDateTime from 'Utilities/Date/formatDateTime';
import getRelativeDate from 'Utilities/Date/getRelativeDate';
import formatBytes from 'Utilities/Number/formatBytes';
@@ -43,6 +45,7 @@ interface MovieIndexPosterInfoProps {
showTmdbRating: boolean;
showImdbRating: boolean;
showRottenTomatoesRating: boolean;
showTraktRating: boolean;
showTags: boolean;
}
@@ -76,6 +79,7 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
showTmdbRating,
showImdbRating,
showRottenTomatoesRating,
showTraktRating,
showTags,
} = props;
@@ -136,7 +140,13 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
});
return (
<div className={styles.info} title={translate('InCinemas')}>
<div
className={styles.info}
title={`${translate('InCinemas')}: ${formatDate(
inCinemas,
longDateFormat
)}`}
>
<Icon name={icons.IN_CINEMAS} /> {inCinemasDate}
</div>
);
@@ -152,7 +162,13 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
});
return (
<div className={styles.info} title={translate('DigitalRelease')}>
<div
className={styles.info}
title={`${translate('DigitalRelease')}: ${formatDate(
digitalRelease,
longDateFormat
)}`}
>
<Icon name={icons.MOVIE_FILE} /> {digitalReleaseDate}
</div>
);
@@ -172,7 +188,13 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
});
return (
<div className={styles.info} title={translate('PhysicalRelease')}>
<div
className={styles.info}
title={`${translate('PhysicalRelease')}: ${formatDate(
physicalRelease,
longDateFormat
)}`}
>
<Icon name={icons.DISC} /> {physicalReleaseDate}
</div>
);
@@ -180,7 +202,13 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
if (sortKey === 'releaseDate' && releaseDate && !showReleaseDate) {
return (
<div className={styles.info} title={translate('ReleaseDate')}>
<div
className={styles.info}
title={`${translate('ReleaseDate')}: ${formatDate(
releaseDate,
longDateFormat
)}`}
>
<Icon name={icons.CALENDAR} />{' '}
{getRelativeDate({
date: releaseDate,
@@ -221,6 +249,14 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
);
}
if (!showTraktRating && sortKey === 'traktRating' && !!ratings.trakt) {
return (
<div className={styles.info}>
<TraktRating ratings={ratings} iconSize={12} />
</div>
);
}
if (!showTags && sortKey === 'tags' && tags.length) {
return (
<div className={styles.tags}>

View File

@@ -150,6 +150,7 @@ export default function MovieIndexPosters(props: MovieIndexPostersProps) {
showTmdbRating,
showImdbRating,
showRottenTomatoesRating,
showTraktRating,
showTags,
} = posterOptions;
@@ -199,6 +200,10 @@ export default function MovieIndexPosters(props: MovieIndexPostersProps) {
heights.push(19);
}
if (showTraktRating) {
heights.push(19);
}
if (showTags) {
heights.push(21);
}
@@ -253,6 +258,11 @@ export default function MovieIndexPosters(props: MovieIndexPostersProps) {
heights.push(19);
}
break;
case 'traktRating':
if (!showTraktRating) {
heights.push(19);
}
break;
case 'tags':
if (!showTags) {
heights.push(21);

View File

@@ -59,6 +59,7 @@ function MovieIndexPosterOptionsModalContent(
showTmdbRating,
showImdbRating,
showRottenTomatoesRating,
showTraktRating,
showTags,
showSearchAction,
} = posterOptions;
@@ -222,6 +223,18 @@ function MovieIndexPosterOptionsModalContent(
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('ShowTraktRating')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showTraktRating"
value={showTraktRating}
helpText={translate('ShowTraktRatingPosterHelpText')}
onChange={onPosterOptionChange}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('ShowTags')}</FormLabel>

View File

@@ -92,7 +92,8 @@
.imdbRating,
.tmdbRating,
.rottenTomatoesRating {
.rottenTomatoesRating,
.traktRating {
composes: cell;
flex: 0 0 80px;

View File

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

View File

@@ -14,6 +14,7 @@ import Column from 'Components/Table/Column';
import TagListConnector from 'Components/TagListConnector';
import TmdbRating from 'Components/TmdbRating';
import Tooltip from 'Components/Tooltip/Tooltip';
import TraktRating from 'Components/TraktRating';
import { icons, kinds } from 'Helpers/Props';
import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
import MovieDetailsLinks from 'Movie/Details/MovieDetailsLinks';
@@ -387,6 +388,14 @@ function MovieIndexRow(props: MovieIndexRowProps) {
);
}
if (name === 'traktRating') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>
{ratings.trakt ? <TraktRating ratings={ratings} /> : null}
</VirtualTableRowCell>
);
}
if (name === 'popularity') {
return (
<VirtualTableRowCell key={name} className={styles[name]}>

View File

@@ -82,7 +82,8 @@
.imdbRating,
.tmdbRating,
.rottenTomatoesRating {
.rottenTomatoesRating,
.traktRating {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 80px;

View File

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

View File

@@ -9,7 +9,7 @@ export type MovieStatus =
| 'released'
| 'deleted';
export type CoverType = 'poster' | 'fanart';
export type CoverType = 'poster' | 'fanart' | 'headshot';
export interface Image {
coverType: CoverType;
@@ -37,6 +37,12 @@ export interface Ratings {
tmdb: RatingValues;
metacritic: RatingValues;
rottenTomatoes: RatingValues;
trakt: RatingValues;
}
export interface AlternativeTitle extends ModelBase {
sourceType: string;
title: string;
}
interface Movie extends ModelBase {
@@ -52,6 +58,7 @@ interface Movie extends ModelBase {
originalTitle: string;
originalLanguage: Language;
collection: Collection;
alternateTitles: AlternativeTitle[];
studio: string;
qualityProfileId: number;
added: string;
@@ -72,6 +79,7 @@ interface Movie extends ModelBase {
images: Image[];
movieFile: MovieFile;
hasFile: boolean;
lastSearchTime?: string;
isAvailable: boolean;
isSaving?: boolean;
}

View File

@@ -1,25 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import MovieImage from './MovieImage';
const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3CAMAAAC+Te+kAAAAZlBMVEUvLi8vLy8vLzAvMDAwLy8wLzAwMDAwMDEwMTExMDAxMDExMTExMTIxMjIyMjIyMjMyMzMzMjMzMzMzMzQzNDQ0NDQ0NDU0NTU1NTU1NTY1NjY2NTY2NjY2Njc2Nzc3Njc3Nzc3NziHChLWAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+MKCgEdHeShUbsAAALZSURBVHja7dxNcuwgDEZR1qAVmP1vMrNUJe91GfTzCSpXo575lAymjYWGXRIDKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKNA/AZ3fcTR0/owjofNDnAadnwPoPnS+xTXQeQZ0rkQ/dC4H0Gzo7ITO3bgGOnug/2PcAF3Mczt0fUj0QncG7znQBupw3PkWqh8qpkagpnyqjuArkkxaC02kRqGypCZANVYFdJZCdy9WTRVB5znQ6qTmjFFBWnOhdg20Lqnp0CpqAbRmAJRAK5JaA32zngTNvv910OSkVkJTs1oLtWugeTkNQZ/nkT2rotBHldUwNE6VQTVWGTQ6AHKggqGaBS23JkKf0hUgE1qa01Ro5fzPhoapR0HtCGg4q0poSCqFRgaAFhqxqqEr1EOgmdJaqHdaHQq1I6CunPZAHdY2aIJUBN2V9kE3H1Wd0BXrNVA7BLpgdUCtALo8pZqhdgd0Z6OyE7q1pdoH3dv7tS7o7iZ1E3R/N70Huuz795cQao65vvkqooT+vEgDdPcbj2s3zxTv9Qt/7cuhdgfUo2yAOplyqNuphfqZSqhFmEJo0HkcdPZCo0rRymRxpwSawHR+YtyBZihfvi+nQO0OqCmcYahGqYPGS4qCUJkzBpUpJdCkordyaFZxXi1UUpaZAJ2XQFOLh8ug2XXjVdD0+vYiqLIO3w1VH8EogtoxUPnpGxe04zyTA1p57i4T2nTmbnnnUuLMg1afYE2C1h+1zYEKjlknQLtPg9tb3YzU+dL054qOBb8cvcz3DlqBZhUmhdrnKo9j+pR0rkN5UHkznZHPtJIYN2TTCe1poTUyk9nWPO0bt8Ys7Ug34mlUMONtPUXMaEdXnXN1MnUzN2Z9q3Lr8XQN1DaLQJpXpiamZwltYdIUHShQoECBAgUKFChQoECBAgUKFChQoECBAgUKFChQoECBAgUKFCjQ+vgCff/mEp/vtiIAAAAASUVORK5CYII=';
function MovieHeadshot(props) {
return (
<MovieImage
{...props}
coverType="headshot"
placeholder={posterPlaceholder}
/>
);
}
MovieHeadshot.propTypes = {
size: PropTypes.number.isRequired
};
MovieHeadshot.defaultProps = {
size: 250
};
export default MovieHeadshot;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import MovieImage, { MovieImageProps } from './MovieImage';
const posterPlaceholder =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3CAMAAAC+Te+kAAAAZlBMVEUvLi8vLy8vLzAvMDAwLy8wLzAwMDAwMDEwMTExMDAxMDExMTExMTIxMjIyMjIyMjMyMzMzMjMzMzMzMzQzNDQ0NDQ0NDU0NTU1NTU1NTY1NjY2NTY2NjY2Njc2Nzc3Njc3Nzc3NziHChLWAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+MKCgEdHeShUbsAAALZSURBVHja7dxNcuwgDEZR1qAVmP1vMrNUJe91GfTzCSpXo575lAymjYWGXRIDKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKNA/AZ3fcTR0/owjofNDnAadnwPoPnS+xTXQeQZ0rkQ/dC4H0Gzo7ITO3bgGOnug/2PcAF3Mczt0fUj0QncG7znQBupw3PkWqh8qpkagpnyqjuArkkxaC02kRqGypCZANVYFdJZCdy9WTRVB5znQ6qTmjFFBWnOhdg20Lqnp0CpqAbRmAJRAK5JaA32zngTNvv910OSkVkJTs1oLtWugeTkNQZ/nkT2rotBHldUwNE6VQTVWGTQ6AHKggqGaBS23JkKf0hUgE1qa01Ro5fzPhoapR0HtCGg4q0poSCqFRgaAFhqxqqEr1EOgmdJaqHdaHQq1I6CunPZAHdY2aIJUBN2V9kE3H1Wd0BXrNVA7BLpgdUCtALo8pZqhdgd0Z6OyE7q1pdoH3dv7tS7o7iZ1E3R/N70Huuz795cQao65vvkqooT+vEgDdPcbj2s3zxTv9Qt/7cuhdgfUo2yAOplyqNuphfqZSqhFmEJo0HkcdPZCo0rRymRxpwSawHR+YtyBZihfvi+nQO0OqCmcYahGqYPGS4qCUJkzBpUpJdCkordyaFZxXi1UUpaZAJ2XQFOLh8ug2XXjVdD0+vYiqLIO3w1VH8EogtoxUPnpGxe04zyTA1p57i4T2nTmbnnnUuLMg1afYE2C1h+1zYEKjlknQLtPg9tb3YzU+dL054qOBb8cvcz3DlqBZhUmhdrnKo9j+pR0rkN5UHkznZHPtJIYN2TTCe1poTUyk9nWPO0bt8Ys7Ug34mlUMONtPUXMaEdXnXN1MnUzN2Z9q3Lr8XQN1DaLQJpXpiamZwltYdIUHShQoECBAgUKFChQoECBAgUKFChQoECBAgUKFChQoECBAgUKFCjQ+vgCff/mEp/vtiIAAAAASUVORK5CYII=';
interface MovieHeadshotProps
extends Omit<MovieImageProps, 'coverType' | 'placeholder'> {
size?: 250 | 500;
}
function MovieHeadshot({ size = 250, ...otherProps }: MovieHeadshotProps) {
return (
<MovieImage
{...otherProps}
size={size}
coverType="headshot"
placeholder={posterPlaceholder}
/>
);
}
export default MovieHeadshot;

View File

@@ -43,7 +43,7 @@ function MovieImage({
}: MovieImageProps) {
const [url, setUrl] = useState<string | null>(null);
const [hasError, setHasError] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const [isLoaded, setIsLoaded] = useState(true);
const image = useRef<Image | null>(null);
const handleLoad = useCallback(() => {

View File

@@ -19,7 +19,7 @@ function EditImportListExclusionModal(
const dispatch = useDispatch();
const onModalClosePress = useCallback(() => {
const handleModalClose = useCallback(() => {
dispatch(
clearPendingChanges({
section: 'settings.importListExclusions',
@@ -29,10 +29,10 @@ function EditImportListExclusionModal(
}, [dispatch, onModalClose]);
return (
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={onModalClosePress}>
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}>
<EditImportListExclusionModalContent
{...otherProps}
onModalClose={onModalClosePress}
onModalClose={handleModalClose}
/>
</Modal>
);

View File

@@ -32,12 +32,6 @@ const newImportListExclusion = {
tmdbId: 0,
};
interface EditImportListExclusionModalContentProps {
id?: number;
onModalClose: () => void;
onDeleteImportListExclusionPress?: () => void;
}
function createImportListExclusionSelector(id?: number) {
return createSelector(
(state: AppState) => state.settings.importListExclusions,
@@ -63,12 +57,24 @@ function createImportListExclusionSelector(id?: number) {
);
}
function EditImportListExclusionModalContent(
props: EditImportListExclusionModalContentProps
) {
const { id, onModalClose, onDeleteImportListExclusionPress } = props;
interface EditImportListExclusionModalContentProps {
id?: number;
onModalClose: () => void;
onDeleteImportListExclusionPress?: () => void;
}
function EditImportListExclusionModalContent({
id,
onModalClose,
onDeleteImportListExclusionPress,
}: EditImportListExclusionModalContentProps) {
const { isFetching, isSaving, item, error, saveError, ...otherProps } =
useSelector(createImportListExclusionSelector(id));
const { movieTitle, movieYear, tmdbId } = item;
const dispatch = useDispatch();
const previousIsSaving = usePrevious(isSaving);
const dispatchSetImportListExclusionValue = (payload: {
name: string;
@@ -78,20 +84,10 @@ function EditImportListExclusionModalContent(
dispatch(setImportListExclusionValue(payload));
};
const { isFetching, isSaving, item, error, saveError, ...otherProps } =
useSelector(createImportListExclusionSelector(props.id));
const previousIsSaving = usePrevious(isSaving);
const { movieTitle, movieYear, tmdbId } = item;
useEffect(() => {
if (!id) {
Object.keys(newImportListExclusion).forEach((name) => {
dispatchSetImportListExclusionValue({
name,
value:
newImportListExclusion[name as keyof typeof newImportListExclusion],
});
Object.entries(newImportListExclusion).forEach(([name, value]) => {
dispatchSetImportListExclusionValue({ name, value });
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -101,7 +97,7 @@ function EditImportListExclusionModalContent(
if (previousIsSaving && !isSaving && !saveError) {
onModalClose();
}
});
}, [previousIsSaving, isSaving, saveError, onModalClose]);
const onSavePress = useCallback(() => {
dispatch(saveImportListExclusion({ id }));

View File

@@ -42,7 +42,7 @@ import styles from './ImportListExclusions.css';
const COLUMNS: Column[] = [
{
name: 'tmdbid',
name: 'tmdbId',
label: () => translate('TMDBId'),
isVisible: true,
isSortable: true,

View File

@@ -4,6 +4,10 @@
margin-right: auto;
}
.labelIcon {
margin-left: 8px;
}
.message {
composes: alert from '~Components/Alert.css';

View File

@@ -2,6 +2,7 @@
// Please do not change this file!
interface CssExports {
'deleteButton': string;
'labelIcon': string;
'message': string;
}
export const cssExports: CssExports;

View File

@@ -1,11 +1,13 @@
import PropTypes from 'prop-types';
import React from 'react';
import MovieMinimumAvailabilityPopoverContent from 'AddMovie/MovieMinimumAvailabilityPopoverContent';
import Alert from 'Components/Alert';
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 ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -13,7 +15,8 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import AdvancedSettingsButton from 'Settings/AdvancedSettingsButton';
import formatShortTimeSpan from 'Utilities/Date/formatShortTimeSpan';
import translate from 'Utilities/String/translate';
@@ -160,12 +163,28 @@ function EditImportListModalContent(props) {
</FormGroup>
<FormGroup>
<FormLabel>{translate('MinimumAvailability')}</FormLabel>
<FormLabel>
{translate('MinimumAvailability')}
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title={translate('MinimumAvailability')}
body={<MovieMinimumAvailabilityPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.AVAILABILITY_SELECT}
name="minimumAvailability"
{...minimumAvailability}
onChange={onInputChange}
helpLink="https://wiki.servarr.com/radarr/faq#what-is-minimum-availability"
/>
</FormGroup>

View File

@@ -3,6 +3,7 @@ import React, { Component } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import { kinds } from 'Helpers/Props';
import formatShortTimeSpan from 'Utilities/Date/formatShortTimeSpan';
import translate from 'Utilities/String/translate';
@@ -58,6 +59,8 @@ class ImportList extends Component {
name,
enabled,
enableAuto,
tags,
tagList,
minRefreshInterval
} = this.props;
@@ -72,7 +75,6 @@ class ImportList extends Component {
</div>
<div className={styles.enabled}>
{
enabled ?
<Label kind={kinds.SUCCESS}>
@@ -87,15 +89,21 @@ class ImportList extends Component {
}
{
enableAuto &&
enableAuto ?
<Label kind={kinds.SUCCESS}>
{translate('AutomaticAdd')}
</Label>
</Label> :
null
}
</div>
<TagList
tags={tags}
tagList={tagList}
/>
<div className={styles.enabled}>
<Label kind={kinds.INFO} title='List Refresh Interval'>
<Label kind={kinds.DEFAULT} title='List Refresh Interval'>
{`${translate('Refresh')}: ${formatShortTimeSpan(minRefreshInterval)}`}
</Label>
</div>
@@ -126,6 +134,8 @@ ImportList.propTypes = {
name: PropTypes.string.isRequired,
enabled: PropTypes.bool.isRequired,
enableAuto: PropTypes.bool.isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
minRefreshInterval: PropTypes.string.isRequired,
onConfirmDeleteImportList: PropTypes.func.isRequired
};

View File

@@ -49,6 +49,7 @@ class ImportLists extends Component {
render() {
const {
items,
tagList,
onConfirmDeleteImportList,
...otherProps
} = this.props;
@@ -71,6 +72,7 @@ class ImportLists extends Component {
<ImportList
key={item.id}
{...item}
tagList={tagList}
onConfirmDeleteImportList={onConfirmDeleteImportList}
/>
);
@@ -109,6 +111,7 @@ ImportLists.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteImportList: PropTypes.func.isRequired
};

View File

@@ -5,13 +5,20 @@ import { createSelector } from 'reselect';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { deleteImportList, fetchImportLists } from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import ImportLists from './ImportLists';
function createMapStateToProps() {
return createSelector(
createSortedSectionSelector('settings.importLists', sortByProp('name')),
(importLists) => importLists
createTagsSelector(),
(importLists, tagList) => {
return {
...importLists,
tagList
};
}
);
}

View File

@@ -13,7 +13,7 @@ import { inputTypes, kinds, sizes } from 'Helpers/Props';
import RootFolders from 'RootFolder/RootFolders';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import NamingConnector from './Naming/NamingConnector';
import Naming from './Naming/Naming';
import AddRootFolder from './RootFolder/AddRootFolder';
const rescanAfterRefreshOptions = [
@@ -106,7 +106,7 @@ class MediaManagement extends Component {
/>
<PageContentBody>
<NamingConnector />
<Naming />
{
isFetching ?
@@ -174,24 +174,21 @@ class MediaManagement extends Component {
<FieldSet
legend={translate('Importing')}
>
{
!isWindows &&
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('SkipFreeSpaceCheck')}</FormLabel>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('SkipFreeSpaceCheck')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="skipFreeSpaceCheckWhenImporting"
helpText={translate('SkipFreeSpaceCheckWhenImportingHelpText')}
onChange={onInputChange}
{...settings.skipFreeSpaceCheckWhenImporting}
/>
</FormGroup>
}
<FormInputGroup
type={inputTypes.CHECK}
name="skipFreeSpaceCheckWhenImporting"
helpText={translate('SkipFreeSpaceCheckHelpText')}
onChange={onInputChange}
{...settings.skipFreeSpaceCheckWhenImporting}
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}

View File

@@ -1,252 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
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 LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import NamingModal from './NamingModal';
import styles from './Naming.css';
const colonReplacementOptions = [
{
key: 'delete',
get value() {
return translate('Delete');
}
},
{
key: 'dash',
get value() {
return translate('ReplaceWithDash');
}
},
{
key: 'spaceDash',
get value() {
return translate('ReplaceWithSpaceDash');
}
},
{
key: 'spaceDashSpace',
get value() {
return translate('ReplaceWithSpaceDashSpace');
}
}
];
class Naming extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isNamingModalOpen: false,
namingModalOptions: null
};
}
//
// Listeners
onStandardNamingModalOpenClick = () => {
this.setState({
isNamingModalOpen: true,
namingModalOptions: {
name: 'standardMovieFormat',
additional: true
}
});
};
onMovieFolderNamingModalOpenClick = () => {
this.setState({
isNamingModalOpen: true,
namingModalOptions: {
name: 'movieFolderFormat'
}
});
};
onNamingModalClose = () => {
this.setState({ isNamingModalOpen: false });
};
//
// Render
render() {
const {
advancedSettings,
isFetching,
error,
settings,
hasSettings,
examples,
examplesPopulated,
onInputChange
} = this.props;
const {
isNamingModalOpen,
namingModalOptions
} = this.state;
const renameMovies = hasSettings && settings.renameMovies.value;
const replaceIllegalCharacters = hasSettings && settings.replaceIllegalCharacters.value;
const standardMovieFormatHelpTexts = [];
const standardMovieFormatErrors = [];
const movieFolderFormatHelpTexts = [];
const movieFolderFormatErrors = [];
if (examplesPopulated) {
if (examples.movieExample) {
standardMovieFormatHelpTexts.push(`${translate('Movie')}: ${examples.movieExample}`);
} else {
standardMovieFormatErrors.push({ get message() {
return translate('MovieInvalidFormat');
} });
}
if (examples.movieFolderExample) {
movieFolderFormatHelpTexts.push(`${translate('Example')}: ${examples.movieFolderExample}`);
} else {
movieFolderFormatErrors.push({ get message() {
return translate('InvalidFormat');
} });
}
}
return (
<FieldSet legend={translate('MovieNaming')}>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && error &&
<Alert kind={kinds.DANGER}>
{translate('NamingSettingsLoadError')}
</Alert>
}
{
hasSettings && !isFetching && !error &&
<Form>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('RenameMovies')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="renameMovies"
helpText={translate('RenameMoviesHelpText')}
onChange={onInputChange}
{...settings.renameMovies}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('ReplaceIllegalCharacters')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="replaceIllegalCharacters"
helpText={translate('ReplaceIllegalCharactersHelpText')}
onChange={onInputChange}
{...settings.replaceIllegalCharacters}
/>
</FormGroup>
{
replaceIllegalCharacters &&
<FormGroup>
<FormLabel>{translate('ColonReplacement')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="colonReplacementFormat"
values={colonReplacementOptions}
helpText={translate('ColonReplacementFormatHelpText')}
onChange={onInputChange}
{...settings.colonReplacementFormat}
/>
</FormGroup>
}
{
renameMovies &&
<FormGroup size={sizes.LARGE}>
<FormLabel>{translate('StandardMovieFormat')}</FormLabel>
<FormInputGroup
inputClassName={styles.namingInput}
type={inputTypes.TEXT}
name="standardMovieFormat"
buttons={<FormInputButton onPress={this.onStandardNamingModalOpenClick}>?</FormInputButton>}
onChange={onInputChange}
{...settings.standardMovieFormat}
helpTexts={standardMovieFormatHelpTexts}
errors={[...standardMovieFormatErrors, ...settings.standardMovieFormat.errors]}
/>
</FormGroup>
}
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('MovieFolderFormat')}</FormLabel>
<FormInputGroup
inputClassName={styles.namingInput}
type={inputTypes.TEXT}
name="movieFolderFormat"
buttons={<FormInputButton onPress={this.onMovieFolderNamingModalOpenClick}>?</FormInputButton>}
onChange={onInputChange}
{...settings.movieFolderFormat}
helpTexts={[translate('MovieFolderFormatHelpText'), ...movieFolderFormatHelpTexts]}
errors={[...movieFolderFormatErrors, ...settings.movieFolderFormat.errors]}
/>
</FormGroup>
{
namingModalOptions &&
<NamingModal
isOpen={isNamingModalOpen}
advancedSettings={advancedSettings}
{...namingModalOptions}
value={settings[namingModalOptions.name].value}
onInputChange={onInputChange}
onModalClose={this.onNamingModalClose}
/>
}
</Form>
}
</FieldSet>
);
}
}
Naming.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
settings: PropTypes.object.isRequired,
hasSettings: PropTypes.bool.isRequired,
examples: PropTypes.object.isRequired,
examplesPopulated: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired
};
export default Naming;

View File

@@ -0,0 +1,273 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
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 LoadingIndicator from 'Components/Loading/LoadingIndicator';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import {
fetchNamingExamples,
fetchNamingSettings,
setNamingSettingsValue,
} from 'Store/Actions/settingsActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import NamingConfig from 'typings/Settings/NamingConfig';
import translate from 'Utilities/String/translate';
import NamingModal from './NamingModal';
import styles from './Naming.css';
const SECTION = 'naming';
function createNamingSelector() {
return createSelector(
(state: AppState) => state.settings.advancedSettings,
(state: AppState) => state.settings.namingExamples,
createSettingsSectionSelector(SECTION),
(advancedSettings, namingExamples, sectionSettings) => {
return {
advancedSettings,
examples: namingExamples.item,
examplesPopulated: namingExamples.isPopulated,
...sectionSettings,
};
}
);
}
interface NamingModalOptions {
name: keyof Pick<NamingConfig, 'standardMovieFormat' | 'movieFolderFormat'>;
movie?: boolean;
additional?: boolean;
}
function Naming() {
const {
advancedSettings,
isFetching,
error,
settings,
hasSettings,
examples,
examplesPopulated,
} = useSelector(createNamingSelector());
const dispatch = useDispatch();
const [isNamingModalOpen, setNamingModalOpen, setNamingModalClosed] =
useModalOpenState(false);
const [namingModalOptions, setNamingModalOptions] =
useState<NamingModalOptions | null>(null);
const namingExampleTimeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
dispatch(fetchNamingSettings());
dispatch(fetchNamingExamples());
return () => {
dispatch(clearPendingChanges({ section: SECTION }));
};
}, [dispatch]);
const handleInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
// @ts-expect-error 'setNamingSettingsValue' isn't typed yet
dispatch(setNamingSettingsValue({ name, value }));
if (namingExampleTimeout.current) {
clearTimeout(namingExampleTimeout.current);
}
namingExampleTimeout.current = setTimeout(() => {
dispatch(fetchNamingExamples());
}, 1000);
},
[dispatch]
);
const onStandardNamingModalOpenClick = useCallback(() => {
setNamingModalOpen();
setNamingModalOptions({
name: 'standardMovieFormat',
movie: true,
additional: true,
});
}, [setNamingModalOpen, setNamingModalOptions]);
const onMovieFolderNamingModalOpenClick = useCallback(() => {
setNamingModalOpen();
setNamingModalOptions({
name: 'movieFolderFormat',
});
}, [setNamingModalOpen, setNamingModalOptions]);
const renameMovies = hasSettings && settings.renameMovies.value;
const replaceIllegalCharacters =
hasSettings && settings.replaceIllegalCharacters.value;
const colonReplacementOptions = [
{ key: 'delete', value: translate('Delete') },
{ key: 'dash', value: translate('ReplaceWithDash') },
{ key: 'spaceDash', value: translate('ReplaceWithSpaceDash') },
{ key: 'spaceDashSpace', value: translate('ReplaceWithSpaceDashSpace') },
{
key: 'smart',
value: translate('SmartReplace'),
hint: translate('SmartReplaceHint'),
},
];
const standardMovieFormatHelpTexts = [];
const standardMovieFormatErrors = [];
const movieFolderFormatHelpTexts = [];
const movieFolderFormatErrors = [];
if (examplesPopulated) {
if (examples.movieExample) {
standardMovieFormatHelpTexts.push(
`${translate('Movie')}: ${examples.movieExample}`
);
} else {
standardMovieFormatErrors.push({
message: translate('MovieInvalidFormat'),
});
}
if (examples.movieFolderExample) {
movieFolderFormatHelpTexts.push(
`${translate('Example')}: ${examples.movieFolderExample}`
);
} else {
movieFolderFormatErrors.push({ message: translate('InvalidFormat') });
}
}
return (
<FieldSet legend={translate('MovieNaming')}>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>
{translate('NamingSettingsLoadError')}
</Alert>
) : null}
{hasSettings && !isFetching && !error ? (
<Form>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('RenameMovies')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="renameMovies"
helpText={translate('RenameMoviesHelpText')}
onChange={handleInputChange}
{...settings.renameMovies}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('ReplaceIllegalCharacters')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="replaceIllegalCharacters"
helpText={translate('ReplaceIllegalCharactersHelpText')}
onChange={handleInputChange}
{...settings.replaceIllegalCharacters}
/>
</FormGroup>
{replaceIllegalCharacters ? (
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('ColonReplacement')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="colonReplacementFormat"
values={colonReplacementOptions}
helpText={translate('ColonReplacementFormatHelpText')}
onChange={handleInputChange}
{...settings.colonReplacementFormat}
/>
</FormGroup>
) : null}
{renameMovies ? (
<FormGroup size={sizes.LARGE}>
<FormLabel>{translate('StandardMovieFormat')}</FormLabel>
<FormInputGroup
inputClassName={styles.namingInput}
type={inputTypes.TEXT}
name="standardMovieFormat"
buttons={
<FormInputButton onPress={onStandardNamingModalOpenClick}>
?
</FormInputButton>
}
onChange={handleInputChange}
{...settings.standardMovieFormat}
helpTexts={standardMovieFormatHelpTexts}
errors={[
...standardMovieFormatErrors,
...settings.standardMovieFormat.errors,
]}
/>
</FormGroup>
) : null}
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('MovieFolderFormat')}</FormLabel>
<FormInputGroup
inputClassName={styles.namingInput}
type={inputTypes.TEXT}
name="movieFolderFormat"
buttons={
<FormInputButton onPress={onMovieFolderNamingModalOpenClick}>
?
</FormInputButton>
}
onChange={handleInputChange}
{...settings.movieFolderFormat}
helpTexts={[
translate('MovieFolderFormatHelpText'),
...movieFolderFormatHelpTexts,
]}
errors={[
...movieFolderFormatErrors,
...settings.movieFolderFormat.errors,
]}
/>
</FormGroup>
{namingModalOptions ? (
<NamingModal
isOpen={isNamingModalOpen}
{...namingModalOptions}
value={settings[namingModalOptions.name].value}
onInputChange={handleInputChange}
onModalClose={setNamingModalClosed}
/>
) : null}
</Form>
) : null}
</FieldSet>
);
}
export default Naming;

View File

@@ -1,97 +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 { clearPendingChanges } from 'Store/Actions/baseActions';
import { fetchNamingExamples, fetchNamingSettings, setNamingSettingsValue } from 'Store/Actions/settingsActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import Naming from './Naming';
const SECTION = 'naming';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
(state) => state.settings.namingExamples,
createSettingsSectionSelector(SECTION),
(advancedSettings, examples, sectionSettings) => {
return {
advancedSettings,
examples: examples.item,
examplesPopulated: !_.isEmpty(examples.item),
...sectionSettings
};
}
);
}
const mapDispatchToProps = {
fetchNamingSettings,
setNamingSettingsValue,
fetchNamingExamples,
clearPendingChanges
};
class NamingConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._namingExampleTimeout = null;
}
componentDidMount() {
this.props.fetchNamingSettings();
this.props.fetchNamingExamples();
}
componentWillUnmount() {
this.props.clearPendingChanges({ section: `settings.${SECTION}` });
}
//
// Control
_fetchNamingExamples = () => {
this.props.fetchNamingExamples();
};
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setNamingSettingsValue({ name, value });
if (this._namingExampleTimeout) {
clearTimeout(this._namingExampleTimeout);
}
this._namingExampleTimeout = setTimeout(this._fetchNamingExamples, 1000);
};
//
// Render
render() {
return (
<Naming
onInputChange={this.onInputChange}
onSavePress={this.onSavePress}
{...this.props}
/>
);
}
}
NamingConnector.propTypes = {
fetchNamingSettings: PropTypes.func.isRequired,
setNamingSettingsValue: PropTypes.func.isRequired,
fetchNamingExamples: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(NamingConnector);

View File

@@ -1,506 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FieldSet from 'Components/FieldSet';
import SelectInput from 'Components/Form/SelectInput';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import Modal from 'Components/Modal/Modal';
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, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import NamingOption from './NamingOption';
import styles from './NamingModal.css';
const separatorOptions = [
{
key: ' ',
get value() {
return `${translate('Space')} ( )`;
}
},
{
key: '.',
get value() {
return `${translate('Period')} (.)`;
}
},
{
key: '_',
get value() {
return `${translate('Underscore')} (_)`;
}
},
{
key: '-',
get value() {
return `${translate('Dash')} (-)`;
}
}
];
const caseOptions = [
{
key: 'title',
get value() {
return translate('DefaultCase');
}
},
{
key: 'lower',
get value() {
return translate('Lowercase');
}
},
{
key: 'upper',
get value() {
return translate('Uppercase');
}
}
];
const fileNameTokens = [
{
token: '{Movie Title} - {Quality Full}',
example: 'Movie Title (2010) - HDTV-720p Proper'
}
];
const movieTokens = [
{ token: '{Movie Title}', example: 'Movie\'s Title', footNote: 1 },
{ token: '{Movie Title:DE}', example: 'Titel des Films', footNote: 1 },
{ token: '{Movie CleanTitle}', example: 'Movies Title', footNote: 1 },
{ token: '{Movie TitleThe}', example: 'Movie\'s Title, The', footNote: 1 },
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
{ token: '{Movie CleanOriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
{ token: '{Movie TitleFirstCharacter}', example: 'M' },
{ token: '{Movie TitleFirstCharacter:DE}', example: 'T' },
{ token: '{Movie Collection}', example: 'The Movie Collection', footNote: 1 },
{ token: '{Movie Certification}', example: 'R' },
{ token: '{Release Year}', example: '2009' }
];
const movieIdTokens = [
{ token: '{ImdbId}', example: 'tt12345' },
{ token: '{TmdbId}', example: '123456' }
];
const qualityTokens = [
{ token: '{Quality Full}', example: 'HDTV-720p Proper' },
{ token: '{Quality Title}', example: 'HDTV-720p' }
];
const mediaInfoTokens = [
{ token: '{MediaInfo Simple}', example: 'x264 DTS' },
{ token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]', footNote: 1 },
{ token: '{MediaInfo AudioCodec}', example: 'DTS' },
{ token: '{MediaInfo AudioChannels}', example: '5.1' },
{ token: '{MediaInfo AudioLanguages}', example: '[EN+DE]', footNote: 1 },
{ token: '{MediaInfo SubtitleLanguages}', example: '[DE]', footNote: 1 },
{ token: '{MediaInfo VideoCodec}', example: 'x264' },
{ token: '{MediaInfo VideoBitDepth}', example: '10' },
{ token: '{MediaInfo VideoDynamicRange}', example: 'HDR' },
{ token: '{MediaInfo VideoDynamicRangeType}', example: 'DV HDR10' },
{ token: '{MediaInfo 3D}', example: '3D' }
];
const releaseGroupTokens = [
{ token: '{Release Group}', example: 'Rls Grp', footNote: 1 }
];
const editionTokens = [
{ token: '{Edition Tags}', example: 'IMAX', footNote: 1 }
];
const customFormatTokens = [
{ token: '{Custom Formats}', example: 'Surround Sound x264' },
{ token: '{Custom Format:FormatName}', example: 'AMZN' }
];
const originalTokens = [
{ token: '{Original Title}', example: 'Movie.Title.HDTV.x264-EVOLVE' },
{ token: '{Original Filename}', example: 'movie title hdtv.x264-Evolve' }
];
class NamingModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._selectionStart = null;
this._selectionEnd = null;
this.state = {
separator: ' ',
case: 'title'
};
}
//
// Listeners
onTokenSeparatorChange = (event) => {
this.setState({ separator: event.value });
};
onTokenCaseChange = (event) => {
this.setState({ case: event.value });
};
onInputSelectionChange = (selectionStart, selectionEnd) => {
this._selectionStart = selectionStart;
this._selectionEnd = selectionEnd;
};
onOptionPress = ({ isFullFilename, tokenValue }) => {
const {
name,
value,
onInputChange
} = this.props;
const selectionStart = this._selectionStart;
const selectionEnd = this._selectionEnd;
if (isFullFilename) {
onInputChange({ name, value: tokenValue });
} else if (selectionStart == null) {
onInputChange({
name,
value: `${value}${tokenValue}`
});
} else {
const start = value.substring(0, selectionStart);
const end = value.substring(selectionEnd);
const newValue = `${start}${tokenValue}${end}`;
onInputChange({ name, value: newValue });
this._selectionStart = newValue.length - 1;
this._selectionEnd = newValue.length - 1;
}
};
//
// Render
render() {
const {
name,
value,
isOpen,
advancedSettings,
additional,
onInputChange,
onModalClose
} = this.props;
const {
separator: tokenSeparator,
case: tokenCase
} = this.state;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('FileNameTokens')}
</ModalHeader>
<ModalBody>
<div className={styles.namingSelectContainer}>
<SelectInput
className={styles.namingSelect}
name="separator"
value={tokenSeparator}
values={separatorOptions}
onChange={this.onTokenSeparatorChange}
/>
<SelectInput
className={styles.namingSelect}
name="case"
value={tokenCase}
values={caseOptions}
onChange={this.onTokenCaseChange}
/>
</div>
{
!advancedSettings &&
<FieldSet legend={translate('FileNames')}>
<div className={styles.groups}>
{
fileNameTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
isFullFilename={true}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
size={sizes.LARGE}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
}
<FieldSet legend={translate('Movie')}>
<div className={styles.groups}>
{
movieTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('MovieFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('MovieID')}>
<div className={styles.groups}>
{
movieIdTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
{
additional &&
<div>
<FieldSet legend={translate('Quality')}>
<div className={styles.groups}>
{
qualityTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
<FieldSet legend={translate('MediaInfo')}>
<div className={styles.groups}>
{
mediaInfoTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('MediaInfoFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('ReleaseGroup')}>
<div className={styles.groups}>
{
releaseGroupTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('ReleaseGroupFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('Edition')}>
<div className={styles.groups}>
{
editionTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('EditionFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('CustomFormats')}>
<div className={styles.groups}>
{
customFormatTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
<FieldSet legend={translate('Original')}>
<div className={styles.groups}>
{
originalTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
size={sizes.LARGE}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
</div>
}
</ModalBody>
<ModalFooter>
<TextInput
name={name}
value={value}
onChange={onInputChange}
onSelectionChange={this.onInputSelectionChange}
/>
<Button onPress={onModalClose}>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
NamingModal.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
isOpen: PropTypes.bool.isRequired,
advancedSettings: PropTypes.bool.isRequired,
additional: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
NamingModal.defaultProps = {
additional: false
};
export default NamingModal;

View File

@@ -0,0 +1,469 @@
import React, { useCallback, useState } from 'react';
import FieldSet from 'Components/FieldSet';
import SelectInput from 'Components/Form/SelectInput';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import Modal from 'Components/Modal/Modal';
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, sizes } from 'Helpers/Props';
import NamingConfig from 'typings/Settings/NamingConfig';
import translate from 'Utilities/String/translate';
import NamingOption from './NamingOption';
import TokenCase from './TokenCase';
import TokenSeparator from './TokenSeparator';
import styles from './NamingModal.css';
const separatorOptions: { key: TokenSeparator; value: string }[] = [
{
key: ' ',
get value() {
return `${translate('Space')} ( )`;
},
},
{
key: '.',
get value() {
return `${translate('Period')} (.)`;
},
},
{
key: '_',
get value() {
return `${translate('Underscore')} (_)`;
},
},
{
key: '-',
get value() {
return `${translate('Dash')} (-)`;
},
},
];
const caseOptions: { key: TokenCase; value: string }[] = [
{
key: 'title',
get value() {
return translate('DefaultCase');
},
},
{
key: 'lower',
get value() {
return translate('Lowercase');
},
},
{
key: 'upper',
get value() {
return translate('Uppercase');
},
},
];
const fileNameTokens = [
{
token:
'{Movie Title} ({Release Year}) - {Edition Tags }{[Custom Formats]}{[Quality Full]}{-Release Group}',
example:
'The Movie - Title (2010) - Ultimate Extended Edition [Surround Sound x264][Bluray-1080p Proper]-EVOLVE',
},
{
token:
'{Movie CleanTitle} {Release Year} - {Edition Tags }{[Custom Formats]}{[Quality Full]}{-Release Group}',
example:
'The Movie Title 2010 - Ultimate Extended Edition [Surround Sound x264][Bluray-1080p Proper]-EVOLVE',
},
{
token:
'{Movie.CleanTitle}{.Release.Year}{.Edition.Tags}{.Custom.Formats}{.Quality.Full}{-Release Group}',
example:
'The.Movie.Title.2010.Ultimate.Extended.Edition.Surround.Sound.x264.Bluray-1080p.Proper-EVOLVE',
},
];
const movieTokens = [
{ token: '{Movie Title}', example: "Movie's Title", footNote: true },
{ token: '{Movie Title:DE}', example: 'Titel des Films', footNote: true },
{ token: '{Movie CleanTitle}', example: 'Movies Title', footNote: true },
{
token: '{Movie CleanTitle:DE}',
example: 'Titel des Films',
footNote: true,
},
{ token: '{Movie TitleThe}', example: "Movie's Title, The", footNote: true },
{
token: '{Movie CleanTitleThe}',
example: 'Movies Title, The',
footNote: true,
},
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNote: true },
{
token: '{Movie CleanOriginalTitle}',
example: 'Τίτλος ταινίας',
footNote: true,
},
{ token: '{Movie TitleFirstCharacter}', example: 'M' },
{ token: '{Movie TitleFirstCharacter:DE}', example: 'T' },
{
token: '{Movie Collection}',
example: 'The Movie Collection',
footNote: true,
},
{ token: '{Movie Certification}', example: 'R' },
{ token: '{Release Year}', example: '2009' },
];
const movieIdTokens = [
{ token: '{ImdbId}', example: 'tt12345' },
{ token: '{TmdbId}', example: '123456' },
];
const qualityTokens = [
{ token: '{Quality Full}', example: 'HDTV-720p Proper' },
{ token: '{Quality Title}', example: 'HDTV-720p' },
];
const mediaInfoTokens = [
{ token: '{MediaInfo Simple}', example: 'x264 DTS' },
{ token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]', footNote: true },
{ token: '{MediaInfo AudioCodec}', example: 'DTS' },
{ token: '{MediaInfo AudioChannels}', example: '5.1' },
{ token: '{MediaInfo AudioLanguages}', example: '[EN+DE]', footNote: true },
{ token: '{MediaInfo SubtitleLanguages}', example: '[DE]', footNote: true },
{ token: '{MediaInfo VideoCodec}', example: 'x264' },
{ token: '{MediaInfo VideoBitDepth}', example: '10' },
{ token: '{MediaInfo VideoDynamicRange}', example: 'HDR' },
{ token: '{MediaInfo VideoDynamicRangeType}', example: 'DV HDR10' },
{ token: '{MediaInfo 3D}', example: '3D' },
];
const releaseGroupTokens = [
{ token: '{Release Group}', example: 'Rls Grp', footNote: true },
];
const editionTokens = [
{ token: '{Edition Tags}', example: 'IMAX', footNote: true },
];
const customFormatTokens = [
{ token: '{Custom Formats}', example: 'Surround Sound x264' },
{ token: '{Custom Format:FormatName}', example: 'AMZN' },
];
const originalTokens = [
{ token: '{Original Title}', example: 'Movie.Title.HDTV.x264-EVOLVE' },
{ token: '{Original Filename}', example: 'movie title hdtv.x264-Evolve' },
];
interface NamingModalProps {
isOpen: boolean;
name: keyof Pick<NamingConfig, 'standardMovieFormat' | 'movieFolderFormat'>;
value: string;
movie?: boolean;
additional?: boolean;
onInputChange: ({ name, value }: { name: string; value: string }) => void;
onModalClose: () => void;
}
function NamingModal(props: NamingModalProps) {
const {
isOpen,
name,
value,
movie = false,
additional = false,
onInputChange,
onModalClose,
} = props;
const [tokenSeparator, setTokenSeparator] = useState<TokenSeparator>(' ');
const [tokenCase, setTokenCase] = useState<TokenCase>('title');
const [selectionStart, setSelectionStart] = useState<number | null>(null);
const [selectionEnd, setSelectionEnd] = useState<number | null>(null);
const handleTokenSeparatorChange = useCallback(
({ value }: { value: TokenSeparator }) => {
setTokenSeparator(value);
},
[setTokenSeparator]
);
const handleTokenCaseChange = useCallback(
({ value }: { value: TokenCase }) => {
setTokenCase(value);
},
[setTokenCase]
);
const handleInputSelectionChange = useCallback(
(selectionStart: number, selectionEnd: number) => {
setSelectionStart(selectionStart);
setSelectionEnd(selectionEnd);
},
[setSelectionStart, setSelectionEnd]
);
const handleOptionPress = useCallback(
({
isFullFilename,
tokenValue,
}: {
isFullFilename: boolean;
tokenValue: string;
}) => {
if (isFullFilename) {
onInputChange({ name, value: tokenValue });
} else if (selectionStart == null || selectionEnd == null) {
onInputChange({
name,
value: `${value}${tokenValue}`,
});
} else {
const start = value.substring(0, selectionStart);
const end = value.substring(selectionEnd);
const newValue = `${start}${tokenValue}${end}`;
onInputChange({ name, value: newValue });
setSelectionStart(newValue.length - 1);
setSelectionEnd(newValue.length - 1);
}
},
[name, value, selectionEnd, selectionStart, onInputChange]
);
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{movie ? translate('FileNameTokens') : translate('FolderNameTokens')}
</ModalHeader>
<ModalBody>
<div className={styles.namingSelectContainer}>
<SelectInput
className={styles.namingSelect}
name="separator"
value={tokenSeparator}
values={separatorOptions}
onChange={handleTokenSeparatorChange}
/>
<SelectInput
className={styles.namingSelect}
name="case"
value={tokenCase}
values={caseOptions}
onChange={handleTokenCaseChange}
/>
</div>
{movie ? (
<FieldSet legend={translate('FileNames')}>
<div className={styles.groups}>
{fileNameTokens.map(({ token, example }) => (
<NamingOption
key={token}
token={token}
example={example}
isFullFilename={true}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
size={sizes.LARGE}
onPress={handleOptionPress}
/>
))}
</div>
</FieldSet>
) : null}
<FieldSet legend={translate('Movie')}>
<div className={styles.groups}>
{movieTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('MovieFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('MovieID')}>
<div className={styles.groups}>
{movieIdTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
</FieldSet>
{additional ? (
<div>
<FieldSet legend={translate('Quality')}>
<div className={styles.groups}>
{qualityTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
</FieldSet>
<FieldSet legend={translate('MediaInfo')}>
<div className={styles.groups}>
{mediaInfoTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('MediaInfoFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('ReleaseGroup')}>
<div className={styles.groups}>
{releaseGroupTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('ReleaseGroupFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('Edition')}>
<div className={styles.groups}>
{editionTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('EditionFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('CustomFormats')}>
<div className={styles.groups}>
{customFormatTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
</FieldSet>
<FieldSet legend={translate('Original')}>
<div className={styles.groups}>
{originalTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
size={sizes.LARGE}
onPress={handleOptionPress}
/>
);
})}
</div>
</FieldSet>
</div>
) : null}
</ModalBody>
<ModalFooter>
<TextInput
name={name}
value={value}
onChange={onInputChange}
onSelectionChange={handleInputSelectionChange}
/>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
export default NamingModal;

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