1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-04-16 21:15:33 -04:00

Compare commits

...

69 Commits

Author SHA1 Message Date
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
227 changed files with 3735 additions and 2326 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,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '5.10.2'
majorVersion: '5.12.1'
minorVersion: $[counter('minorVersion', 2000)]
radarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(radarrVersion)'

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

@@ -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,7 @@ import IndexerFlag from 'typings/IndexerFlag';
import Notification from 'typings/Notification';
import QualityProfile from 'typings/QualityProfile';
import General from 'typings/Settings/General';
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import UiSettings from 'typings/Settings/UiSettings';
export interface DownloadClientAppState
@@ -49,6 +50,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,
@@ -83,6 +90,7 @@ interface SettingsAppState {
languages: LanguageSettingsAppState;
notifications: NotificationAppState;
qualityProfiles: QualityProfilesAppState;
releaseProfiles: ReleaseProfilesAppState;
ui: UiSettingsAppState;
}

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

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

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

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

@@ -13,33 +13,6 @@ 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 {
//
@@ -103,6 +76,14 @@ class Naming extends Component {
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 = [];
@@ -112,17 +93,13 @@ class Naming extends Component {
if (examples.movieExample) {
standardMovieFormatHelpTexts.push(`${translate('Movie')}: ${examples.movieExample}`);
} else {
standardMovieFormatErrors.push({ get message() {
return translate('MovieInvalidFormat');
} });
standardMovieFormatErrors.push({ message: translate('MovieInvalidFormat') });
}
if (examples.movieFolderExample) {
movieFolderFormatHelpTexts.push(`${translate('Example')}: ${examples.movieFolderExample}`);
} else {
movieFolderFormatErrors.push({ get message() {
return translate('InvalidFormat');
} });
movieFolderFormatErrors.push({ message: translate('InvalidFormat') });
}
}

View File

@@ -1,4 +1,3 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
@@ -15,11 +14,11 @@ function createMapStateToProps() {
(state) => state.settings.advancedSettings,
(state) => state.settings.namingExamples,
createSettingsSectionSelector(SECTION),
(advancedSettings, examples, sectionSettings) => {
(advancedSettings, namingExamples, sectionSettings) => {
return {
advancedSettings,
examples: examples.item,
examplesPopulated: !_.isEmpty(examples.item),
examples: namingExamples.item,
examplesPopulated: namingExamples.isPopulated,
...sectionSettings
};
}

View File

@@ -75,7 +75,9 @@ 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 CleanTitle:DE}', example: 'Titel des Films', footNote: 1 },
{ token: '{Movie TitleThe}', example: 'Movie\'s Title, The', footNote: 1 },
{ token: '{Movie CleanTitleThe}', example: 'Movies Title, The', footNote: 1 },
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
{ token: '{Movie CleanOriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
{ token: '{Movie TitleFirstCharacter}', example: 'M' },

View File

@@ -7,7 +7,7 @@ import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import DelayProfilesConnector from './Delay/DelayProfilesConnector';
import QualityProfilesConnector from './Quality/QualityProfilesConnector';
import ReleaseProfilesConnector from './Release/ReleaseProfilesConnector';
import ReleaseProfiles from './Release/ReleaseProfiles';
// Only a single DragDrop Context can exist so it's done here to allow editing
// quality profiles and reordering delay profiles to work.
@@ -26,7 +26,7 @@ class Profiles extends Component {
<DndProvider options={HTML5toTouch}>
<QualityProfilesConnector />
<DelayProfilesConnector />
<ReleaseProfilesConnector />
<ReleaseProfiles />
</DndProvider>
</PageContentBody>
</PageContent>

View File

@@ -125,6 +125,7 @@ class EditQualityProfileModalContent extends Component {
upgradeAllowed,
cutoff,
minFormatScore,
minUpgradeFormatScore,
cutoffFormatScore,
language,
items,
@@ -249,6 +250,25 @@ class EditQualityProfileModalContent extends Component {
</FormGroup>
}
{
upgradeAllowed.value && formatItems.value.length > 0 ?
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('MinimumCustomFormatScoreIncrement')}
</FormLabel>
<FormInputGroup
type={inputTypes.NUMBER}
name="minUpgradeFormatScore"
min={1}
{...minUpgradeFormatScore}
helpText={translate('MinimumCustomFormatScoreIncrementHelpText')}
onChange={onInputChange}
/>
</FormGroup> :
null
}
<FormGroup size={sizes.EXTRA_SMALL}>
<FormLabel size={sizes.SMALL}>
{translate('Language')}

View File

@@ -1,27 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import EditReleaseProfileModalContentConnector from './EditReleaseProfileModalContentConnector';
function EditReleaseProfileModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditReleaseProfileModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditReleaseProfileModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditReleaseProfileModal;

View File

@@ -0,0 +1,41 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditReleaseProfileModalContent from './EditReleaseProfileModalContent';
interface EditReleaseProfileModalProps {
id?: number;
isOpen: boolean;
onModalClose: () => void;
onDeleteReleaseProfilePress?: () => void;
}
function EditReleaseProfileModal({
isOpen,
onModalClose,
...otherProps
}: EditReleaseProfileModalProps) {
const dispatch = useDispatch();
const handleModalClose = useCallback(() => {
dispatch(
clearPendingChanges({
section: 'settings.releaseProfiles',
})
);
onModalClose();
}, [dispatch, onModalClose]);
return (
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}>
<EditReleaseProfileModalContent
{...otherProps}
onModalClose={handleModalClose}
/>
</Modal>
);
}
export default EditReleaseProfileModal;

View File

@@ -1,39 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditReleaseProfileModal from './EditReleaseProfileModal';
const mapDispatchToProps = {
clearPendingChanges
};
class EditReleaseProfileModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'settings.releaseProfiles' });
this.props.onModalClose();
};
//
// Render
render() {
return (
<EditReleaseProfileModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditReleaseProfileModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(null, mapDispatchToProps)(EditReleaseProfileModalConnector);

View File

@@ -1,5 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
@@ -10,33 +12,97 @@ 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 usePrevious from 'Helpers/Hooks/usePrevious';
import { inputTypes, kinds } from 'Helpers/Props';
import {
saveReleaseProfile,
setReleaseProfileValue,
} from 'Store/Actions/Settings/releaseProfiles';
import selectSettings from 'Store/Selectors/selectSettings';
import { PendingSection } from 'typings/pending';
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import translate from 'Utilities/String/translate';
import styles from './EditReleaseProfileModalContent.css';
const tagInputDelimiters = ['Tab', 'Enter'];
function EditReleaseProfileModalContent(props) {
const {
isSaving,
saveError,
item,
onInputChange,
onModalClose,
onSavePress,
onDeleteReleaseProfilePress,
...otherProps
} = props;
const newReleaseProfile = {
enabled: true,
required: [],
ignored: [],
tags: [],
indexerId: 0,
};
const {
id,
name,
enabled,
required,
ignored,
tags,
indexerId
} = item;
function createReleaseProfileSelector(id?: number) {
return createSelector(
(state: AppState) => state.settings.releaseProfiles,
(releaseProfiles) => {
const { items, isFetching, error, isSaving, saveError, pendingChanges } =
releaseProfiles;
const mapping = id ? items.find((i) => i.id === id) : newReleaseProfile;
const settings = selectSettings(mapping, pendingChanges, saveError);
return {
id,
isFetching,
error,
isSaving,
saveError,
item: settings.settings as PendingSection<ReleaseProfile>,
...settings,
};
}
);
}
interface EditReleaseProfileModalContentProps {
id?: number;
onModalClose: () => void;
onDeleteReleaseProfilePress?: () => void;
}
function EditReleaseProfileModalContent({
id,
onModalClose,
onDeleteReleaseProfilePress,
}: EditReleaseProfileModalContentProps) {
const { item, isFetching, isSaving, error, saveError, ...otherProps } =
useSelector(createReleaseProfileSelector(id));
const { name, enabled, required, ignored, tags, indexerId } = item;
const dispatch = useDispatch();
const previousIsSaving = usePrevious(isSaving);
useEffect(() => {
if (!id) {
Object.entries(newReleaseProfile).forEach(([name, value]) => {
// @ts-expect-error 'setReleaseProfileValue' isn't typed yet
dispatch(setReleaseProfileValue({ name, value }));
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (previousIsSaving && !isSaving && !saveError) {
onModalClose();
}
}, [previousIsSaving, isSaving, saveError, onModalClose]);
const handleSavePress = useCallback(() => {
dispatch(saveReleaseProfile({ id }));
}, [dispatch, id]);
const handleInputChange = useCallback(
(payload: { name: string; value: string | number }) => {
// @ts-expect-error 'setReleaseProfileValue' isn't typed yet
dispatch(setReleaseProfileValue(payload));
},
[dispatch]
);
return (
<ModalContent onModalClose={onModalClose}>
@@ -46,7 +112,6 @@ function EditReleaseProfileModalContent(props) {
<ModalBody>
<Form {...otherProps}>
<FormGroup>
<FormLabel>{translate('Name')}</FormLabel>
@@ -56,7 +121,7 @@ function EditReleaseProfileModalContent(props) {
{...name}
placeholder={translate('OptionalName')}
canEdit={true}
onChange={onInputChange}
onChange={handleInputChange}
/>
</FormGroup>
@@ -68,7 +133,7 @@ function EditReleaseProfileModalContent(props) {
name="enabled"
helpText={translate('EnableProfileHelpText')}
{...enabled}
onChange={onInputChange}
onChange={handleInputChange}
/>
</FormGroup>
@@ -85,7 +150,7 @@ function EditReleaseProfileModalContent(props) {
placeholder={translate('AddNewRestriction')}
delimiters={tagInputDelimiters}
canEdit={true}
onChange={onInputChange}
onChange={handleInputChange}
/>
</FormGroup>
@@ -102,7 +167,7 @@ function EditReleaseProfileModalContent(props) {
placeholder={translate('AddNewRestriction')}
delimiters={tagInputDelimiters}
canEdit={true}
onChange={onInputChange}
onChange={handleInputChange}
/>
</FormGroup>
@@ -113,10 +178,12 @@ function EditReleaseProfileModalContent(props) {
type={inputTypes.INDEXER_SELECT}
name="indexerId"
helpText={translate('ReleaseProfileIndexerHelpText')}
helpTextWarning={translate('ReleaseProfileIndexerHelpTextWarning')}
helpTextWarning={translate(
'ReleaseProfileIndexerHelpTextWarning'
)}
{...indexerId}
includeAny={true}
onChange={onInputChange}
onChange={handleInputChange}
/>
</FormGroup>
@@ -128,33 +195,28 @@ function EditReleaseProfileModalContent(props) {
name="tags"
helpText={translate('ReleaseProfileTagMovieHelpText')}
{...tags}
onChange={onInputChange}
onChange={handleInputChange}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteReleaseProfilePress}
>
{translate('Delete')}
</Button>
}
{id ? (
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteReleaseProfilePress}
>
{translate('Delete')}
</Button>
) : null}
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
onPress={handleSavePress}
>
{translate('Save')}
</SpinnerErrorButton>
@@ -163,14 +225,4 @@ function EditReleaseProfileModalContent(props) {
);
}
EditReleaseProfileModalContent.propTypes = {
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onDeleteReleaseProfilePress: PropTypes.func
};
export default EditReleaseProfileModalContent;

View File

@@ -1,112 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { saveReleaseProfile, setReleaseProfileValue } from 'Store/Actions/settingsActions';
import selectSettings from 'Store/Selectors/selectSettings';
import EditReleaseProfileModalContent from './EditReleaseProfileModalContent';
const newReleaseProfile = {
enabled: true,
required: [],
ignored: [],
tags: [],
indexerId: 0
};
function createMapStateToProps() {
return createSelector(
(state, { id }) => id,
(state) => state.settings.releaseProfiles,
(id, releaseProfiles) => {
const {
isFetching,
error,
isSaving,
saveError,
pendingChanges,
items
} = releaseProfiles;
const profile = id ? items.find((i) => i.id === id) : newReleaseProfile;
const settings = selectSettings(profile, pendingChanges, saveError);
return {
id,
isFetching,
error,
isSaving,
saveError,
item: settings.settings,
...settings
};
}
);
}
const mapDispatchToProps = {
setReleaseProfileValue,
saveReleaseProfile
};
class EditReleaseProfileModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.id) {
Object.keys(newReleaseProfile).forEach((name) => {
this.props.setReleaseProfileValue({
name,
value: newReleaseProfile[name]
});
});
}
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setReleaseProfileValue({ name, value });
};
onSavePress = () => {
this.props.saveReleaseProfile({ id: this.props.id });
};
//
// Render
render() {
return (
<EditReleaseProfileModalContent
{...this.props}
onSavePress={this.onSavePress}
onTestPress={this.onTestPress}
onInputChange={this.onInputChange}
onFieldChange={this.onFieldChange}
/>
);
}
}
EditReleaseProfileModalContentConnector.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setReleaseProfileValue: PropTypes.func.isRequired,
saveReleaseProfile: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditReleaseProfileModalContentConnector);

View File

@@ -1,197 +0,0 @@
import PropTypes from 'prop-types';
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 translate from 'Utilities/String/translate';
import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector';
import styles from './ReleaseProfile.css';
class ReleaseProfile extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditReleaseProfileModalOpen: false,
isDeleteReleaseProfileModalOpen: false
};
}
//
// Listeners
onEditReleaseProfilePress = () => {
this.setState({ isEditReleaseProfileModalOpen: true });
};
onEditReleaseProfileModalClose = () => {
this.setState({ isEditReleaseProfileModalOpen: false });
};
onDeleteReleaseProfilePress = () => {
this.setState({
isEditReleaseProfileModalOpen: false,
isDeleteReleaseProfileModalOpen: true
});
};
onDeleteReleaseProfileModalClose = () => {
this.setState({ isDeleteReleaseProfileModalOpen: false });
};
onConfirmDeleteReleaseProfile = () => {
this.props.onConfirmDeleteReleaseProfile(this.props.id);
};
//
// Render
render() {
const {
id,
name,
enabled,
required,
ignored,
tags,
indexerId,
tagList,
indexerList
} = this.props;
const {
isEditReleaseProfileModalOpen,
isDeleteReleaseProfileModalOpen
} = this.state;
const indexer = indexerId !== 0 && indexerList.find((i) => i.id === indexerId);
return (
<Card
className={styles.releaseProfile}
overlayContent={true}
onPress={this.onEditReleaseProfilePress}
>
{
name ?
<div className={styles.name}>
{name}
</div> :
null
}
<div>
{
required.map((item) => {
if (!item) {
return null;
}
return (
<Label
className={styles.label}
key={item}
kind={kinds.SUCCESS}
>
{item}
</Label>
);
})
}
</div>
<div>
{
ignored.map((item) => {
if (!item) {
return null;
}
return (
<Label
className={styles.label}
key={item}
kind={kinds.DANGER}
>
{item}
</Label>
);
})
}
</div>
<TagList
tags={tags}
tagList={tagList}
/>
<div>
{
!enabled &&
<Label
kind={kinds.DISABLED}
outline={true}
>
{translate('Disabled')}
</Label>
}
{
indexer &&
<Label
kind={kinds.INFO}
outline={true}
>
{indexer.name}
</Label>
}
</div>
<EditReleaseProfileModalConnector
id={id}
isOpen={isEditReleaseProfileModalOpen}
onModalClose={this.onEditReleaseProfileModalClose}
onDeleteReleaseProfilePress={this.onDeleteReleaseProfilePress}
/>
<ConfirmModal
isOpen={isDeleteReleaseProfileModalOpen}
kind={kinds.DANGER}
title={translate('DeleteReleaseProfile')}
message={translate('DeleteReleaseProfileMessageText', { name })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteReleaseProfile}
onCancel={this.onDeleteReleaseProfileModalClose}
/>
</Card>
);
}
}
ReleaseProfile.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string,
enabled: PropTypes.bool.isRequired,
required: PropTypes.arrayOf(PropTypes.string).isRequired,
ignored: PropTypes.arrayOf(PropTypes.string).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
indexerId: PropTypes.number.isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
indexerList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteReleaseProfile: PropTypes.func.isRequired
};
ReleaseProfile.defaultProps = {
enabled: true,
required: [],
ignored: [],
indexerId: 0
};
export default ReleaseProfile;

View File

@@ -0,0 +1,130 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { Tag } from 'App/State/TagsAppState';
import Card from 'Components/Card';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TagList from 'Components/TagList';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { kinds } from 'Helpers/Props';
import { deleteReleaseProfile } from 'Store/Actions/Settings/releaseProfiles';
import Indexer from 'typings/Indexer';
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import translate from 'Utilities/String/translate';
import EditReleaseProfileModal from './EditReleaseProfileModal';
import styles from './ReleaseProfileRow.css';
interface ReleaseProfileProps extends ReleaseProfile {
tagList: Tag[];
indexerList: Indexer[];
}
function ReleaseProfileRow(props: ReleaseProfileProps) {
const {
id,
name,
enabled = true,
required = [],
ignored = [],
tags,
indexerId = 0,
tagList,
indexerList,
} = props;
const dispatch = useDispatch();
const [
isEditReleaseProfileModalOpen,
setEditReleaseProfileModalOpen,
setEditReleaseProfileModalClosed,
] = useModalOpenState(false);
const [
isDeleteReleaseProfileModalOpen,
setDeleteReleaseProfileModalOpen,
setDeleteReleaseProfileModalClosed,
] = useModalOpenState(false);
const handleDeletePress = useCallback(() => {
dispatch(deleteReleaseProfile({ id }));
}, [id, dispatch]);
const indexer =
indexerId !== 0 && indexerList.find((i) => i.id === indexerId);
return (
<Card
className={styles.releaseProfile}
overlayContent={true}
onPress={setEditReleaseProfileModalOpen}
>
{name ? <div className={styles.name}>{name}</div> : null}
<div>
{required.map((item) => {
if (!item) {
return null;
}
return (
<Label key={item} className={styles.label} kind={kinds.SUCCESS}>
{item}
</Label>
);
})}
</div>
<div>
{ignored.map((item) => {
if (!item) {
return null;
}
return (
<Label key={item} className={styles.label} kind={kinds.DANGER}>
{item}
</Label>
);
})}
</div>
<TagList tags={tags} tagList={tagList} />
<div>
{enabled ? null : (
<Label kind={kinds.DISABLED} outline={true}>
{translate('Disabled')}
</Label>
)}
{indexer ? (
<Label kind={kinds.INFO} outline={true}>
{indexer.name}
</Label>
) : null}
</div>
<EditReleaseProfileModal
id={id}
isOpen={isEditReleaseProfileModalOpen}
onModalClose={setEditReleaseProfileModalClosed}
onDeleteReleaseProfilePress={setDeleteReleaseProfileModalOpen}
/>
<ConfirmModal
isOpen={isDeleteReleaseProfileModalOpen}
kind={kinds.DANGER}
title={translate('DeleteReleaseProfile')}
message={translate('DeleteReleaseProfileMessageText', {
name: name ?? id,
})}
confirmLabel={translate('Delete')}
onConfirm={handleDeletePress}
onCancel={setDeleteReleaseProfileModalClosed}
/>
</Card>
);
}
export default ReleaseProfileRow;

View File

@@ -4,7 +4,7 @@
}
.addReleaseProfile {
composes: releaseProfile from '~./ReleaseProfile.css';
composes: releaseProfile from '~./ReleaseProfileRow.css';
background-color: var(--cardAlternateBackgroundColor);
color: var(--gray);

View File

@@ -1,102 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector';
import ReleaseProfile from './ReleaseProfile';
import styles from './ReleaseProfiles.css';
class ReleaseProfiles extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddReleaseProfileModalOpen: false
};
}
//
// Listeners
onAddReleaseProfilePress = () => {
this.setState({ isAddReleaseProfileModalOpen: true });
};
onAddReleaseProfileModalClose = () => {
this.setState({ isAddReleaseProfileModalOpen: false });
};
//
// Render
render() {
const {
items,
tagList,
indexerList,
onConfirmDeleteReleaseProfile,
...otherProps
} = this.props;
return (
<FieldSet legend={translate('ReleaseProfiles')}>
<PageSectionContent
errorMessage={translate('ReleaseProfilesLoadError')}
{...otherProps}
>
<div className={styles.releaseProfiles}>
<Card
className={styles.addReleaseProfile}
onPress={this.onAddReleaseProfilePress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
{
items.map((item) => {
return (
<ReleaseProfile
key={item.id}
tagList={tagList}
indexerList={indexerList}
{...item}
onConfirmDeleteReleaseProfile={onConfirmDeleteReleaseProfile}
/>
);
})
}
</div>
<EditReleaseProfileModalConnector
isOpen={this.state.isAddReleaseProfileModalOpen}
onModalClose={this.onAddReleaseProfileModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
}
ReleaseProfiles.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
indexerList: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteReleaseProfile: PropTypes.func.isRequired
};
export default ReleaseProfiles;

View File

@@ -0,0 +1,81 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import { ReleaseProfilesAppState } from 'App/State/SettingsAppState';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons } from 'Helpers/Props';
import ReleaseProfileRow from 'Settings/Profiles/Release/ReleaseProfileRow';
import { fetchIndexers } from 'Store/Actions/Settings/indexers';
import { fetchReleaseProfiles } from 'Store/Actions/Settings/releaseProfiles';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import translate from 'Utilities/String/translate';
import EditReleaseProfileModal from './EditReleaseProfileModal';
import styles from './ReleaseProfiles.css';
function ReleaseProfiles() {
const { items, isFetching, isPopulated, error }: ReleaseProfilesAppState =
useSelector(createClientSideCollectionSelector('settings.releaseProfiles'));
const tagList = useSelector(createTagsSelector());
const indexerList = useSelector(
(state: AppState) => state.settings.indexers.items
);
const dispatch = useDispatch();
const [
isAddReleaseProfileModalOpen,
setAddReleaseProfileModalOpen,
setAddReleaseProfileModalClosed,
] = useModalOpenState(false);
useEffect(() => {
dispatch(fetchReleaseProfiles());
dispatch(fetchIndexers());
}, [dispatch]);
return (
<FieldSet legend={translate('ReleaseProfiles')}>
<PageSectionContent
errorMessage={translate('ReleaseProfilesLoadError')}
isFetching={isFetching}
isPopulated={isPopulated}
error={error}
>
<div className={styles.releaseProfiles}>
<Card
className={styles.addReleaseProfile}
onPress={setAddReleaseProfileModalOpen}
>
<div className={styles.center}>
<Icon name={icons.ADD} size={45} />
</div>
</Card>
{items.map((item) => {
return (
<ReleaseProfileRow
key={item.id}
tagList={tagList}
indexerList={indexerList}
{...item}
/>
);
})}
</div>
<EditReleaseProfileModal
isOpen={isAddReleaseProfileModalOpen}
onModalClose={setAddReleaseProfileModalClosed}
/>
</PageSectionContent>
</FieldSet>
);
}
export default ReleaseProfiles;

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